From 8fdc351004dfd2cf520ef0c994c3427efa08c287 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 11:50:09 +0000 Subject: [PATCH 1/6] Rewrite ws_client.py against the documented WebSocket API Replaces the previous order-book/account-only client with a full implementation of the channels documented at https://apidocs.lighter.xyz/docs/websocket-reference. The new WsClient supports every documented channel (order book, ticker, market_stats, spot_market_stats, trade, candle, all account_* streams, user_stats, notification, pool_data, pool_info, height) plus jsonapi/sendtx and jsonapi/sendtxbatch transaction submission. It keeps the existing public surface used in examples/ws.py and examples/ws_async.py (order_book_ids, account_ids, on_order_book_update, on_account_update, run(), run_async()) and adds add_* helpers, async subscribe/unsubscribe, send_tx/send_tx_batch, readonly mode, ping/pong handling, configurable WebSocket keepalive, and optional auto-reconnect. --- lighter/ws_client.py | 887 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 757 insertions(+), 130 deletions(-) diff --git a/lighter/ws_client.py b/lighter/ws_client.py index 1369417..32772f0 100644 --- a/lighter/ws_client.py +++ b/lighter/ws_client.py @@ -1,168 +1,795 @@ +"""Async-first WebSocket client for the Lighter API. + +This module wraps the :mod:`websockets` library to expose a high-level +interface around the channels documented at +https://apidocs.lighter.xyz/docs/websocket-reference. + +Supported channels include order book, ticker, market stats, trades, +candles, account-scoped streams, pool data, height, and notifications. The +client can also send transactions over the ``jsonapi/sendtx`` and +``jsonapi/sendtxbatch`` envelopes. + +Backwards compatibility is preserved with the prior client: instances can +still be constructed with ``order_book_ids`` / ``account_ids`` / +``on_order_book_update`` / ``on_account_update`` and driven with +:meth:`WsClient.run` or :meth:`WsClient.run_async`. +""" + +from __future__ import annotations + +import asyncio import json -from websockets.sync.client import connect -from websockets.client import connect as connect_async +import logging +from contextlib import suppress +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + List, + Mapping, + Optional, + Tuple, + Union, +) + +try: + from websockets.asyncio.client import connect as _ws_connect +except ImportError: # pragma: no cover - websockets 12.x fallback + from websockets.client import connect as _ws_connect + from lighter.configuration import Configuration +logger = logging.getLogger(__name__) + +# Public callback type alias - callbacks may be either sync or async. +Callback = Callable[..., Union[None, Awaitable[None]]] + +# Channels that require an ``auth`` token alongside the subscribe message. +_AUTH_REQUIRED_PREFIXES: Tuple[str, ...] = ( + "account_market/", + "account_tx/", + "account_all_orders/", + "account_orders/", + "account_all_assets/", + "account_spot_avg_entry_prices/", + "notification/", + "pool_data/", + "pool_info/", +) + +# Subscriptions passed to the constructor as a plain channel string or a +# ``(channel, auth)`` tuple. +SubscriptionSpec = Union[str, Tuple[str, Optional[str]]] + + class WsClient: + """High-level async WebSocket client for the Lighter API. + + Parameters + ---------- + host + Hostname of the Lighter API (without scheme). Defaults to the host + of :class:`lighter.Configuration`. + path + WebSocket path (default ``"/stream"``). + readonly + If ``True`` connect with the ``?readonly=true`` query parameter so + the server allows read-only data from restricted regions. + auth + Default auth token used for channels that require authentication + when no per-subscription token is provided. + order_book_ids + Convenience: list of market indices to subscribe to ``order_book``. + account_ids + Convenience: list of account indices to subscribe to ``account_all``. + subscriptions + Additional channels to subscribe to on connect. Each item is either + a channel string (e.g. ``"trade/0"``, ``"candle/0/1m"``, + ``"market_stats/all"``) or a ``(channel, auth)`` tuple. + ping_interval, ping_timeout + Forwarded to :func:`websockets.connect` to keep the connection + alive. The Lighter server closes connections that send no frames + for two minutes, so ``ping_interval`` should remain well below + that. + auto_reconnect + If ``True``, the run loop reconnects when the connection drops. + reconnect_delay + Seconds to wait between reconnect attempts. + on_* + Optional callbacks (sync or async) invoked when an update for the + corresponding channel arrives. Each callback receives the natural + identifier(s) for the channel followed by the full server message. + """ + + DEFAULT_PATH = "/stream" + def __init__( self, - host=None, - path="/stream", - order_book_ids=[], - account_ids=[], - on_order_book_update=print, - on_account_update=print, - ): + host: Optional[str] = None, + path: str = DEFAULT_PATH, + *, + readonly: bool = False, + auth: Optional[str] = None, + order_book_ids: Optional[Iterable[int]] = None, + account_ids: Optional[Iterable[int]] = None, + subscriptions: Optional[Iterable[SubscriptionSpec]] = None, + ping_interval: Optional[float] = 30.0, + ping_timeout: Optional[float] = 60.0, + auto_reconnect: bool = False, + reconnect_delay: float = 1.0, + on_order_book_update: Optional[Callback] = None, + on_account_update: Optional[Callback] = None, + on_ticker_update: Optional[Callback] = None, + on_market_stats_update: Optional[Callback] = None, + on_spot_market_stats_update: Optional[Callback] = None, + on_trade_update: Optional[Callback] = None, + on_candle_update: Optional[Callback] = None, + on_account_market_update: Optional[Callback] = None, + on_account_all_orders_update: Optional[Callback] = None, + on_account_orders_update: Optional[Callback] = None, + on_account_all_trades_update: Optional[Callback] = None, + on_account_all_positions_update: Optional[Callback] = None, + on_account_all_assets_update: Optional[Callback] = None, + on_account_spot_avg_entry_prices_update: Optional[Callback] = None, + on_account_tx_update: Optional[Callback] = None, + on_user_stats_update: Optional[Callback] = None, + on_notification_update: Optional[Callback] = None, + on_pool_data_update: Optional[Callback] = None, + on_pool_info_update: Optional[Callback] = None, + on_height_update: Optional[Callback] = None, + on_tx_response: Optional[Callback] = None, + on_message: Optional[Callback] = None, + ) -> None: if host is None: - host = Configuration.get_default().host.replace("https://", "") + default_host = Configuration.get_default().host + host = default_host.replace("https://", "").replace("http://", "") - self.base_url = f"wss://{host}{path}" + self.host = host + self.path = path + self.readonly = readonly + self.auth = auth + self.ping_interval = ping_interval + self.ping_timeout = ping_timeout + self.auto_reconnect = auto_reconnect + self.reconnect_delay = reconnect_delay - self.subscriptions = { - "order_books": order_book_ids, - "accounts": account_ids, - } + query = "?readonly=true" if readonly else "" + self.base_url = f"wss://{host}{path}{query}" - if len(order_book_ids) == 0 and len(account_ids) == 0: - raise Exception("No subscriptions provided.") + # Map of channel -> optional explicit auth token to send on subscribe. + self._subscriptions: Dict[str, Optional[str]] = {} - self.order_book_states = {} - self.account_states = {} + for market_id in order_book_ids or []: + self._subscriptions[f"order_book/{int(market_id)}"] = None + for account_id in account_ids or []: + self._subscriptions[f"account_all/{int(account_id)}"] = None + for spec in subscriptions or []: + channel, token = _normalize_subscription(spec) + self._subscriptions[channel] = token + + # Locally cached state. ``order_book_states`` reflects the merged + # snapshot+diffs for each market, while ``account_states`` mirrors + # the most recent ``account_all`` payload. + self.order_book_states: Dict[int, Dict[str, List[Dict[str, Any]]]] = {} + self.account_states: Dict[int, Dict[str, Any]] = {} self.on_order_book_update = on_order_book_update self.on_account_update = on_account_update + self.on_ticker_update = on_ticker_update + self.on_market_stats_update = on_market_stats_update + self.on_spot_market_stats_update = on_spot_market_stats_update + self.on_trade_update = on_trade_update + self.on_candle_update = on_candle_update + self.on_account_market_update = on_account_market_update + self.on_account_all_orders_update = on_account_all_orders_update + self.on_account_orders_update = on_account_orders_update + self.on_account_all_trades_update = on_account_all_trades_update + self.on_account_all_positions_update = on_account_all_positions_update + self.on_account_all_assets_update = on_account_all_assets_update + self.on_account_spot_avg_entry_prices_update = ( + on_account_spot_avg_entry_prices_update + ) + self.on_account_tx_update = on_account_tx_update + self.on_user_stats_update = on_user_stats_update + self.on_notification_update = on_notification_update + self.on_pool_data_update = on_pool_data_update + self.on_pool_info_update = on_pool_info_update + self.on_height_update = on_height_update + self.on_tx_response = on_tx_response + self.on_message = on_message - self.ws = None + self.ws: Optional[Any] = None + self._send_lock: Optional[asyncio.Lock] = None + self._stopped = False - def on_message(self, ws, message): - if isinstance(message, str): - message = json.loads(message) + # ------------------------------------------------------------------ + # Subscription registration (pre-connect, synchronous) + # ------------------------------------------------------------------ - message_type = message.get("type") + def add_subscription( + self, channel: str, *, auth: Optional[str] = None + ) -> None: + """Queue a channel to be subscribed to once :meth:`run_async` connects.""" + self._subscriptions[channel] = auth - if message_type == "connected": - self.handle_connected(ws) - elif message_type == "subscribed/order_book": - self.handle_subscribed_order_book(message) - elif message_type == "update/order_book": - self.handle_update_order_book(message) - elif message_type == "subscribed/account_all": - self.handle_subscribed_account(message) - elif message_type == "update/account_all": - self.handle_update_account(message) - elif message_type == "ping": - # Respond to ping with pong - ws.send(json.dumps({"type": "pong"})) - else: - self.handle_unhandled_message(message) + def add_order_book(self, market_id: int) -> None: + self.add_subscription(f"order_book/{int(market_id)}") - async def on_message_async(self, ws, message): - message = json.loads(message) - message_type = message.get("type") + def add_ticker(self, market_id: int) -> None: + self.add_subscription(f"ticker/{int(market_id)}") - if message_type == "connected": - await self.handle_connected_async(ws) - elif message_type == "ping": - # Respond to ping with pong - await ws.send(json.dumps({"type": "pong"})) - else: - self.on_message(ws, message) + def add_market_stats(self, market_id: Union[int, str] = "all") -> None: + self.add_subscription(f"market_stats/{market_id}") - def handle_connected(self, ws): - for market_id in self.subscriptions["order_books"]: - ws.send( - json.dumps({"type": "subscribe", "channel": f"order_book/{market_id}"}) - ) - for account_id in self.subscriptions["accounts"]: - ws.send( - json.dumps( - {"type": "subscribe", "channel": f"account_all/{account_id}"} + def add_spot_market_stats(self, market_id: Union[int, str] = "all") -> None: + self.add_subscription(f"spot_market_stats/{market_id}") + + def add_trade(self, market_id: int) -> None: + self.add_subscription(f"trade/{int(market_id)}") + + def add_candle(self, market_id: int, resolution: str) -> None: + self.add_subscription(f"candle/{int(market_id)}/{resolution}") + + def add_height(self) -> None: + self.add_subscription("height") + + def add_account_all( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription(f"account_all/{int(account_id)}", auth=auth) + + def add_account_market( + self, + market_id: int, + account_id: int, + *, + auth: Optional[str] = None, + ) -> None: + self.add_subscription( + f"account_market/{int(market_id)}/{int(account_id)}", auth=auth + ) + + def add_account_all_orders( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription( + f"account_all_orders/{int(account_id)}", auth=auth + ) + + def add_account_orders( + self, + market_id: int, + account_id: int, + *, + auth: Optional[str] = None, + ) -> None: + self.add_subscription( + f"account_orders/{int(market_id)}/{int(account_id)}", auth=auth + ) + + def add_account_all_trades(self, account_id: int) -> None: + self.add_subscription(f"account_all_trades/{int(account_id)}") + + def add_account_all_positions(self, account_id: int) -> None: + self.add_subscription(f"account_all_positions/{int(account_id)}") + + def add_account_all_assets( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription( + f"account_all_assets/{int(account_id)}", auth=auth + ) + + def add_account_spot_avg_entry_prices( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription( + f"account_spot_avg_entry_prices/{int(account_id)}", auth=auth + ) + + def add_account_tx( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription(f"account_tx/{int(account_id)}", auth=auth) + + def add_user_stats(self, account_id: int) -> None: + self.add_subscription(f"user_stats/{int(account_id)}") + + def add_notification( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription(f"notification/{int(account_id)}", auth=auth) + + def add_pool_data( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription(f"pool_data/{int(account_id)}", auth=auth) + + def add_pool_info( + self, account_id: int, *, auth: Optional[str] = None + ) -> None: + self.add_subscription(f"pool_info/{int(account_id)}", auth=auth) + + @property + def subscriptions(self) -> Dict[str, Optional[str]]: + """A copy of the registered subscriptions (channel -> auth token).""" + return dict(self._subscriptions) + + # ------------------------------------------------------------------ + # Async send helpers (runtime) + # ------------------------------------------------------------------ + + async def send_json(self, message: Mapping[str, Any]) -> None: + """Send a JSON-encoded message on the open WebSocket connection.""" + ws = self.ws + if ws is None: + raise RuntimeError("WebSocket is not connected") + if self._send_lock is None: + self._send_lock = asyncio.Lock() + async with self._send_lock: + await ws.send(json.dumps(message)) + + async def subscribe( + self, channel: str, auth: Optional[str] = None + ) -> None: + """Subscribe to ``channel`` at runtime. + + The subscription is also remembered so that it will be re-sent if + :attr:`auto_reconnect` triggers a reconnect. + """ + token = auth if auth is not None else self._auth_for_channel(channel) + payload: Dict[str, Any] = {"type": "subscribe", "channel": channel} + if token is not None: + payload["auth"] = token + await self.send_json(payload) + self._subscriptions[channel] = auth + + async def unsubscribe(self, channel: str) -> None: + """Unsubscribe from ``channel`` at runtime and drop any cached state.""" + await self.send_json({"type": "unsubscribe", "channel": channel}) + self._subscriptions.pop(channel, None) + if channel.startswith("order_book/"): + with suppress(ValueError): + market_id = int(channel.split("/", 1)[1]) + self.order_book_states.pop(market_id, None) + elif channel.startswith("account_all/"): + with suppress(ValueError): + account_id = int(channel.split("/", 1)[1]) + self.account_states.pop(account_id, None) + + async def send_tx( + self, + tx_type: int, + tx_info: Union[str, Mapping[str, Any]], + *, + id: Optional[str] = None, + ) -> None: + """Send a signed transaction via ``jsonapi/sendtx``. + + ``tx_info`` may be the JSON-encoded string returned by the signer + helpers or an already-parsed mapping. + """ + data: Dict[str, Any] = { + "tx_type": int(tx_type), + "tx_info": _decode_tx_info(tx_info), + } + if id is not None: + data["id"] = id + await self.send_json({"type": "jsonapi/sendtx", "data": data}) + + async def send_tx_batch( + self, + tx_types: Iterable[int], + tx_infos: Iterable[Union[str, Mapping[str, Any]]], + *, + id: Optional[str] = None, + ) -> None: + """Send a batch of signed transactions via ``jsonapi/sendtxbatch``. + + The server expects ``tx_types`` and ``tx_infos`` as JSON-encoded + string arrays; this method matches that wire format so it accepts + the same ``tx_info`` strings produced by the signer helpers. + """ + info_strings: List[str] = [] + for info in tx_infos: + if isinstance(info, str): + info_strings.append(info) + else: + info_strings.append(json.dumps(info)) + data: Dict[str, Any] = { + "tx_types": json.dumps([int(t) for t in tx_types]), + "tx_infos": json.dumps(info_strings), + } + if id is not None: + data["id"] = id + await self.send_json({"type": "jsonapi/sendtxbatch", "data": data}) + + def _auth_for_channel(self, channel: str) -> Optional[str]: + stored = self._subscriptions.get(channel) + if stored is not None: + return stored + if channel.startswith(_AUTH_REQUIRED_PREFIXES): + return self.auth + return None + + # ------------------------------------------------------------------ + # Run loop + # ------------------------------------------------------------------ + + def run(self) -> None: + """Synchronous wrapper that drives :meth:`run_async` to completion.""" + asyncio.run(self.run_async()) + + async def run_async(self) -> None: + """Connect, dispatch messages, and (optionally) reconnect on errors.""" + self._stopped = False + while not self._stopped: + try: + await self._connect_and_consume() + except asyncio.CancelledError: + raise + except Exception as exc: + if not self.auto_reconnect or self._stopped: + raise + logger.warning( + "WebSocket disconnected: %s; reconnecting in %.1fs", + exc, + self.reconnect_delay, ) + await asyncio.sleep(self.reconnect_delay) + continue + if not self.auto_reconnect: + return + + async def close(self) -> None: + """Stop the run loop and close the WebSocket connection.""" + self._stopped = True + ws = self.ws + if ws is not None: + with suppress(Exception): + await ws.close() + + async def _connect_and_consume(self) -> None: + async with _ws_connect( + self.base_url, + ping_interval=self.ping_interval, + ping_timeout=self.ping_timeout, + ) as ws: + self.ws = ws + self._send_lock = asyncio.Lock() + try: + await self._send_all_subscriptions() + async for raw in ws: + if isinstance(raw, bytes): + # Lighter sends JSON text; ignore unexpected binary + # frames so callers can still rely on dict payloads. + logger.debug("Ignoring binary WebSocket frame") + continue + await self._dispatch(json.loads(raw)) + finally: + self.ws = None + self._send_lock = None + + async def _send_all_subscriptions(self) -> None: + for channel, explicit_auth in list(self._subscriptions.items()): + payload: Dict[str, Any] = {"type": "subscribe", "channel": channel} + token = ( + explicit_auth + if explicit_auth is not None + else self._auth_for_channel(channel) ) + if token is not None: + payload["auth"] = token + await self.send_json(payload) + + # ------------------------------------------------------------------ + # Message dispatch + # ------------------------------------------------------------------ + + async def _dispatch(self, message: Dict[str, Any]) -> None: + message_type = message.get("type", "") - async def handle_connected_async(self, ws): - for market_id in self.subscriptions["order_books"]: - await ws.send( - json.dumps({"type": "subscribe", "channel": f"order_book/{market_id}"}) + if message_type == "ping": + await self.send_json({"type": "pong"}) + return + if message_type == "pong": + return + if message_type == "connected": + # The server's welcome message arrives once per connection. + # Subscriptions are already sent eagerly on connect. + await self._call(self.on_message, message) + return + if message_type.startswith("jsonapi/"): + await self._call(self.on_tx_response, message) + return + + kind = _channel_kind(message_type) + if kind is None: + await self._call(self.on_message, message) + return + + handler = getattr(self, f"_handle_{kind}", None) + if handler is None: + await self._call(self.on_message, message) + return + await handler(message) + + # ------------------------------------------------------------------ + # Per-channel handlers + # ------------------------------------------------------------------ + + async def _handle_order_book(self, message: Dict[str, Any]) -> None: + market_id = _parse_id(message.get("channel", "")) + if market_id is None: + return + order_book = message.get("order_book") or {} + if message.get("type") == "subscribed/order_book": + self.order_book_states[market_id] = { + "asks": list(order_book.get("asks") or []), + "bids": list(order_book.get("bids") or []), + } + else: + state = self.order_book_states.setdefault( + market_id, {"asks": [], "bids": []} ) - for account_id in self.subscriptions["accounts"]: - await ws.send( - json.dumps( - {"type": "subscribe", "channel": f"account_all/{account_id}"} - ) + _apply_order_book_diff( + order_book.get("asks") or [], state["asks"] + ) + _apply_order_book_diff( + order_book.get("bids") or [], state["bids"] ) + await self._call( + self.on_order_book_update, + market_id, + self.order_book_states[market_id], + ) + + async def _handle_account_all(self, message: Dict[str, Any]) -> None: + account_id = _parse_id(message.get("channel", "")) + if account_id is None: + return + self.account_states[account_id] = message + await self._call(self.on_account_update, account_id, message) + + async def _handle_ticker(self, message: Dict[str, Any]) -> None: + market_id = _parse_id(message.get("channel", "")) + await self._call(self.on_ticker_update, market_id, message) - def handle_subscribed_order_book(self, message): - market_id = message["channel"].split(":")[1] - self.order_book_states[market_id] = message["order_book"] - if self.on_order_book_update: - self.on_order_book_update(market_id, self.order_book_states[market_id]) - - def handle_update_order_book(self, message): - market_id = message["channel"].split(":")[1] - self.update_order_book_state(market_id, message["order_book"]) - if self.on_order_book_update: - self.on_order_book_update(market_id, self.order_book_states[market_id]) - - def update_order_book_state(self, market_id, order_book): - self.update_orders( - order_book["asks"], self.order_book_states[market_id]["asks"] + async def _handle_market_stats(self, message: Dict[str, Any]) -> None: + key = _parse_key(message.get("channel", "")) + await self._call(self.on_market_stats_update, key, message) + + async def _handle_spot_market_stats( + self, message: Dict[str, Any] + ) -> None: + key = _parse_key(message.get("channel", "")) + await self._call(self.on_spot_market_stats_update, key, message) + + async def _handle_trade(self, message: Dict[str, Any]) -> None: + market_id = _parse_id(message.get("channel", "")) + await self._call(self.on_trade_update, market_id, message) + + async def _handle_candle(self, message: Dict[str, Any]) -> None: + market_id, resolution = _parse_candle_channel(message.get("channel", "")) + await self._call( + self.on_candle_update, market_id, resolution, message ) - self.update_orders( - order_book["bids"], self.order_book_states[market_id]["bids"] + + async def _handle_account_market(self, message: Dict[str, Any]) -> None: + market_id, account_id = _parse_two_ids(message.get("channel", "")) + await self._call( + self.on_account_market_update, market_id, account_id, message ) - def update_orders(self, new_orders, existing_orders): - for new_order in new_orders: - is_new_order = True - # iterate over a copy so removal is safe - existing_order_copy = existing_orders[:] - for existing_order in existing_order_copy: - if new_order["price"] == existing_order["price"]: - is_new_order = False - existing_order["size"] = new_order["size"] - if float(new_order["size"]) == 0: - existing_orders.remove(existing_order) - - if is_new_order: - existing_orders.append(new_order) - - # final cleanup (in-place) - existing_orders[:] = [ - order for order in existing_orders if float(order["size"]) > 0 - ] - - def handle_subscribed_account(self, message): - account_id = message["channel"].split(":")[1] - self.account_states[account_id] = message - if self.on_account_update: - self.on_account_update(account_id, self.account_states[account_id]) + async def _handle_account_orders(self, message: Dict[str, Any]) -> None: + market_id, account_id = _parse_two_ids(message.get("channel", "")) + await self._call( + self.on_account_orders_update, market_id, account_id, message + ) - def handle_update_account(self, message): - account_id = message["channel"].split(":")[1] - self.account_states[account_id] = message - if self.on_account_update: - self.on_account_update(account_id, self.account_states[account_id]) + async def _handle_account_all_orders( + self, message: Dict[str, Any] + ) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call( + self.on_account_all_orders_update, account_id, message + ) - def handle_unhandled_message(self, message): - raise Exception(f"Unhandled message: {message}") + async def _handle_account_all_trades( + self, message: Dict[str, Any] + ) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call( + self.on_account_all_trades_update, account_id, message + ) - def on_error(self, ws, error): - raise Exception(f"Error: {error}") + async def _handle_account_all_positions( + self, message: Dict[str, Any] + ) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call( + self.on_account_all_positions_update, account_id, message + ) - def on_close(self, ws, close_status_code, close_msg): - raise Exception(f"Closed: {close_status_code} {close_msg}") + async def _handle_account_all_assets( + self, message: Dict[str, Any] + ) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call( + self.on_account_all_assets_update, account_id, message + ) + + async def _handle_account_spot_avg_entry_prices( + self, message: Dict[str, Any] + ) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call( + self.on_account_spot_avg_entry_prices_update, + account_id, + message, + ) + + async def _handle_account_tx(self, message: Dict[str, Any]) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call(self.on_account_tx_update, account_id, message) + + async def _handle_user_stats(self, message: Dict[str, Any]) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call(self.on_user_stats_update, account_id, message) + + async def _handle_notification(self, message: Dict[str, Any]) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call(self.on_notification_update, account_id, message) + + async def _handle_pool_data(self, message: Dict[str, Any]) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call(self.on_pool_data_update, account_id, message) + + async def _handle_pool_info(self, message: Dict[str, Any]) -> None: + account_id = _parse_id(message.get("channel", "")) + await self._call(self.on_pool_info_update, account_id, message) + + async def _handle_height(self, message: Dict[str, Any]) -> None: + height = message.get("height") + await self._call(self.on_height_update, height, message) + + # ------------------------------------------------------------------ + # Callback invocation helper + # ------------------------------------------------------------------ + + async def _call( + self, callback: Optional[Callback], *args: Any + ) -> None: + if callback is None: + return + result = callback(*args) + if asyncio.iscoroutine(result): + await result + + +# --------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------- + + +def _normalize_subscription(spec: SubscriptionSpec) -> Tuple[str, Optional[str]]: + if isinstance(spec, tuple): + if len(spec) != 2: + raise ValueError( + "subscription tuples must have the form (channel, auth_token)" + ) + channel, token = spec + return str(channel), token + return str(spec), None - def run(self): - ws = connect(self.base_url) - self.ws = ws - for message in ws: - self.on_message(ws, message) +def _channel_kind(message_type: str) -> Optional[str]: + """Return the channel kind for ``subscribed/`` and ``update/``.""" + if not message_type: + return None + for prefix in ("subscribed/", "update/"): + if message_type.startswith(prefix): + return message_type[len(prefix):] + return None - async def run_async(self): - ws = await connect_async(self.base_url) - self.ws = ws - async for message in ws: - await self.on_message_async(ws, message) +def _channel_body(channel: str) -> List[str]: + """Return the parts of the channel that follow the leading name. + + Channel strings appear with either ``/`` or ``:`` separators depending on + whether they come from a subscribe request or a server response. + """ + if not channel: + return [] + normalized = channel.replace("/", ":") + parts = normalized.split(":") + if len(parts) <= 1: + return [] + return parts[1:] + + +def _parse_id(channel: str) -> Optional[int]: + parts = _channel_body(channel) + if not parts: + return None + try: + return int(parts[0]) + except ValueError: + return None + + +def _parse_key(channel: str) -> Union[int, str, None]: + parts = _channel_body(channel) + if not parts: + return None + value = parts[0] + try: + return int(value) + except ValueError: + return value + + +def _parse_two_ids( + channel: str, +) -> Tuple[Optional[int], Optional[int]]: + parts = _channel_body(channel) + first: Optional[int] = None + second: Optional[int] = None + if len(parts) >= 1: + with suppress(ValueError): + first = int(parts[0]) + if len(parts) >= 2: + with suppress(ValueError): + second = int(parts[1]) + return first, second + + +def _parse_candle_channel( + channel: str, +) -> Tuple[Optional[int], Optional[str]]: + parts = _channel_body(channel) + market_id: Optional[int] = None + resolution: Optional[str] = None + if len(parts) >= 1: + with suppress(ValueError): + market_id = int(parts[0]) + if len(parts) >= 2: + resolution = parts[1] + return market_id, resolution + + +def _decode_tx_info( + tx_info: Union[str, Mapping[str, Any]], +) -> Any: + if isinstance(tx_info, str): + return json.loads(tx_info) + return dict(tx_info) + + +def _apply_order_book_diff( + new_orders: List[Dict[str, Any]], + existing_orders: List[Dict[str, Any]], +) -> None: + """Merge ``new_orders`` into ``existing_orders`` in-place using price as key. + + Entries with size ``0`` remove the corresponding price level. The order + of the resulting list reflects the underlying dict insertion order so it + is not guaranteed to be sorted; consumers that need a sorted book should + sort by ``price`` after each update. + """ + by_price: Dict[str, Dict[str, Any]] = { + order["price"]: order for order in existing_orders + } + for new_order in new_orders: + price = new_order["price"] + try: + size = float(new_order["size"]) + except (TypeError, ValueError): + size = 0.0 + if size == 0: + by_price.pop(price, None) + else: + by_price[price] = new_order + existing_orders[:] = list(by_price.values()) From 13fd953a4f0067c0b64e8c9adf8067c67445e28c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 12:35:41 +0000 Subject: [PATCH 2/6] Collapse per-channel API into a single subscribe(channel, on_update) Replaces the 20+ on_X_update constructor params and add_X helper methods with one uniform subscribe(channel, on_update=..., auth=...) entry point. Dispatch looks up the registered subscription by canonical channel name (channels are normalized so callers can pass either ':' or '/' separators). Order book snapshot+diff reconstruction is still performed automatically for any 'order_book/*' subscription and exposed via client.order_book_states[market_id]. Other changes: - subscribe/unsubscribe are sync registration; if called while connected they also send the subscribe/unsubscribe frame via the running loop. - order_book_ids / account_ids constructor shortcuts and the per-channel on_*_update callbacks are removed. examples/ws.py and examples/ws_async.py are rewritten against the new API. - on_message is now the fallback for the 'connected' welcome and any unmatched channels. on_tx_response fires for every 'jsonapi/*' reply. --- examples/ws.py | 24 +- examples/ws_async.py | 27 +- lighter/ws_client.py | 635 ++++++++++--------------------------------- 3 files changed, 181 insertions(+), 505 deletions(-) diff --git a/examples/ws.py b/examples/ws.py index 8fc6445..cfa87be 100644 --- a/examples/ws.py +++ b/examples/ws.py @@ -5,19 +5,23 @@ logging.basicConfig(level=logging.INFO) -def on_order_book_update(market_id, order_book): - logging.info(f"Order book {market_id}:\n{json.dumps(order_book, indent=2)}") +def on_order_book(message): + logging.info( + f"Order book {message['channel']}:\n" + f"{json.dumps(message.get('order_book'), indent=2)}" + ) -def on_account_update(account_id, account): - logging.info(f"Account {account_id}:\n{json.dumps(account, indent=2)}") +def on_account(message): + logging.info( + f"Account {message['channel']}:\n{json.dumps(message, indent=2)}" + ) -client = lighter.WsClient( - order_book_ids=[0, 1], - account_ids=[1, 2], - on_order_book_update=on_order_book_update, - on_account_update=on_account_update, -) +client = lighter.WsClient() +for market_id in [0, 1]: + client.subscribe(f"order_book/{market_id}", on_update=on_order_book) +for account_id in [1, 2]: + client.subscribe(f"account_all/{account_id}", on_update=on_account) client.run() diff --git a/examples/ws_async.py b/examples/ws_async.py index 3262192..67e6e45 100644 --- a/examples/ws_async.py +++ b/examples/ws_async.py @@ -1,24 +1,29 @@ +import asyncio import json import logging -import asyncio + import lighter logging.basicConfig(level=logging.INFO) -def on_order_book_update(market_id, order_book): - logging.info(f"Order book {market_id}:\n{json.dumps(order_book, indent=2)}") +def on_order_book(message): + logging.info( + f"Order book {message['channel']}:\n" + f"{json.dumps(message.get('order_book'), indent=2)}" + ) -def on_account_update(account_id, account): - logging.info(f"Account {account_id}:\n{json.dumps(account, indent=2)}") +def on_account(message): + logging.info( + f"Account {message['channel']}:\n{json.dumps(message, indent=2)}" + ) -client = lighter.WsClient( - order_book_ids=[0, 1], - account_ids=[1, 2], - on_order_book_update=on_order_book_update, - on_account_update=on_account_update, -) +client = lighter.WsClient() +for market_id in [0, 1]: + client.subscribe(f"order_book/{market_id}", on_update=on_order_book) +for account_id in [1, 2]: + client.subscribe(f"account_all/{account_id}", on_update=on_account) asyncio.run(client.run_async()) diff --git a/lighter/ws_client.py b/lighter/ws_client.py index 32772f0..486534c 100644 --- a/lighter/ws_client.py +++ b/lighter/ws_client.py @@ -1,18 +1,18 @@ -"""Async-first WebSocket client for the Lighter API. +"""Async WebSocket client for the Lighter API. -This module wraps the :mod:`websockets` library to expose a high-level -interface around the channels documented at +Implements the channels and message types documented at https://apidocs.lighter.xyz/docs/websocket-reference. -Supported channels include order book, ticker, market stats, trades, -candles, account-scoped streams, pool data, height, and notifications. The -client can also send transactions over the ``jsonapi/sendtx`` and -``jsonapi/sendtxbatch`` envelopes. +The client exposes a single uniform :meth:`WsClient.subscribe` method for +all channels: callers pass the full channel string (e.g. ``"order_book/0"``, +``"trade/0"``, ``"candle/0/1m"``, ``"market_stats/all"``, +``"account_all/123"``) and an ``on_update`` callback. Authenticated +channels receive a default auth token from the client (overridable per +subscription). -Backwards compatibility is preserved with the prior client: instances can -still be constructed with ``order_book_ids`` / ``account_ids`` / -``on_order_book_update`` / ``on_account_update`` and driven with -:meth:`WsClient.run` or :meth:`WsClient.run_async`. +Transaction submission is supported via :meth:`WsClient.send_tx` and +:meth:`WsClient.send_tx_batch` which wrap the ``jsonapi/sendtx`` and +``jsonapi/sendtxbatch`` envelopes. """ from __future__ import annotations @@ -21,10 +21,12 @@ import json import logging from contextlib import suppress +from dataclasses import dataclass from typing import ( Any, Awaitable, Callable, + Coroutine, Dict, Iterable, List, @@ -59,9 +61,14 @@ "pool_info/", ) -# Subscriptions passed to the constructor as a plain channel string or a -# ``(channel, auth)`` tuple. -SubscriptionSpec = Union[str, Tuple[str, Optional[str]]] + +@dataclass +class _Subscription: + """A registered channel subscription.""" + + channel: str + on_update: Optional[Callback] = None + auth: Optional[str] = None class WsClient: @@ -75,32 +82,27 @@ class WsClient: path WebSocket path (default ``"/stream"``). readonly - If ``True`` connect with the ``?readonly=true`` query parameter so - the server allows read-only data from restricted regions. + If ``True`` connect with the ``?readonly=true`` query parameter. auth - Default auth token used for channels that require authentication - when no per-subscription token is provided. - order_book_ids - Convenience: list of market indices to subscribe to ``order_book``. - account_ids - Convenience: list of account indices to subscribe to ``account_all``. - subscriptions - Additional channels to subscribe to on connect. Each item is either - a channel string (e.g. ``"trade/0"``, ``"candle/0/1m"``, - ``"market_stats/all"``) or a ``(channel, auth)`` tuple. + Default auth token used for channels under the documented + auth-required prefixes when no per-subscription token is provided. ping_interval, ping_timeout - Forwarded to :func:`websockets.connect` to keep the connection - alive. The Lighter server closes connections that send no frames - for two minutes, so ``ping_interval`` should remain well below - that. + Forwarded to :func:`websockets.connect` for WebSocket-level + keepalive. The Lighter server closes connections that send no + frames for two minutes, so ``ping_interval`` should remain well + below that. auto_reconnect If ``True``, the run loop reconnects when the connection drops. + Registered subscriptions are re-sent on each reconnect. reconnect_delay Seconds to wait between reconnect attempts. - on_* - Optional callbacks (sync or async) invoked when an update for the - corresponding channel arrives. Each callback receives the natural - identifier(s) for the channel followed by the full server message. + on_message + Optional callback invoked for any server message that does not + match a registered subscription (e.g. the ``connected`` welcome + message or unknown channels). + on_tx_response + Optional callback invoked for every ``jsonapi/*`` server message + (transaction send responses and errors). """ DEFAULT_PATH = "/stream" @@ -108,39 +110,16 @@ class WsClient: def __init__( self, host: Optional[str] = None, - path: str = DEFAULT_PATH, *, + path: str = DEFAULT_PATH, readonly: bool = False, auth: Optional[str] = None, - order_book_ids: Optional[Iterable[int]] = None, - account_ids: Optional[Iterable[int]] = None, - subscriptions: Optional[Iterable[SubscriptionSpec]] = None, ping_interval: Optional[float] = 30.0, ping_timeout: Optional[float] = 60.0, auto_reconnect: bool = False, reconnect_delay: float = 1.0, - on_order_book_update: Optional[Callback] = None, - on_account_update: Optional[Callback] = None, - on_ticker_update: Optional[Callback] = None, - on_market_stats_update: Optional[Callback] = None, - on_spot_market_stats_update: Optional[Callback] = None, - on_trade_update: Optional[Callback] = None, - on_candle_update: Optional[Callback] = None, - on_account_market_update: Optional[Callback] = None, - on_account_all_orders_update: Optional[Callback] = None, - on_account_orders_update: Optional[Callback] = None, - on_account_all_trades_update: Optional[Callback] = None, - on_account_all_positions_update: Optional[Callback] = None, - on_account_all_assets_update: Optional[Callback] = None, - on_account_spot_avg_entry_prices_update: Optional[Callback] = None, - on_account_tx_update: Optional[Callback] = None, - on_user_stats_update: Optional[Callback] = None, - on_notification_update: Optional[Callback] = None, - on_pool_data_update: Optional[Callback] = None, - on_pool_info_update: Optional[Callback] = None, - on_height_update: Optional[Callback] = None, - on_tx_response: Optional[Callback] = None, on_message: Optional[Callback] = None, + on_tx_response: Optional[Callback] = None, ) -> None: if host is None: default_host = Configuration.get_default().host @@ -154,171 +133,80 @@ def __init__( self.ping_timeout = ping_timeout self.auto_reconnect = auto_reconnect self.reconnect_delay = reconnect_delay + self.on_message = on_message + self.on_tx_response = on_tx_response query = "?readonly=true" if readonly else "" self.base_url = f"wss://{host}{path}{query}" - # Map of channel -> optional explicit auth token to send on subscribe. - self._subscriptions: Dict[str, Optional[str]] = {} + # Registered subscriptions keyed by canonical channel name (using + # ``/`` separators throughout for parity with the docs). + self._subscriptions: Dict[str, _Subscription] = {} - for market_id in order_book_ids or []: - self._subscriptions[f"order_book/{int(market_id)}"] = None - for account_id in account_ids or []: - self._subscriptions[f"account_all/{int(account_id)}"] = None - for spec in subscriptions or []: - channel, token = _normalize_subscription(spec) - self._subscriptions[channel] = token - - # Locally cached state. ``order_book_states`` reflects the merged - # snapshot+diffs for each market, while ``account_states`` mirrors - # the most recent ``account_all`` payload. + # Reconstructed order book snapshots, keyed by market_id. The + # client merges snapshot+diff messages for any ``order_book/*`` + # subscription so that callers can read the current book without + # having to maintain state themselves. self.order_book_states: Dict[int, Dict[str, List[Dict[str, Any]]]] = {} - self.account_states: Dict[int, Dict[str, Any]] = {} - - self.on_order_book_update = on_order_book_update - self.on_account_update = on_account_update - self.on_ticker_update = on_ticker_update - self.on_market_stats_update = on_market_stats_update - self.on_spot_market_stats_update = on_spot_market_stats_update - self.on_trade_update = on_trade_update - self.on_candle_update = on_candle_update - self.on_account_market_update = on_account_market_update - self.on_account_all_orders_update = on_account_all_orders_update - self.on_account_orders_update = on_account_orders_update - self.on_account_all_trades_update = on_account_all_trades_update - self.on_account_all_positions_update = on_account_all_positions_update - self.on_account_all_assets_update = on_account_all_assets_update - self.on_account_spot_avg_entry_prices_update = ( - on_account_spot_avg_entry_prices_update - ) - self.on_account_tx_update = on_account_tx_update - self.on_user_stats_update = on_user_stats_update - self.on_notification_update = on_notification_update - self.on_pool_data_update = on_pool_data_update - self.on_pool_info_update = on_pool_info_update - self.on_height_update = on_height_update - self.on_tx_response = on_tx_response - self.on_message = on_message self.ws: Optional[Any] = None self._send_lock: Optional[asyncio.Lock] = None self._stopped = False # ------------------------------------------------------------------ - # Subscription registration (pre-connect, synchronous) + # Subscription management # ------------------------------------------------------------------ - def add_subscription( - self, channel: str, *, auth: Optional[str] = None - ) -> None: - """Queue a channel to be subscribed to once :meth:`run_async` connects.""" - self._subscriptions[channel] = auth - - def add_order_book(self, market_id: int) -> None: - self.add_subscription(f"order_book/{int(market_id)}") - - def add_ticker(self, market_id: int) -> None: - self.add_subscription(f"ticker/{int(market_id)}") - - def add_market_stats(self, market_id: Union[int, str] = "all") -> None: - self.add_subscription(f"market_stats/{market_id}") - - def add_spot_market_stats(self, market_id: Union[int, str] = "all") -> None: - self.add_subscription(f"spot_market_stats/{market_id}") - - def add_trade(self, market_id: int) -> None: - self.add_subscription(f"trade/{int(market_id)}") - - def add_candle(self, market_id: int, resolution: str) -> None: - self.add_subscription(f"candle/{int(market_id)}/{resolution}") - - def add_height(self) -> None: - self.add_subscription("height") - - def add_account_all( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription(f"account_all/{int(account_id)}", auth=auth) - - def add_account_market( - self, - market_id: int, - account_id: int, - *, - auth: Optional[str] = None, - ) -> None: - self.add_subscription( - f"account_market/{int(market_id)}/{int(account_id)}", auth=auth - ) - - def add_account_all_orders( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription( - f"account_all_orders/{int(account_id)}", auth=auth - ) - - def add_account_orders( + def subscribe( self, - market_id: int, - account_id: int, + channel: str, + on_update: Optional[Callback] = None, *, auth: Optional[str] = None, ) -> None: - self.add_subscription( - f"account_orders/{int(market_id)}/{int(account_id)}", auth=auth - ) + """Register a subscription for ``channel``. - def add_account_all_trades(self, account_id: int) -> None: - self.add_subscription(f"account_all_trades/{int(account_id)}") + The subscription is sent to the server on connect (and re-sent on + each reconnect when :attr:`auto_reconnect` is enabled). If the + client is already connected when :meth:`subscribe` is called the + subscribe frame is dispatched immediately via the running event + loop. - def add_account_all_positions(self, account_id: int) -> None: - self.add_subscription(f"account_all_positions/{int(account_id)}") - - def add_account_all_assets( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription( - f"account_all_assets/{int(account_id)}", auth=auth - ) + ``on_update`` is invoked with the full server message dict (both + the initial ``subscribed/...`` snapshot and subsequent + ``update/...`` messages). The callback may be sync or async. - def add_account_spot_avg_entry_prices( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription( - f"account_spot_avg_entry_prices/{int(account_id)}", auth=auth + ``auth`` is sent alongside the subscribe message. If omitted and + ``channel`` is under one of the documented auth-required + prefixes, the client's default :attr:`auth` is used. + """ + canonical = _canonical_channel(channel) + self._subscriptions[canonical] = _Subscription( + channel=canonical, on_update=on_update, auth=auth ) - - def add_account_tx( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription(f"account_tx/{int(account_id)}", auth=auth) - - def add_user_stats(self, account_id: int) -> None: - self.add_subscription(f"user_stats/{int(account_id)}") - - def add_notification( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription(f"notification/{int(account_id)}", auth=auth) - - def add_pool_data( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription(f"pool_data/{int(account_id)}", auth=auth) - - def add_pool_info( - self, account_id: int, *, auth: Optional[str] = None - ) -> None: - self.add_subscription(f"pool_info/{int(account_id)}", auth=auth) + if self.ws is not None: + self._spawn(self._send_subscribe(canonical)) + + def unsubscribe(self, channel: str) -> None: + """Unsubscribe from ``channel`` and discard any cached state.""" + canonical = _canonical_channel(channel) + self._subscriptions.pop(canonical, None) + if canonical.startswith("order_book/"): + with suppress(ValueError): + market_id = int(canonical.split("/", 1)[1]) + self.order_book_states.pop(market_id, None) + if self.ws is not None: + self._spawn( + self.send_json({"type": "unsubscribe", "channel": canonical}) + ) @property - def subscriptions(self) -> Dict[str, Optional[str]]: - """A copy of the registered subscriptions (channel -> auth token).""" + def subscriptions(self) -> Mapping[str, _Subscription]: + """A read-only view of registered subscriptions.""" return dict(self._subscriptions) # ------------------------------------------------------------------ - # Async send helpers (runtime) + # Sending # ------------------------------------------------------------------ async def send_json(self, message: Mapping[str, Any]) -> None: @@ -331,34 +219,6 @@ async def send_json(self, message: Mapping[str, Any]) -> None: async with self._send_lock: await ws.send(json.dumps(message)) - async def subscribe( - self, channel: str, auth: Optional[str] = None - ) -> None: - """Subscribe to ``channel`` at runtime. - - The subscription is also remembered so that it will be re-sent if - :attr:`auto_reconnect` triggers a reconnect. - """ - token = auth if auth is not None else self._auth_for_channel(channel) - payload: Dict[str, Any] = {"type": "subscribe", "channel": channel} - if token is not None: - payload["auth"] = token - await self.send_json(payload) - self._subscriptions[channel] = auth - - async def unsubscribe(self, channel: str) -> None: - """Unsubscribe from ``channel`` at runtime and drop any cached state.""" - await self.send_json({"type": "unsubscribe", "channel": channel}) - self._subscriptions.pop(channel, None) - if channel.startswith("order_book/"): - with suppress(ValueError): - market_id = int(channel.split("/", 1)[1]) - self.order_book_states.pop(market_id, None) - elif channel.startswith("account_all/"): - with suppress(ValueError): - account_id = int(channel.split("/", 1)[1]) - self.account_states.pop(account_id, None) - async def send_tx( self, tx_type: int, @@ -366,11 +226,7 @@ async def send_tx( *, id: Optional[str] = None, ) -> None: - """Send a signed transaction via ``jsonapi/sendtx``. - - ``tx_info`` may be the JSON-encoded string returned by the signer - helpers or an already-parsed mapping. - """ + """Send a signed transaction via ``jsonapi/sendtx``.""" data: Dict[str, Any] = { "tx_type": int(tx_type), "tx_info": _decode_tx_info(tx_info), @@ -388,9 +244,8 @@ async def send_tx_batch( ) -> None: """Send a batch of signed transactions via ``jsonapi/sendtxbatch``. - The server expects ``tx_types`` and ``tx_infos`` as JSON-encoded - string arrays; this method matches that wire format so it accepts - the same ``tx_info`` strings produced by the signer helpers. + ``tx_types`` and ``tx_infos`` are wire-encoded as JSON-encoded + string arrays (the form produced by the signer helpers). """ info_strings: List[str] = [] for info in tx_infos: @@ -406,14 +261,6 @@ async def send_tx_batch( data["id"] = id await self.send_json({"type": "jsonapi/sendtxbatch", "data": data}) - def _auth_for_channel(self, channel: str) -> Optional[str]: - stored = self._subscriptions.get(channel) - if stored is not None: - return stored - if channel.startswith(_AUTH_REQUIRED_PREFIXES): - return self.auth - return None - # ------------------------------------------------------------------ # Run loop # ------------------------------------------------------------------ @@ -460,11 +307,10 @@ async def _connect_and_consume(self) -> None: self.ws = ws self._send_lock = asyncio.Lock() try: - await self._send_all_subscriptions() + for channel in list(self._subscriptions): + await self._send_subscribe(channel) async for raw in ws: if isinstance(raw, bytes): - # Lighter sends JSON text; ignore unexpected binary - # frames so callers can still rely on dict payloads. logger.debug("Ignoring binary WebSocket frame") continue await self._dispatch(json.loads(raw)) @@ -472,17 +318,33 @@ async def _connect_and_consume(self) -> None: self.ws = None self._send_lock = None - async def _send_all_subscriptions(self) -> None: - for channel, explicit_auth in list(self._subscriptions.items()): - payload: Dict[str, Any] = {"type": "subscribe", "channel": channel} - token = ( - explicit_auth - if explicit_auth is not None - else self._auth_for_channel(channel) - ) - if token is not None: - payload["auth"] = token - await self.send_json(payload) + async def _send_subscribe(self, channel: str) -> None: + sub = self._subscriptions.get(channel) + if sub is None: + return + payload: Dict[str, Any] = {"type": "subscribe", "channel": channel} + token = sub.auth if sub.auth is not None else self._default_auth(channel) + if token is not None: + payload["auth"] = token + await self.send_json(payload) + + def _default_auth(self, channel: str) -> Optional[str]: + if channel.startswith(_AUTH_REQUIRED_PREFIXES): + return self.auth + return None + + def _spawn(self, coro: Coroutine[Any, Any, None]) -> None: + """Schedule a coroutine on the running event loop, if any. + + Used so the sync :meth:`subscribe` / :meth:`unsubscribe` methods can + also drive runtime subscribe/unsubscribe frames when the caller is + inside an async context. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(coro) # ------------------------------------------------------------------ # Message dispatch @@ -496,33 +358,36 @@ async def _dispatch(self, message: Dict[str, Any]) -> None: return if message_type == "pong": return - if message_type == "connected": - # The server's welcome message arrives once per connection. - # Subscriptions are already sent eagerly on connect. - await self._call(self.on_message, message) - return if message_type.startswith("jsonapi/"): await self._call(self.on_tx_response, message) return - - kind = _channel_kind(message_type) - if kind is None: + if message_type == "connected" or not message_type.startswith( + ("subscribed/", "update/") + ): await self._call(self.on_message, message) return - handler = getattr(self, f"_handle_{kind}", None) - if handler is None: + channel_raw = message.get("channel") + if not isinstance(channel_raw, str): await self._call(self.on_message, message) return - await handler(message) + channel = _canonical_channel(channel_raw) - # ------------------------------------------------------------------ - # Per-channel handlers - # ------------------------------------------------------------------ + if channel.startswith("order_book/"): + self._update_order_book_state(channel, message) - async def _handle_order_book(self, message: Dict[str, Any]) -> None: - market_id = _parse_id(message.get("channel", "")) - if market_id is None: + sub = self._subscriptions.get(channel) + if sub is None: + await self._call(self.on_message, message) + return + await self._call(sub.on_update, message) + + def _update_order_book_state( + self, channel: str, message: Dict[str, Any] + ) -> None: + try: + market_id = int(channel.split("/", 1)[1]) + except (IndexError, ValueError): return order_book = message.get("order_book") or {} if message.get("type") == "subscribed/order_book": @@ -530,134 +395,12 @@ async def _handle_order_book(self, message: Dict[str, Any]) -> None: "asks": list(order_book.get("asks") or []), "bids": list(order_book.get("bids") or []), } - else: - state = self.order_book_states.setdefault( - market_id, {"asks": [], "bids": []} - ) - _apply_order_book_diff( - order_book.get("asks") or [], state["asks"] - ) - _apply_order_book_diff( - order_book.get("bids") or [], state["bids"] - ) - await self._call( - self.on_order_book_update, - market_id, - self.order_book_states[market_id], - ) - - async def _handle_account_all(self, message: Dict[str, Any]) -> None: - account_id = _parse_id(message.get("channel", "")) - if account_id is None: return - self.account_states[account_id] = message - await self._call(self.on_account_update, account_id, message) - - async def _handle_ticker(self, message: Dict[str, Any]) -> None: - market_id = _parse_id(message.get("channel", "")) - await self._call(self.on_ticker_update, market_id, message) - - async def _handle_market_stats(self, message: Dict[str, Any]) -> None: - key = _parse_key(message.get("channel", "")) - await self._call(self.on_market_stats_update, key, message) - - async def _handle_spot_market_stats( - self, message: Dict[str, Any] - ) -> None: - key = _parse_key(message.get("channel", "")) - await self._call(self.on_spot_market_stats_update, key, message) - - async def _handle_trade(self, message: Dict[str, Any]) -> None: - market_id = _parse_id(message.get("channel", "")) - await self._call(self.on_trade_update, market_id, message) - - async def _handle_candle(self, message: Dict[str, Any]) -> None: - market_id, resolution = _parse_candle_channel(message.get("channel", "")) - await self._call( - self.on_candle_update, market_id, resolution, message - ) - - async def _handle_account_market(self, message: Dict[str, Any]) -> None: - market_id, account_id = _parse_two_ids(message.get("channel", "")) - await self._call( - self.on_account_market_update, market_id, account_id, message + state = self.order_book_states.setdefault( + market_id, {"asks": [], "bids": []} ) - - async def _handle_account_orders(self, message: Dict[str, Any]) -> None: - market_id, account_id = _parse_two_ids(message.get("channel", "")) - await self._call( - self.on_account_orders_update, market_id, account_id, message - ) - - async def _handle_account_all_orders( - self, message: Dict[str, Any] - ) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call( - self.on_account_all_orders_update, account_id, message - ) - - async def _handle_account_all_trades( - self, message: Dict[str, Any] - ) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call( - self.on_account_all_trades_update, account_id, message - ) - - async def _handle_account_all_positions( - self, message: Dict[str, Any] - ) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call( - self.on_account_all_positions_update, account_id, message - ) - - async def _handle_account_all_assets( - self, message: Dict[str, Any] - ) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call( - self.on_account_all_assets_update, account_id, message - ) - - async def _handle_account_spot_avg_entry_prices( - self, message: Dict[str, Any] - ) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call( - self.on_account_spot_avg_entry_prices_update, - account_id, - message, - ) - - async def _handle_account_tx(self, message: Dict[str, Any]) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call(self.on_account_tx_update, account_id, message) - - async def _handle_user_stats(self, message: Dict[str, Any]) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call(self.on_user_stats_update, account_id, message) - - async def _handle_notification(self, message: Dict[str, Any]) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call(self.on_notification_update, account_id, message) - - async def _handle_pool_data(self, message: Dict[str, Any]) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call(self.on_pool_data_update, account_id, message) - - async def _handle_pool_info(self, message: Dict[str, Any]) -> None: - account_id = _parse_id(message.get("channel", "")) - await self._call(self.on_pool_info_update, account_id, message) - - async def _handle_height(self, message: Dict[str, Any]) -> None: - height = message.get("height") - await self._call(self.on_height_update, height, message) - - # ------------------------------------------------------------------ - # Callback invocation helper - # ------------------------------------------------------------------ + _apply_order_book_diff(order_book.get("asks") or [], state["asks"]) + _apply_order_book_diff(order_book.get("bids") or [], state["bids"]) async def _call( self, callback: Optional[Callback], *args: Any @@ -674,90 +417,15 @@ async def _call( # --------------------------------------------------------------------- -def _normalize_subscription(spec: SubscriptionSpec) -> Tuple[str, Optional[str]]: - if isinstance(spec, tuple): - if len(spec) != 2: - raise ValueError( - "subscription tuples must have the form (channel, auth_token)" - ) - channel, token = spec - return str(channel), token - return str(spec), None - - -def _channel_kind(message_type: str) -> Optional[str]: - """Return the channel kind for ``subscribed/`` and ``update/``.""" - if not message_type: - return None - for prefix in ("subscribed/", "update/"): - if message_type.startswith(prefix): - return message_type[len(prefix):] - return None - +def _canonical_channel(channel: str) -> str: + """Normalize a channel string. -def _channel_body(channel: str) -> List[str]: - """Return the parts of the channel that follow the leading name. - - Channel strings appear with either ``/`` or ``:`` separators depending on - whether they come from a subscribe request or a server response. + Server-emitted channels use ``:`` as the separator (e.g. + ``"order_book:0"``); subscribe requests in the docs use ``/`` (e.g. + ``"order_book/0"``). The client stores and matches channels using + ``/`` everywhere so users only have to remember one form. """ - if not channel: - return [] - normalized = channel.replace("/", ":") - parts = normalized.split(":") - if len(parts) <= 1: - return [] - return parts[1:] - - -def _parse_id(channel: str) -> Optional[int]: - parts = _channel_body(channel) - if not parts: - return None - try: - return int(parts[0]) - except ValueError: - return None - - -def _parse_key(channel: str) -> Union[int, str, None]: - parts = _channel_body(channel) - if not parts: - return None - value = parts[0] - try: - return int(value) - except ValueError: - return value - - -def _parse_two_ids( - channel: str, -) -> Tuple[Optional[int], Optional[int]]: - parts = _channel_body(channel) - first: Optional[int] = None - second: Optional[int] = None - if len(parts) >= 1: - with suppress(ValueError): - first = int(parts[0]) - if len(parts) >= 2: - with suppress(ValueError): - second = int(parts[1]) - return first, second - - -def _parse_candle_channel( - channel: str, -) -> Tuple[Optional[int], Optional[str]]: - parts = _channel_body(channel) - market_id: Optional[int] = None - resolution: Optional[str] = None - if len(parts) >= 1: - with suppress(ValueError): - market_id = int(parts[0]) - if len(parts) >= 2: - resolution = parts[1] - return market_id, resolution + return channel.replace(":", "/") def _decode_tx_info( @@ -774,10 +442,9 @@ def _apply_order_book_diff( ) -> None: """Merge ``new_orders`` into ``existing_orders`` in-place using price as key. - Entries with size ``0`` remove the corresponding price level. The order - of the resulting list reflects the underlying dict insertion order so it - is not guaranteed to be sorted; consumers that need a sorted book should - sort by ``price`` after each update. + Entries with size ``0`` remove the corresponding price level. The + resulting list is not guaranteed to be sorted by price; consumers + that need a sorted book should sort after each update. """ by_price: Dict[str, Dict[str, Any]] = { order["price"]: order for order in existing_orders From fd3292f1bdee4a11df42fb1f5b1a9c2f7973d7f1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 12:39:05 +0000 Subject: [PATCH 3/6] Add opt-in typed WebSocket message envelopes Introduces lighter/ws_messages.py with one pydantic envelope per documented server message type (WSOrderBookUpdate, WSTradeUpdate, WSCandleUpdate, WSAccountAllUpdate, etc., plus WSTxResponse for jsonapi/* replies). Every envelope uses ConfigDict(extra='allow') and treats channel-specific fields as Optional, so new server fields land in model_extra and missing fields surface as None - schema drift never causes parsing to raise. WsClient.subscribe gains a parse=False flag. When True, the dispatcher runs the message through ws_messages.parse_ws_message before invoking on_update, so callbacks receive a typed envelope instead of a dict. on_message and on_tx_response still receive the raw dict. ws_messages also exposes envelope_for(message_type) and a standalone parse_ws_message(message) helper for users who want to validate messages outside the run loop. --- lighter/ws_client.py | 22 ++- lighter/ws_messages.py | 306 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 lighter/ws_messages.py diff --git a/lighter/ws_client.py b/lighter/ws_client.py index 486534c..11db176 100644 --- a/lighter/ws_client.py +++ b/lighter/ws_client.py @@ -42,6 +42,7 @@ from websockets.client import connect as _ws_connect from lighter.configuration import Configuration +from lighter.ws_messages import parse_ws_message logger = logging.getLogger(__name__) @@ -69,6 +70,7 @@ class _Subscription: channel: str on_update: Optional[Callback] = None auth: Optional[str] = None + parse: bool = False class WsClient: @@ -163,6 +165,7 @@ def subscribe( on_update: Optional[Callback] = None, *, auth: Optional[str] = None, + parse: bool = False, ) -> None: """Register a subscription for ``channel``. @@ -172,17 +175,25 @@ def subscribe( subscribe frame is dispatched immediately via the running event loop. - ``on_update`` is invoked with the full server message dict (both - the initial ``subscribed/...`` snapshot and subsequent - ``update/...`` messages). The callback may be sync or async. + ``on_update`` is invoked with the full server message (both the + initial ``subscribed/...`` snapshot and subsequent ``update/...`` + messages). The callback may be sync or async. ``auth`` is sent alongside the subscribe message. If omitted and ``channel`` is under one of the documented auth-required prefixes, the client's default :attr:`auth` is used. + + ``parse`` is opt-in typed payloads. When ``True``, the message is + validated through :func:`lighter.ws_messages.parse_ws_message` + and the corresponding :class:`~lighter.ws_messages.WSEnvelope` + subclass is passed to ``on_update`` instead of the raw dict. The + envelopes are forward-compatible (``extra="allow"`` + Optional + fields) so server-side schema additions do not cause parsing to + fail. """ canonical = _canonical_channel(channel) self._subscriptions[canonical] = _Subscription( - channel=canonical, on_update=on_update, auth=auth + channel=canonical, on_update=on_update, auth=auth, parse=parse, ) if self.ws is not None: self._spawn(self._send_subscribe(canonical)) @@ -380,7 +391,8 @@ async def _dispatch(self, message: Dict[str, Any]) -> None: if sub is None: await self._call(self.on_message, message) return - await self._call(sub.on_update, message) + payload: Any = parse_ws_message(message) if sub.parse else message + await self._call(sub.on_update, payload) def _update_order_book_state( self, channel: str, message: Dict[str, Any] diff --git a/lighter/ws_messages.py b/lighter/ws_messages.py new file mode 100644 index 0000000..fd65a4c --- /dev/null +++ b/lighter/ws_messages.py @@ -0,0 +1,306 @@ +"""Typed envelopes for Lighter WebSocket messages. + +Each server message ``type`` documented at +https://apidocs.lighter.xyz/docs/websocket-reference maps to a pydantic +envelope model below. All envelopes use ``extra="allow"`` and Optional +fields so that: + +* New fields the server may add later do not raise validation errors + (they end up in ``model_extra``). +* Missing fields show up as ``None`` instead of breaking parsing. + +The envelopes are intentionally schema-loose. They give you completion +and a stable shape to write code against without coupling tightly to the +exact field set documented at any one point in time. If you need strict +validation, set ``model_config = ConfigDict(extra="forbid")`` on a +subclass. + +Typical usage:: + + from lighter import WsClient, ws_messages + + def on_book(message: ws_messages.WSOrderBookUpdate) -> None: + for level in (message.order_book or {}).get("asks", []): + ... + + client = WsClient() + client.subscribe("order_book/0", on_update=on_book, parse=True) + client.run() + +Or standalone:: + + parsed = ws_messages.parse_ws_message(raw_dict) +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Type, Union + +from pydantic import BaseModel, ConfigDict + + +class WSEnvelope(BaseModel): + """Base envelope for all Lighter WebSocket messages. + + Accepts unknown fields (forward compatible) and treats every + channel-specific field as Optional so partial / new payloads parse + without raising. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: str + channel: Optional[str] = None + + +class WSConnected(WSEnvelope): + """Server welcome message sent once per connection.""" + + +class WSError(WSEnvelope): + """Server-emitted error message.""" + + message: Optional[str] = None + code: Optional[int] = None + + +# --------------------------------------------------------------------- +# Public market data +# --------------------------------------------------------------------- + + +class WSOrderBookUpdate(WSEnvelope): + """``subscribed/order_book`` snapshot or ``update/order_book`` diff.""" + + order_book: Optional[Dict[str, Any]] = None + + +class WSTickerUpdate(WSEnvelope): + ticker: Optional[Dict[str, Any]] = None + + +class WSMarketStatsUpdate(WSEnvelope): + market_stats: Optional[Dict[str, Any]] = None + + +class WSSpotMarketStatsUpdate(WSEnvelope): + market_stats: Optional[Dict[str, Any]] = None + + +class WSTradeUpdate(WSEnvelope): + trades: Optional[List[Dict[str, Any]]] = None + + +class WSCandleUpdate(WSEnvelope): + candle: Optional[Dict[str, Any]] = None + resolution: Optional[str] = None + + +class WSHeightUpdate(WSEnvelope): + height: Optional[int] = None + + +# --------------------------------------------------------------------- +# Account-scoped streams +# --------------------------------------------------------------------- + + +class WSAccountAllUpdate(WSEnvelope): + """Combined account snapshot / diff from ``account_all/*``.""" + + account_id: Optional[int] = None + orders: Optional[List[Dict[str, Any]]] = None + positions: Optional[List[Dict[str, Any]]] = None + trades: Optional[List[Dict[str, Any]]] = None + funding_histories: Optional[List[Dict[str, Any]]] = None + funding_rates: Optional[List[Dict[str, Any]]] = None + shares: Optional[List[Dict[str, Any]]] = None + + +class WSAccountMarketUpdate(WSEnvelope): + account_id: Optional[int] = None + market_id: Optional[int] = None + orders: Optional[List[Dict[str, Any]]] = None + positions: Optional[List[Dict[str, Any]]] = None + trades: Optional[List[Dict[str, Any]]] = None + + +class WSAccountAllOrdersUpdate(WSEnvelope): + account_id: Optional[int] = None + orders: Optional[List[Dict[str, Any]]] = None + + +class WSAccountOrdersUpdate(WSEnvelope): + account_id: Optional[int] = None + market_id: Optional[int] = None + orders: Optional[List[Dict[str, Any]]] = None + + +class WSAccountAllTradesUpdate(WSEnvelope): + account_id: Optional[int] = None + trades: Optional[List[Dict[str, Any]]] = None + + +class WSAccountAllPositionsUpdate(WSEnvelope): + account_id: Optional[int] = None + positions: Optional[List[Dict[str, Any]]] = None + + +class WSAccountAllAssetsUpdate(WSEnvelope): + account_id: Optional[int] = None + assets: Optional[Dict[str, Dict[str, Any]]] = None + + +class WSAccountSpotAvgEntryPricesUpdate(WSEnvelope): + account_id: Optional[int] = None + avg_entry_prices: Optional[Dict[str, Any]] = None + + +class WSAccountTxUpdate(WSEnvelope): + account_id: Optional[int] = None + txs: Optional[List[Dict[str, Any]]] = None + + +class WSUserStatsUpdate(WSEnvelope): + account_id: Optional[int] = None + stats: Optional[Dict[str, Any]] = None + + +class WSNotificationUpdate(WSEnvelope): + account_id: Optional[int] = None + notifications: Optional[List[Dict[str, Any]]] = None + + +class WSPoolDataUpdate(WSEnvelope): + account_id: Optional[int] = None + + +class WSPoolInfoUpdate(WSEnvelope): + account_id: Optional[int] = None + + +# --------------------------------------------------------------------- +# Transaction submission responses +# --------------------------------------------------------------------- + + +class WSTxResponse(WSEnvelope): + """Response envelope for ``jsonapi/sendtx`` and ``jsonapi/sendtxbatch``.""" + + id: Optional[str] = None + code: Optional[int] = None + message: Optional[str] = None + tx_hash: Optional[str] = None + tx_hashes: Optional[List[str]] = None + error: Optional[Any] = None + + +# --------------------------------------------------------------------- +# Registry & dispatch helper +# --------------------------------------------------------------------- + + +_TYPE_MAP: Dict[str, Type[WSEnvelope]] = { + "connected": WSConnected, + "error": WSError, + # public market data + "subscribed/order_book": WSOrderBookUpdate, + "update/order_book": WSOrderBookUpdate, + "subscribed/ticker": WSTickerUpdate, + "update/ticker": WSTickerUpdate, + "subscribed/market_stats": WSMarketStatsUpdate, + "update/market_stats": WSMarketStatsUpdate, + "subscribed/spot_market_stats": WSSpotMarketStatsUpdate, + "update/spot_market_stats": WSSpotMarketStatsUpdate, + "subscribed/trade": WSTradeUpdate, + "update/trade": WSTradeUpdate, + "subscribed/candle": WSCandleUpdate, + "update/candle": WSCandleUpdate, + "subscribed/height": WSHeightUpdate, + "update/height": WSHeightUpdate, + # account-scoped + "subscribed/account_all": WSAccountAllUpdate, + "update/account_all": WSAccountAllUpdate, + "subscribed/account_market": WSAccountMarketUpdate, + "update/account_market": WSAccountMarketUpdate, + "subscribed/account_all_orders": WSAccountAllOrdersUpdate, + "update/account_all_orders": WSAccountAllOrdersUpdate, + "subscribed/account_orders": WSAccountOrdersUpdate, + "update/account_orders": WSAccountOrdersUpdate, + "subscribed/account_all_trades": WSAccountAllTradesUpdate, + "update/account_all_trades": WSAccountAllTradesUpdate, + "subscribed/account_all_positions": WSAccountAllPositionsUpdate, + "update/account_all_positions": WSAccountAllPositionsUpdate, + "subscribed/account_all_assets": WSAccountAllAssetsUpdate, + "update/account_all_assets": WSAccountAllAssetsUpdate, + "subscribed/account_spot_avg_entry_prices": ( + WSAccountSpotAvgEntryPricesUpdate + ), + "update/account_spot_avg_entry_prices": WSAccountSpotAvgEntryPricesUpdate, + "subscribed/account_tx": WSAccountTxUpdate, + "update/account_tx": WSAccountTxUpdate, + "subscribed/user_stats": WSUserStatsUpdate, + "update/user_stats": WSUserStatsUpdate, + "subscribed/notification": WSNotificationUpdate, + "update/notification": WSNotificationUpdate, + "subscribed/pool_data": WSPoolDataUpdate, + "update/pool_data": WSPoolDataUpdate, + "subscribed/pool_info": WSPoolInfoUpdate, + "update/pool_info": WSPoolInfoUpdate, + # transaction submission responses + "jsonapi/sendtx": WSTxResponse, + "jsonapi/sendtxbatch": WSTxResponse, +} + + +def envelope_for(message_type: str) -> Optional[Type[WSEnvelope]]: + """Return the envelope class registered for ``message_type``, or ``None``.""" + return _TYPE_MAP.get(message_type) + + +def parse_ws_message( + message: Mapping[str, Any], +) -> Union[WSEnvelope, Mapping[str, Any]]: + """Parse a raw WS message into a typed envelope. + + Returns the input ``message`` unchanged if no envelope is registered + for the message ``type`` (e.g. an unknown channel kind), so callers + can still rely on a dict-shaped fallback. + """ + msg_type = message.get("type") + if not isinstance(msg_type, str): + return message + cls = _TYPE_MAP.get(msg_type) + if cls is None: + return message + return cls.model_validate(message) + + +__all__ = [ + "WSEnvelope", + "WSConnected", + "WSError", + "WSOrderBookUpdate", + "WSTickerUpdate", + "WSMarketStatsUpdate", + "WSSpotMarketStatsUpdate", + "WSTradeUpdate", + "WSCandleUpdate", + "WSHeightUpdate", + "WSAccountAllUpdate", + "WSAccountMarketUpdate", + "WSAccountAllOrdersUpdate", + "WSAccountOrdersUpdate", + "WSAccountAllTradesUpdate", + "WSAccountAllPositionsUpdate", + "WSAccountAllAssetsUpdate", + "WSAccountSpotAvgEntryPricesUpdate", + "WSAccountTxUpdate", + "WSUserStatsUpdate", + "WSNotificationUpdate", + "WSPoolDataUpdate", + "WSPoolInfoUpdate", + "WSTxResponse", + "envelope_for", + "parse_ws_message", +] From 1e930b77fbca1610ccc983cef36c15353701635c Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" Date: Tue, 2 Jun 2026 11:58:13 +0000 Subject: [PATCH 4/6] Make ws_async.py actually demonstrate async (async callbacks, asyncio.gather) --- examples/ws_async.py | 78 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/examples/ws_async.py b/examples/ws_async.py index 67e6e45..3056675 100644 --- a/examples/ws_async.py +++ b/examples/ws_async.py @@ -1,5 +1,22 @@ +"""Async usage of :class:`lighter.WsClient`. + +Reach for ``run_async()`` (instead of ``run()``) when you already have +an asyncio program — e.g. a trading bot that also makes REST calls, +maintains other WebSocket connections, or runs periodic tasks. +``run()`` is just ``asyncio.run(run_async())`` and cannot be called +from inside a running event loop. + +This example shows the two things sync mode cannot do: + +1. **Async callbacks.** Handlers may be ``async def`` and can ``await`` + coroutines directly (e.g. ``await client.send_tx(...)`` or an + ``aiohttp`` REST call) without spawning threads. +2. **Concurrency with other async work.** ``run_async()`` shares the + loop with the rest of your program — here we run a periodic stats + task alongside the WebSocket consumer via :func:`asyncio.gather`. +""" + import asyncio -import json import logging import lighter @@ -7,23 +24,52 @@ logging.basicConfig(level=logging.INFO) -def on_order_book(message): - logging.info( - f"Order book {message['channel']}:\n" - f"{json.dumps(message.get('order_book'), indent=2)}" - ) +class Counters: + book_updates = 0 + account_updates = 0 -def on_account(message): - logging.info( - f"Account {message['channel']}:\n{json.dumps(message, indent=2)}" - ) +async def on_order_book(message): + Counters.book_updates += 1 + # Async callbacks can await arbitrary coroutines — e.g. an aiohttp + # REST call, a database write, or ``await client.send_tx(...)`` to + # react to a book change with an order. A sync callback would have + # to schedule that work on a separate thread/loop. + bids = (message.get("order_book") or {}).get("bids") or [] + logging.info("order book %s: %d bid levels", message["channel"], len(bids)) + +async def on_account(message): + Counters.account_updates += 1 + logging.info("account %s update", message["channel"]) + + +async def log_stats(): + """Independent task running on the same loop as the ws client.""" + while True: + await asyncio.sleep(10) + logging.info( + "stats: %d book updates, %d account updates", + Counters.book_updates, + Counters.account_updates, + ) + + +async def main(): + client = lighter.WsClient() + for market_id in [0, 1]: + client.subscribe(f"order_book/{market_id}", on_update=on_order_book) + for account_id in [1, 2]: + client.subscribe(f"account_all/{account_id}", on_update=on_account) + + # ``run_async()`` runs forever; ``log_stats()`` runs in parallel on + # the same loop. In a real app, replace ``log_stats`` with whatever + # other async work your bot already does. + await asyncio.gather( + client.run_async(), + log_stats(), + ) -client = lighter.WsClient() -for market_id in [0, 1]: - client.subscribe(f"order_book/{market_id}", on_update=on_order_book) -for account_id in [1, 2]: - client.subscribe(f"account_all/{account_id}", on_update=on_account) -asyncio.run(client.run_async()) +if __name__ == "__main__": + asyncio.run(main()) From 1cc9a55a19ce5bde03b8a55df5e9e63e3ca9eb76 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" Date: Tue, 2 Jun 2026 12:33:02 +0000 Subject: [PATCH 5/6] Remove ws_async.py example (sync example is sufficient) --- examples/ws_async.py | 75 -------------------------------------------- 1 file changed, 75 deletions(-) delete mode 100644 examples/ws_async.py diff --git a/examples/ws_async.py b/examples/ws_async.py deleted file mode 100644 index 3056675..0000000 --- a/examples/ws_async.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Async usage of :class:`lighter.WsClient`. - -Reach for ``run_async()`` (instead of ``run()``) when you already have -an asyncio program — e.g. a trading bot that also makes REST calls, -maintains other WebSocket connections, or runs periodic tasks. -``run()`` is just ``asyncio.run(run_async())`` and cannot be called -from inside a running event loop. - -This example shows the two things sync mode cannot do: - -1. **Async callbacks.** Handlers may be ``async def`` and can ``await`` - coroutines directly (e.g. ``await client.send_tx(...)`` or an - ``aiohttp`` REST call) without spawning threads. -2. **Concurrency with other async work.** ``run_async()`` shares the - loop with the rest of your program — here we run a periodic stats - task alongside the WebSocket consumer via :func:`asyncio.gather`. -""" - -import asyncio -import logging - -import lighter - -logging.basicConfig(level=logging.INFO) - - -class Counters: - book_updates = 0 - account_updates = 0 - - -async def on_order_book(message): - Counters.book_updates += 1 - # Async callbacks can await arbitrary coroutines — e.g. an aiohttp - # REST call, a database write, or ``await client.send_tx(...)`` to - # react to a book change with an order. A sync callback would have - # to schedule that work on a separate thread/loop. - bids = (message.get("order_book") or {}).get("bids") or [] - logging.info("order book %s: %d bid levels", message["channel"], len(bids)) - - -async def on_account(message): - Counters.account_updates += 1 - logging.info("account %s update", message["channel"]) - - -async def log_stats(): - """Independent task running on the same loop as the ws client.""" - while True: - await asyncio.sleep(10) - logging.info( - "stats: %d book updates, %d account updates", - Counters.book_updates, - Counters.account_updates, - ) - - -async def main(): - client = lighter.WsClient() - for market_id in [0, 1]: - client.subscribe(f"order_book/{market_id}", on_update=on_order_book) - for account_id in [1, 2]: - client.subscribe(f"account_all/{account_id}", on_update=on_account) - - # ``run_async()`` runs forever; ``log_stats()`` runs in parallel on - # the same loop. In a real app, replace ``log_stats`` with whatever - # other async work your bot already does. - await asyncio.gather( - client.run_async(), - log_stats(), - ) - - -if __name__ == "__main__": - asyncio.run(main()) From 9e6fd8783ce42adc5d7081cf4a25ceb8713b6973 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:27:12 +0000 Subject: [PATCH 6/6] Add AsyncAPI 3.0 spec for the WebSocket API asyncapi.json (at repo root, sibling of openapi.json) describes every documented WS channel, message type, and shared schema. Generated by tools/gen_asyncapi.py, which is the editable source of truth -- the JSON is reproducible by running 'python tools/gen_asyncapi.py > asyncapi.json'. Spec is intentionally permissive (additionalProperties: true on all message payloads, channel-specific fields Optional) so server-side field additions stay backwards-compatible with generated clients. This mirrors the forward-compat policy already documented on WSEnvelope in lighter/ws_messages.py. Coverage matches lighter/ws_messages.py exactly: 20 documented channels + a synthetic _control channel for connection-level operations (subscribe/unsubscribe/ping/jsonapi.* + their server responses). Wiring this into codegen for ws_messages.py is a clean follow-up. Co-Authored-By: Mihail --- asyncapi.json | 3177 +++++++++++++++++++++++++++++++++++++++++ tools/gen_asyncapi.py | 917 ++++++++++++ 2 files changed, 4094 insertions(+) create mode 100644 asyncapi.json create mode 100644 tools/gen_asyncapi.py diff --git a/asyncapi.json b/asyncapi.json new file mode 100644 index 0000000..f03378d --- /dev/null +++ b/asyncapi.json @@ -0,0 +1,3177 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Lighter WebSocket API", + "version": "1.0.0", + "description": "Real-time market data, account state, and transaction submission for the zkLighter exchange. Hand-mirrored from https://apidocs.lighter.xyz/docs/websocket-reference. The schemas are intentionally permissive (`additionalProperties: true`, all channel-specific fields optional) so server-side additions do not invalidate generated clients.", + "contact": { + "name": "Lighter API docs", + "url": "https://apidocs.lighter.xyz/docs/websocket-reference" + } + }, + "defaultContentType": "application/json", + "servers": { + "mainnet": { + "host": "mainnet.zklighter.elliot.ai", + "pathname": "/stream", + "protocol": "wss", + "description": "Mainnet WebSocket gateway. Append `?readonly=true` to bypass IP region restrictions for read-only data." + }, + "testnet": { + "host": "testnet.zklighter.elliot.ai", + "pathname": "/stream", + "protocol": "wss", + "description": "Testnet WebSocket gateway." + } + }, + "channels": { + "order_book": { + "address": "order_book/{market_id}", + "title": "Order Book", + "description": "Order book snapshots and diffs for a given market. Snapshots ship on subscribe; subsequent messages are price-level diffs.", + "messages": { + "OrderBookSubscribed": { + "$ref": "#/components/messages/OrderBookSubscribed" + }, + "OrderBookUpdate": { + "$ref": "#/components/messages/OrderBookUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Market index." + } + } + }, + "ticker": { + "address": "ticker/{market_id}", + "title": "Best Bid and Offer (BBO)", + "description": "Best bid/offer updates for a given market.", + "messages": { + "TickerSubscribed": { + "$ref": "#/components/messages/TickerSubscribed" + }, + "TickerUpdate": { + "$ref": "#/components/messages/TickerUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Market index." + } + } + }, + "market_stats": { + "address": "market_stats/{market_id}", + "title": "Market Stats", + "description": "Per-market rolling stats (volume, price change, etc.). Pass `all` as the market id to receive every market on one subscription.", + "messages": { + "MarketStatsSubscribed": { + "$ref": "#/components/messages/MarketStatsSubscribed" + }, + "MarketStatsUpdate": { + "$ref": "#/components/messages/MarketStatsUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Market index, or the literal string `all` to receive stats for every market." + } + } + }, + "spot_market_stats": { + "address": "spot_market_stats/{market_id}", + "title": "Spot Market Stats", + "description": "Per-spot-market rolling stats. Pass `all` to receive every spot market on one subscription.", + "messages": { + "SpotMarketStatsSubscribed": { + "$ref": "#/components/messages/SpotMarketStatsSubscribed" + }, + "SpotMarketStatsUpdate": { + "$ref": "#/components/messages/SpotMarketStatsUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Spot market index, or the literal string `all`." + } + } + }, + "trade": { + "address": "trade/{market_id}", + "title": "Trade", + "description": "Public trade stream for a given market.", + "messages": { + "TradeSubscribed": { + "$ref": "#/components/messages/TradeSubscribed" + }, + "TradeUpdate": { + "$ref": "#/components/messages/TradeUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Market index." + } + } + }, + "candle": { + "address": "candle/{market_id}/{resolution}", + "title": "Candlesticks", + "description": "Candlestick stream for a (market, resolution) pair.", + "messages": { + "CandleSubscribed": { + "$ref": "#/components/messages/CandleSubscribed" + }, + "CandleUpdate": { + "$ref": "#/components/messages/CandleUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Market index." + }, + "resolution": { + "description": "Candle resolution, e.g. `1m`, `5m`, `1h`, `1d`." + } + } + }, + "height": { + "address": "height", + "title": "Height", + "description": "Latest L2 block height.", + "messages": { + "HeightSubscribed": { + "$ref": "#/components/messages/HeightSubscribed" + }, + "HeightUpdate": { + "$ref": "#/components/messages/HeightUpdate" + } + } + }, + "account_all": { + "address": "account_all/{account_id}", + "title": "Account All", + "description": "Combined account stream: orders, positions, trades, funding histories/rates, and pool shares.", + "messages": { + "AccountAllSubscribed": { + "$ref": "#/components/messages/AccountAllSubscribed" + }, + "AccountAllUpdate": { + "$ref": "#/components/messages/AccountAllUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "account_market": { + "address": "account_market/{market_id}/{account_id}", + "title": "Account Market", + "description": "Per-market view of a specific account (orders, positions, trades restricted to one market).", + "messages": { + "AccountMarketSubscribed": { + "$ref": "#/components/messages/AccountMarketSubscribed" + }, + "AccountMarketUpdate": { + "$ref": "#/components/messages/AccountMarketUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Market index." + }, + "account_id": { + "description": "Account index." + } + } + }, + "user_stats": { + "address": "user_stats/{account_id}", + "title": "Account Stats", + "description": "Aggregate stats for an account (collateral, portfolio value, etc.).", + "messages": { + "UserStatsSubscribed": { + "$ref": "#/components/messages/UserStatsSubscribed" + }, + "UserStatsUpdate": { + "$ref": "#/components/messages/UserStatsUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "account_tx": { + "address": "account_tx/{account_id}", + "title": "Account Tx", + "description": "Transaction history for a specific account.", + "messages": { + "AccountTxSubscribed": { + "$ref": "#/components/messages/AccountTxSubscribed" + }, + "AccountTxUpdate": { + "$ref": "#/components/messages/AccountTxUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "account_all_orders": { + "address": "account_all_orders/{account_id}", + "title": "Account All Orders", + "description": "All orders across markets for an account.", + "messages": { + "AccountAllOrdersSubscribed": { + "$ref": "#/components/messages/AccountAllOrdersSubscribed" + }, + "AccountAllOrdersUpdate": { + "$ref": "#/components/messages/AccountAllOrdersUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "account_orders": { + "address": "account_orders/{market_id}/{account_id}", + "title": "Account Orders", + "description": "Orders for an account scoped to a single market.", + "messages": { + "AccountOrdersSubscribed": { + "$ref": "#/components/messages/AccountOrdersSubscribed" + }, + "AccountOrdersUpdate": { + "$ref": "#/components/messages/AccountOrdersUpdate" + } + }, + "parameters": { + "market_id": { + "description": "Market index." + }, + "account_id": { + "description": "Account index." + } + } + }, + "account_all_trades": { + "address": "account_all_trades/{account_id}", + "title": "Account All Trades", + "description": "All trades for an account across markets. Snapshot keys trades by market index; updates may emit a flat list.", + "messages": { + "AccountAllTradesSubscribed": { + "$ref": "#/components/messages/AccountAllTradesSubscribed" + }, + "AccountAllTradesUpdate": { + "$ref": "#/components/messages/AccountAllTradesUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "account_all_positions": { + "address": "account_all_positions/{account_id}", + "title": "Account All Positions", + "description": "All positions for an account, keyed by market index.", + "messages": { + "AccountAllPositionsSubscribed": { + "$ref": "#/components/messages/AccountAllPositionsSubscribed" + }, + "AccountAllPositionsUpdate": { + "$ref": "#/components/messages/AccountAllPositionsUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "account_all_assets": { + "address": "account_all_assets/{account_id}", + "title": "Account All Assets", + "description": "Per-asset balances for all spot markets for a specific account. `balance` is in coin terms, not USDC.", + "messages": { + "AccountAllAssetsSubscribed": { + "$ref": "#/components/messages/AccountAllAssetsSubscribed" + }, + "AccountAllAssetsUpdate": { + "$ref": "#/components/messages/AccountAllAssetsUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "account_spot_avg_entry_prices": { + "address": "account_spot_avg_entry_prices/{account_id}", + "title": "Average Entry Prices", + "description": "Spot avg-entry-price stream. Each event accounts as a buy/sell at the index price; `last_trade_id` confirms the validity horizon.", + "messages": { + "AccountSpotAvgEntryPricesSubscribed": { + "$ref": "#/components/messages/AccountSpotAvgEntryPricesSubscribed" + }, + "AccountSpotAvgEntryPricesUpdate": { + "$ref": "#/components/messages/AccountSpotAvgEntryPricesUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "notification": { + "address": "notification/{account_id}", + "title": "Notification", + "description": "Per-account notification stream.", + "messages": { + "NotificationSubscribed": { + "$ref": "#/components/messages/NotificationSubscribed" + }, + "NotificationUpdate": { + "$ref": "#/components/messages/NotificationUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Account index." + } + } + }, + "pool_data": { + "address": "pool_data/{account_id}", + "title": "Pool Data", + "description": "Live data for a public pool account.", + "messages": { + "PoolDataSubscribed": { + "$ref": "#/components/messages/PoolDataSubscribed" + }, + "PoolDataUpdate": { + "$ref": "#/components/messages/PoolDataUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Pool account index." + } + } + }, + "pool_info": { + "address": "pool_info/{account_id}", + "title": "Pool Info", + "description": "Public pool metadata.", + "messages": { + "PoolInfoSubscribed": { + "$ref": "#/components/messages/PoolInfoSubscribed" + }, + "PoolInfoUpdate": { + "$ref": "#/components/messages/PoolInfoUpdate" + } + }, + "parameters": { + "account_id": { + "description": "Pool account index." + } + } + }, + "_control": { + "address": "(connection)", + "title": "Connection control plane", + "description": "Frames not tied to a subscription address: client \u2192 server `subscribe`/`unsubscribe`/`ping`/`jsonapi/sendtx`/`jsonapi/sendtxbatch`, and server \u2192 client `connected`/`error`/`pong`/`jsonapi/*` responses.", + "messages": { + "SubscribeRequest": { + "$ref": "#/components/messages/SubscribeRequest" + }, + "UnsubscribeRequest": { + "$ref": "#/components/messages/UnsubscribeRequest" + }, + "Ping": { + "$ref": "#/components/messages/Ping" + }, + "SendTx": { + "$ref": "#/components/messages/SendTx" + }, + "SendTxBatch": { + "$ref": "#/components/messages/SendTxBatch" + }, + "Connected": { + "$ref": "#/components/messages/Connected" + }, + "ServerError": { + "$ref": "#/components/messages/ServerError" + }, + "Pong": { + "$ref": "#/components/messages/Pong" + }, + "TxResponse": { + "$ref": "#/components/messages/TxResponse" + } + } + } + }, + "operations": { + "subscribe_order_book": { + "action": "send", + "channel": { + "$ref": "#/channels/order_book" + }, + "summary": "Subscribe to `order_book/{market_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_order_book": { + "action": "receive", + "channel": { + "$ref": "#/channels/order_book" + }, + "summary": "Receive snapshot + updates for `order_book/{market_id}`.", + "messages": [ + { + "$ref": "#/channels/order_book/messages/OrderBookSubscribed" + }, + { + "$ref": "#/channels/order_book/messages/OrderBookUpdate" + } + ] + }, + "subscribe_ticker": { + "action": "send", + "channel": { + "$ref": "#/channels/ticker" + }, + "summary": "Subscribe to `ticker/{market_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_ticker": { + "action": "receive", + "channel": { + "$ref": "#/channels/ticker" + }, + "summary": "Receive snapshot + updates for `ticker/{market_id}`.", + "messages": [ + { + "$ref": "#/channels/ticker/messages/TickerSubscribed" + }, + { + "$ref": "#/channels/ticker/messages/TickerUpdate" + } + ] + }, + "subscribe_market_stats": { + "action": "send", + "channel": { + "$ref": "#/channels/market_stats" + }, + "summary": "Subscribe to `market_stats/{market_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_market_stats": { + "action": "receive", + "channel": { + "$ref": "#/channels/market_stats" + }, + "summary": "Receive snapshot + updates for `market_stats/{market_id}`.", + "messages": [ + { + "$ref": "#/channels/market_stats/messages/MarketStatsSubscribed" + }, + { + "$ref": "#/channels/market_stats/messages/MarketStatsUpdate" + } + ] + }, + "subscribe_spot_market_stats": { + "action": "send", + "channel": { + "$ref": "#/channels/spot_market_stats" + }, + "summary": "Subscribe to `spot_market_stats/{market_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_spot_market_stats": { + "action": "receive", + "channel": { + "$ref": "#/channels/spot_market_stats" + }, + "summary": "Receive snapshot + updates for `spot_market_stats/{market_id}`.", + "messages": [ + { + "$ref": "#/channels/spot_market_stats/messages/SpotMarketStatsSubscribed" + }, + { + "$ref": "#/channels/spot_market_stats/messages/SpotMarketStatsUpdate" + } + ] + }, + "subscribe_trade": { + "action": "send", + "channel": { + "$ref": "#/channels/trade" + }, + "summary": "Subscribe to `trade/{market_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_trade": { + "action": "receive", + "channel": { + "$ref": "#/channels/trade" + }, + "summary": "Receive snapshot + updates for `trade/{market_id}`.", + "messages": [ + { + "$ref": "#/channels/trade/messages/TradeSubscribed" + }, + { + "$ref": "#/channels/trade/messages/TradeUpdate" + } + ] + }, + "subscribe_candle": { + "action": "send", + "channel": { + "$ref": "#/channels/candle" + }, + "summary": "Subscribe to `candle/{market_id}/{resolution}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_candle": { + "action": "receive", + "channel": { + "$ref": "#/channels/candle" + }, + "summary": "Receive snapshot + updates for `candle/{market_id}/{resolution}`.", + "messages": [ + { + "$ref": "#/channels/candle/messages/CandleSubscribed" + }, + { + "$ref": "#/channels/candle/messages/CandleUpdate" + } + ] + }, + "subscribe_height": { + "action": "send", + "channel": { + "$ref": "#/channels/height" + }, + "summary": "Subscribe to `height`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_height": { + "action": "receive", + "channel": { + "$ref": "#/channels/height" + }, + "summary": "Receive snapshot + updates for `height`.", + "messages": [ + { + "$ref": "#/channels/height/messages/HeightSubscribed" + }, + { + "$ref": "#/channels/height/messages/HeightUpdate" + } + ] + }, + "subscribe_account_all": { + "action": "send", + "channel": { + "$ref": "#/channels/account_all" + }, + "summary": "Subscribe to `account_all/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_account_all": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_all" + }, + "summary": "Receive snapshot + updates for `account_all/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_all/messages/AccountAllSubscribed" + }, + { + "$ref": "#/channels/account_all/messages/AccountAllUpdate" + } + ] + }, + "subscribe_account_market": { + "action": "send", + "channel": { + "$ref": "#/channels/account_market" + }, + "summary": "Subscribe to `account_market/{market_id}/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_account_market": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_market" + }, + "summary": "Receive snapshot + updates for `account_market/{market_id}/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_market/messages/AccountMarketSubscribed" + }, + { + "$ref": "#/channels/account_market/messages/AccountMarketUpdate" + } + ] + }, + "subscribe_user_stats": { + "action": "send", + "channel": { + "$ref": "#/channels/user_stats" + }, + "summary": "Subscribe to `user_stats/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_user_stats": { + "action": "receive", + "channel": { + "$ref": "#/channels/user_stats" + }, + "summary": "Receive snapshot + updates for `user_stats/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/user_stats/messages/UserStatsSubscribed" + }, + { + "$ref": "#/channels/user_stats/messages/UserStatsUpdate" + } + ] + }, + "subscribe_account_tx": { + "action": "send", + "channel": { + "$ref": "#/channels/account_tx" + }, + "summary": "Subscribe to `account_tx/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_account_tx": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_tx" + }, + "summary": "Receive snapshot + updates for `account_tx/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_tx/messages/AccountTxSubscribed" + }, + { + "$ref": "#/channels/account_tx/messages/AccountTxUpdate" + } + ] + }, + "subscribe_account_all_orders": { + "action": "send", + "channel": { + "$ref": "#/channels/account_all_orders" + }, + "summary": "Subscribe to `account_all_orders/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_account_all_orders": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_all_orders" + }, + "summary": "Receive snapshot + updates for `account_all_orders/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_all_orders/messages/AccountAllOrdersSubscribed" + }, + { + "$ref": "#/channels/account_all_orders/messages/AccountAllOrdersUpdate" + } + ] + }, + "subscribe_account_orders": { + "action": "send", + "channel": { + "$ref": "#/channels/account_orders" + }, + "summary": "Subscribe to `account_orders/{market_id}/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_account_orders": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_orders" + }, + "summary": "Receive snapshot + updates for `account_orders/{market_id}/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_orders/messages/AccountOrdersSubscribed" + }, + { + "$ref": "#/channels/account_orders/messages/AccountOrdersUpdate" + } + ] + }, + "subscribe_account_all_trades": { + "action": "send", + "channel": { + "$ref": "#/channels/account_all_trades" + }, + "summary": "Subscribe to `account_all_trades/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_account_all_trades": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_all_trades" + }, + "summary": "Receive snapshot + updates for `account_all_trades/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_all_trades/messages/AccountAllTradesSubscribed" + }, + { + "$ref": "#/channels/account_all_trades/messages/AccountAllTradesUpdate" + } + ] + }, + "subscribe_account_all_positions": { + "action": "send", + "channel": { + "$ref": "#/channels/account_all_positions" + }, + "summary": "Subscribe to `account_all_positions/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ] + }, + "receive_account_all_positions": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_all_positions" + }, + "summary": "Receive snapshot + updates for `account_all_positions/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_all_positions/messages/AccountAllPositionsSubscribed" + }, + { + "$ref": "#/channels/account_all_positions/messages/AccountAllPositionsUpdate" + } + ] + }, + "subscribe_account_all_assets": { + "action": "send", + "channel": { + "$ref": "#/channels/account_all_assets" + }, + "summary": "Subscribe to `account_all_assets/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_account_all_assets": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_all_assets" + }, + "summary": "Receive snapshot + updates for `account_all_assets/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_all_assets/messages/AccountAllAssetsSubscribed" + }, + { + "$ref": "#/channels/account_all_assets/messages/AccountAllAssetsUpdate" + } + ] + }, + "subscribe_account_spot_avg_entry_prices": { + "action": "send", + "channel": { + "$ref": "#/channels/account_spot_avg_entry_prices" + }, + "summary": "Subscribe to `account_spot_avg_entry_prices/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_account_spot_avg_entry_prices": { + "action": "receive", + "channel": { + "$ref": "#/channels/account_spot_avg_entry_prices" + }, + "summary": "Receive snapshot + updates for `account_spot_avg_entry_prices/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/account_spot_avg_entry_prices/messages/AccountSpotAvgEntryPricesSubscribed" + }, + { + "$ref": "#/channels/account_spot_avg_entry_prices/messages/AccountSpotAvgEntryPricesUpdate" + } + ] + }, + "subscribe_notification": { + "action": "send", + "channel": { + "$ref": "#/channels/notification" + }, + "summary": "Subscribe to `notification/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_notification": { + "action": "receive", + "channel": { + "$ref": "#/channels/notification" + }, + "summary": "Receive snapshot + updates for `notification/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/notification/messages/NotificationSubscribed" + }, + { + "$ref": "#/channels/notification/messages/NotificationUpdate" + } + ] + }, + "subscribe_pool_data": { + "action": "send", + "channel": { + "$ref": "#/channels/pool_data" + }, + "summary": "Subscribe to `pool_data/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_pool_data": { + "action": "receive", + "channel": { + "$ref": "#/channels/pool_data" + }, + "summary": "Receive snapshot + updates for `pool_data/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/pool_data/messages/PoolDataSubscribed" + }, + { + "$ref": "#/channels/pool_data/messages/PoolDataUpdate" + } + ] + }, + "subscribe_pool_info": { + "action": "send", + "channel": { + "$ref": "#/channels/pool_info" + }, + "summary": "Subscribe to `pool_info/{account_id}`.", + "messages": [ + { + "$ref": "#/components/messages/SubscribeRequest" + } + ], + "security": [ + { + "$ref": "#/components/securitySchemes/bearerToken" + } + ] + }, + "receive_pool_info": { + "action": "receive", + "channel": { + "$ref": "#/channels/pool_info" + }, + "summary": "Receive snapshot + updates for `pool_info/{account_id}`.", + "messages": [ + { + "$ref": "#/channels/pool_info/messages/PoolInfoSubscribed" + }, + { + "$ref": "#/channels/pool_info/messages/PoolInfoUpdate" + } + ] + }, + "unsubscribe": { + "action": "send", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Cancel an existing subscription.", + "messages": [ + { + "$ref": "#/components/messages/UnsubscribeRequest" + } + ] + }, + "ping": { + "action": "send", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Application-level heartbeat (server replies with `Pong`).", + "messages": [ + { + "$ref": "#/components/messages/Ping" + } + ] + }, + "send_tx": { + "action": "send", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Submit a single signed transaction.", + "messages": [ + { + "$ref": "#/components/messages/SendTx" + } + ] + }, + "send_tx_batch": { + "action": "send", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Submit up to 15 signed transactions in a single frame.", + "messages": [ + { + "$ref": "#/components/messages/SendTxBatch" + } + ] + }, + "receive_connected": { + "action": "receive", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Connection welcome frame.", + "messages": [ + { + "$ref": "#/channels/_control/messages/Connected" + } + ] + }, + "receive_error": { + "action": "receive", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Server error frame.", + "messages": [ + { + "$ref": "#/channels/_control/messages/ServerError" + } + ] + }, + "receive_pong": { + "action": "receive", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Reply to a client `Ping`.", + "messages": [ + { + "$ref": "#/channels/_control/messages/Pong" + } + ] + }, + "receive_tx_response": { + "action": "receive", + "channel": { + "$ref": "#/channels/_control" + }, + "summary": "Reply to `jsonapi/sendtx` or `jsonapi/sendtxbatch` (success or error).", + "messages": [ + { + "$ref": "#/channels/_control/messages/TxResponse" + } + ] + } + }, + "components": { + "messages": { + "OrderBookSubscribed": { + "name": "OrderBookSubscribed", + "title": "Order Book snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `order_book/{market_id}`.", + "x-message-type": "subscribed/order_book", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "order_book": { + "type": "object", + "additionalProperties": true, + "properties": { + "asks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "bids": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "offset": { + "type": "integer" + } + } + } + } + } + }, + "OrderBookUpdate": { + "name": "OrderBookUpdate", + "title": "Order Book update", + "summary": "Live update for an existing subscription to `order_book/{market_id}`.", + "x-message-type": "update/order_book", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "order_book": { + "type": "object", + "additionalProperties": true, + "properties": { + "asks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "bids": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "offset": { + "type": "integer" + } + } + } + } + } + }, + "TickerSubscribed": { + "name": "TickerSubscribed", + "title": "Best Bid and Offer (BBO) snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `ticker/{market_id}`.", + "x-message-type": "subscribed/ticker", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "ticker": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "TickerUpdate": { + "name": "TickerUpdate", + "title": "Best Bid and Offer (BBO) update", + "summary": "Live update for an existing subscription to `ticker/{market_id}`.", + "x-message-type": "update/ticker", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "ticker": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "MarketStatsSubscribed": { + "name": "MarketStatsSubscribed", + "title": "Market Stats snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `market_stats/{market_id}`.", + "x-message-type": "subscribed/market_stats", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "market_stats": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "MarketStatsUpdate": { + "name": "MarketStatsUpdate", + "title": "Market Stats update", + "summary": "Live update for an existing subscription to `market_stats/{market_id}`.", + "x-message-type": "update/market_stats", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "market_stats": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "SpotMarketStatsSubscribed": { + "name": "SpotMarketStatsSubscribed", + "title": "Spot Market Stats snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `spot_market_stats/{market_id}`.", + "x-message-type": "subscribed/spot_market_stats", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "spot_market_stats": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "SpotMarketStatsUpdate": { + "name": "SpotMarketStatsUpdate", + "title": "Spot Market Stats update", + "summary": "Live update for an existing subscription to `spot_market_stats/{market_id}`.", + "x-message-type": "update/spot_market_stats", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "spot_market_stats": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "TradeSubscribed": { + "name": "TradeSubscribed", + "title": "Trade snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `trade/{market_id}`.", + "x-message-type": "subscribed/trade", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "trades": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + } + } + } + }, + "TradeUpdate": { + "name": "TradeUpdate", + "title": "Trade update", + "summary": "Live update for an existing subscription to `trade/{market_id}`.", + "x-message-type": "update/trade", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "trades": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + } + } + } + }, + "CandleSubscribed": { + "name": "CandleSubscribed", + "title": "Candlesticks snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `candle/{market_id}/{resolution}`.", + "x-message-type": "subscribed/candle", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "candle": { + "type": "object", + "additionalProperties": true + }, + "resolution": { + "type": "string" + } + } + } + }, + "CandleUpdate": { + "name": "CandleUpdate", + "title": "Candlesticks update", + "summary": "Live update for an existing subscription to `candle/{market_id}/{resolution}`.", + "x-message-type": "update/candle", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "candle": { + "type": "object", + "additionalProperties": true + }, + "resolution": { + "type": "string" + } + } + } + }, + "HeightSubscribed": { + "name": "HeightSubscribed", + "title": "Height snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `height`.", + "x-message-type": "subscribed/height", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + } + }, + "HeightUpdate": { + "name": "HeightUpdate", + "title": "Height update", + "summary": "Live update for an existing subscription to `height`.", + "x-message-type": "update/height", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + } + }, + "AccountAllSubscribed": { + "name": "AccountAllSubscribed", + "title": "Account All snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_all/{account_id}`.", + "x-message-type": "subscribed/account_all", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Position" + } + }, + "trades": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + }, + "funding_histories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionFunding" + } + }, + "funding_rates": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "shares": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PoolShares" + } + } + } + } + }, + "AccountAllUpdate": { + "name": "AccountAllUpdate", + "title": "Account All update", + "summary": "Live update for an existing subscription to `account_all/{account_id}`.", + "x-message-type": "update/account_all", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Position" + } + }, + "trades": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + }, + "funding_histories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionFunding" + } + }, + "funding_rates": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "shares": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PoolShares" + } + } + } + } + }, + "AccountMarketSubscribed": { + "name": "AccountMarketSubscribed", + "title": "Account Market snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_market/{market_id}/{account_id}`.", + "x-message-type": "subscribed/account_market", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "market_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Position" + } + }, + "trades": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + } + } + } + }, + "AccountMarketUpdate": { + "name": "AccountMarketUpdate", + "title": "Account Market update", + "summary": "Live update for an existing subscription to `account_market/{market_id}/{account_id}`.", + "x-message-type": "update/account_market", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "market_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Position" + } + }, + "trades": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + } + } + } + }, + "UserStatsSubscribed": { + "name": "UserStatsSubscribed", + "title": "Account Stats snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `user_stats/{account_id}`.", + "x-message-type": "subscribed/user_stats", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "stats": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "UserStatsUpdate": { + "name": "UserStatsUpdate", + "title": "Account Stats update", + "summary": "Live update for an existing subscription to `user_stats/{account_id}`.", + "x-message-type": "update/user_stats", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "stats": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "AccountTxSubscribed": { + "name": "AccountTxSubscribed", + "title": "Account Tx snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_tx/{account_id}`.", + "x-message-type": "subscribed/account_tx", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "txs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Transaction" + } + } + } + } + }, + "AccountTxUpdate": { + "name": "AccountTxUpdate", + "title": "Account Tx update", + "summary": "Live update for an existing subscription to `account_tx/{account_id}`.", + "x-message-type": "update/account_tx", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "txs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Transaction" + } + } + } + } + }, + "AccountAllOrdersSubscribed": { + "name": "AccountAllOrdersSubscribed", + "title": "Account All Orders snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_all_orders/{account_id}`.", + "x-message-type": "subscribed/account_all_orders", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + } + } + } + }, + "AccountAllOrdersUpdate": { + "name": "AccountAllOrdersUpdate", + "title": "Account All Orders update", + "summary": "Live update for an existing subscription to `account_all_orders/{account_id}`.", + "x-message-type": "update/account_all_orders", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + } + } + } + }, + "AccountOrdersSubscribed": { + "name": "AccountOrdersSubscribed", + "title": "Account Orders snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_orders/{market_id}/{account_id}`.", + "x-message-type": "subscribed/account_orders", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "market_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + } + } + } + }, + "AccountOrdersUpdate": { + "name": "AccountOrdersUpdate", + "title": "Account Orders update", + "summary": "Live update for an existing subscription to `account_orders/{market_id}/{account_id}`.", + "x-message-type": "update/account_orders", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "market_id": { + "type": "integer" + }, + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + } + } + } + }, + "AccountAllTradesSubscribed": { + "name": "AccountAllTradesSubscribed", + "title": "Account All Trades snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_all_trades/{account_id}`.", + "x-message-type": "subscribed/account_all_trades", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "trades": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + }, + { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + } + } + ] + }, + "total_volume": { + "type": "number" + }, + "monthly_volume": { + "type": "number" + } + } + } + }, + "AccountAllTradesUpdate": { + "name": "AccountAllTradesUpdate", + "title": "Account All Trades update", + "summary": "Live update for an existing subscription to `account_all_trades/{account_id}`.", + "x-message-type": "update/account_all_trades", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "trades": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + }, + { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + } + } + ] + }, + "total_volume": { + "type": "number" + }, + "monthly_volume": { + "type": "number" + } + } + } + }, + "AccountAllPositionsSubscribed": { + "name": "AccountAllPositionsSubscribed", + "title": "Account All Positions snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_all_positions/{account_id}`.", + "x-message-type": "subscribed/account_all_positions", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "positions": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Position" + } + } + } + } + }, + "AccountAllPositionsUpdate": { + "name": "AccountAllPositionsUpdate", + "title": "Account All Positions update", + "summary": "Live update for an existing subscription to `account_all_positions/{account_id}`.", + "x-message-type": "update/account_all_positions", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "positions": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Position" + } + } + } + } + }, + "AccountAllAssetsSubscribed": { + "name": "AccountAllAssetsSubscribed", + "title": "Account All Assets snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_all_assets/{account_id}`.", + "x-message-type": "subscribed/account_all_assets", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "assets": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Asset" + } + } + } + } + }, + "AccountAllAssetsUpdate": { + "name": "AccountAllAssetsUpdate", + "title": "Account All Assets update", + "summary": "Live update for an existing subscription to `account_all_assets/{account_id}`.", + "x-message-type": "update/account_all_assets", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "assets": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Asset" + } + } + } + } + }, + "AccountSpotAvgEntryPricesSubscribed": { + "name": "AccountSpotAvgEntryPricesSubscribed", + "title": "Average Entry Prices snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `account_spot_avg_entry_prices/{account_id}`.", + "x-message-type": "subscribed/account_spot_avg_entry_prices", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "avg_entry_prices": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "asset_id": { + "type": "integer" + }, + "avg_entry_price": { + "type": "string" + }, + "asset_size": { + "type": "string" + }, + "last_trade_id": { + "type": "integer" + } + } + } + } + } + } + }, + "AccountSpotAvgEntryPricesUpdate": { + "name": "AccountSpotAvgEntryPricesUpdate", + "title": "Average Entry Prices update", + "summary": "Live update for an existing subscription to `account_spot_avg_entry_prices/{account_id}`.", + "x-message-type": "update/account_spot_avg_entry_prices", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "avg_entry_prices": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "asset_id": { + "type": "integer" + }, + "avg_entry_price": { + "type": "string" + }, + "asset_size": { + "type": "string" + }, + "last_trade_id": { + "type": "integer" + } + } + } + } + } + } + }, + "NotificationSubscribed": { + "name": "NotificationSubscribed", + "title": "Notification snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `notification/{account_id}`.", + "x-message-type": "subscribed/notification", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "notifications": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "NotificationUpdate": { + "name": "NotificationUpdate", + "title": "Notification update", + "summary": "Live update for an existing subscription to `notification/{account_id}`.", + "x-message-type": "update/notification", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + }, + "notifications": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "PoolDataSubscribed": { + "name": "PoolDataSubscribed", + "title": "Pool Data snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `pool_data/{account_id}`.", + "x-message-type": "subscribed/pool_data", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + } + } + } + }, + "PoolDataUpdate": { + "name": "PoolDataUpdate", + "title": "Pool Data update", + "summary": "Live update for an existing subscription to `pool_data/{account_id}`.", + "x-message-type": "update/pool_data", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + } + } + } + }, + "PoolInfoSubscribed": { + "name": "PoolInfoSubscribed", + "title": "Pool Info snapshot", + "summary": "Initial snapshot delivered after a successful `subscribe` to `pool_info/{account_id}`.", + "x-message-type": "subscribed/pool_info", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + } + } + } + }, + "PoolInfoUpdate": { + "name": "PoolInfoUpdate", + "title": "Pool Info update", + "summary": "Live update for an existing subscription to `pool_info/{account_id}`.", + "x-message-type": "update/pool_info", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "account_id": { + "type": "integer" + } + } + } + }, + "Connected": { + "name": "Connected", + "title": "Connection welcome", + "summary": "Sent once when the WebSocket connection is established.", + "x-message-type": "connected", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "connected" + } + } + } + }, + "ServerError": { + "name": "ServerError", + "title": "Server error", + "summary": "Server-emitted error frame.", + "x-message-type": "error", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "error" + }, + "message": { + "type": "string" + }, + "code": { + "type": "integer" + } + } + } + }, + "Pong": { + "name": "Pong", + "title": "Application-level pong", + "summary": "Reply to a client-sent `ping` frame.", + "x-message-type": "pong", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "pong" + } + } + } + }, + "TxResponse": { + "name": "TxResponse", + "title": "Transaction submission response", + "summary": "Reply to `jsonapi/sendtx` or `jsonapi/sendtxbatch`.", + "x-message-type": "jsonapi/sendtx", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "tx_hash": { + "type": "string" + }, + "tx_hashes": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": {} + } + } + }, + "SubscribeRequest": { + "name": "SubscribeRequest", + "title": "Subscribe", + "summary": "Open a subscription on a channel.", + "x-message-type": "subscribe", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type", + "channel" + ], + "properties": { + "type": { + "const": "subscribe" + }, + "channel": { + "type": "string", + "description": "Channel address. Use `/` as the path separator (e.g. `order_book/0`)." + }, + "auth": { + "type": "string", + "description": "Bearer token. Required for the channels listed under `securitySchemes.bearerToken`." + } + } + } + }, + "UnsubscribeRequest": { + "name": "UnsubscribeRequest", + "title": "Unsubscribe", + "summary": "Cancel an existing subscription.", + "x-message-type": "unsubscribe", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type", + "channel" + ], + "properties": { + "type": { + "const": "unsubscribe" + }, + "channel": { + "type": "string" + } + } + } + }, + "Ping": { + "name": "Ping", + "title": "Application-level ping", + "summary": "Heartbeat frame. The server replies with a `Pong`. Either WebSocket-level ping frames or this application-level frame satisfy the 2-minute idle requirement.", + "x-message-type": "ping", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "ping" + } + } + } + }, + "SendTx": { + "name": "SendTx", + "title": "Send transaction", + "summary": "Submit a single signed transaction over the socket.", + "x-message-type": "jsonapi/sendtx", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type", + "data" + ], + "properties": { + "type": { + "const": "jsonapi/sendtx" + }, + "data": { + "type": "object", + "additionalProperties": true, + "required": [ + "tx_type", + "tx_info" + ], + "properties": { + "tx_type": { + "type": "integer" + }, + "tx_info": { + "description": "Signed payload produced by SignerClient. Usually a JSON-encoded string." + } + } + } + } + } + }, + "SendTxBatch": { + "name": "SendTxBatch", + "title": "Send transaction batch", + "summary": "Submit up to 15 signed transactions in one message. `tx_infos` is a JSON-encoded list of JSON-encoded `tx_info` strings (double-encoded).", + "x-message-type": "jsonapi/sendtxbatch", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": true, + "required": [ + "type", + "data" + ], + "properties": { + "type": { + "const": "jsonapi/sendtxbatch" + }, + "data": { + "type": "object", + "additionalProperties": true, + "required": [ + "tx_types", + "tx_infos" + ], + "properties": { + "tx_types": { + "type": "string", + "description": "JSON-encoded list of integer tx types, e.g. `\"[14,14]\"`." + }, + "tx_infos": { + "type": "string", + "description": "JSON-encoded list of JSON-encoded tx_info strings." + } + } + } + } + } + } + }, + "schemas": { + "Transaction": { + "type": "object", + "additionalProperties": true, + "description": "Transaction as emitted on the `account_tx` channel.", + "properties": { + "hash": { + "type": "string" + }, + "type": { + "type": "integer" + }, + "info": { + "type": "string", + "description": "JSON object encoded as string; shape depends on tx type." + }, + "event_info": { + "type": "string", + "description": "JSON object encoded as string; shape depends on tx type." + }, + "status": { + "type": "integer" + }, + "transaction_index": { + "type": "integer" + }, + "l1_address": { + "type": "string" + }, + "account_index": { + "type": "integer" + }, + "nonce": { + "type": "integer" + }, + "expire_at": { + "type": "integer" + }, + "block_height": { + "type": "integer" + }, + "queued_at": { + "type": "integer" + }, + "executed_at": { + "type": "integer" + }, + "sequence_index": { + "type": "integer" + }, + "parent_hash": { + "type": "string" + }, + "api_key_index": { + "type": "integer" + }, + "transaction_time": { + "type": "integer" + } + } + }, + "Order": { + "type": "object", + "additionalProperties": true, + "properties": { + "order_index": { + "type": "integer" + }, + "client_order_index": { + "type": "integer" + }, + "order_id": { + "type": "string" + }, + "client_order_id": { + "type": "string" + }, + "market_index": { + "type": "integer" + }, + "owner_account_index": { + "type": "integer" + }, + "initial_base_amount": { + "type": "string" + }, + "price": { + "type": "string" + }, + "nonce": { + "type": "integer" + }, + "remaining_base_amount": { + "type": "string" + }, + "is_ask": { + "type": "boolean" + }, + "base_size": { + "type": "integer" + }, + "base_price": { + "type": "integer" + }, + "filled_base_amount": { + "type": "string" + }, + "filled_quote_amount": { + "type": "string" + }, + "side": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "limit", + "market", + "stop-loss", + "stop-loss-limit", + "take-profit", + "take-profit-limit", + "twap", + "twap-sub", + "liquidation" + ] + }, + "time_in_force": { + "type": "string", + "enum": [ + "good-till-time", + "immediate-or-cancel", + "post-only", + "Unknown" + ] + }, + "reduce_only": { + "type": "boolean" + }, + "trigger_price": { + "type": "string" + }, + "order_expiry": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "trigger_status": { + "type": "string" + }, + "trigger_time": { + "type": "integer" + }, + "parent_order_index": { + "type": "integer" + }, + "parent_order_id": { + "type": "string" + }, + "to_trigger_order_id_0": { + "type": "string" + }, + "to_trigger_order_id_1": { + "type": "string" + }, + "to_cancel_order_id_0": { + "type": "string" + }, + "integrator_fee_collector_index": { + "type": "string" + }, + "integrator_taker_fee": { + "type": "string" + }, + "integrator_maker_fee": { + "type": "string" + }, + "block_height": { + "type": "integer" + } + } + }, + "Trade": { + "type": "object", + "additionalProperties": true, + "properties": { + "trade_id": { + "type": "integer" + }, + "market_id": { + "type": "integer" + }, + "size": { + "type": "string" + }, + "price": { + "type": "string" + }, + "usd_amount": { + "type": "string" + }, + "ask_id": { + "type": "integer" + }, + "ask_account_id": { + "type": "integer" + }, + "bid_id": { + "type": "integer" + }, + "bid_account_id": { + "type": "integer" + }, + "is_maker_ask": { + "type": "boolean" + }, + "block_height": { + "type": "integer" + }, + "timestamp": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "Position": { + "type": "object", + "additionalProperties": true, + "properties": { + "market_id": { + "type": "integer" + }, + "sign": { + "type": "integer" + }, + "position": { + "type": "string" + }, + "avg_entry_price": { + "type": "string" + }, + "position_value": { + "type": "string" + }, + "unrealized_pnl": { + "type": "string" + }, + "realized_pnl": { + "type": "string" + }, + "margin_mode": { + "type": "integer" + }, + "allocated_margin": { + "type": "string" + }, + "liquidation_price": { + "type": "string" + } + } + }, + "PoolShares": { + "type": "object", + "additionalProperties": true, + "properties": { + "pool_account_index": { + "type": "integer" + }, + "owner_account_index": { + "type": "integer" + }, + "shares_amount": { + "type": "string" + }, + "entry_usdc_amount": { + "type": "string" + } + } + }, + "Asset": { + "type": "object", + "additionalProperties": true, + "properties": { + "symbol": { + "type": "string" + }, + "asset_id": { + "type": "integer" + }, + "balance": { + "type": "string" + }, + "locked_balance": { + "type": "string" + } + } + }, + "PositionFunding": { + "type": "object", + "additionalProperties": true, + "properties": { + "timestamp": { + "type": "integer" + }, + "market_id": { + "type": "integer" + }, + "funding_id": { + "type": "integer" + }, + "change": { + "type": "string" + }, + "rate": { + "type": "string" + }, + "position_size": { + "type": "string" + }, + "position_side": { + "type": "string", + "enum": [ + "long", + "short" + ] + }, + "discount": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "bearerToken": { + "type": "http", + "scheme": "bearer", + "description": "Per-channel auth token passed in the `auth` field of the `subscribe` message. Required for channels whose subscribe operation lists this scheme under `security`. See the `apikeys` REST endpoint for token generation." + } + } + } +} diff --git a/tools/gen_asyncapi.py b/tools/gen_asyncapi.py new file mode 100644 index 0000000..744cb13 --- /dev/null +++ b/tools/gen_asyncapi.py @@ -0,0 +1,917 @@ +"""Generate asyncapi.json for the Lighter WebSocket API. + +This script is a one-shot generator — only the produced ``asyncapi.json`` +is checked into the repo. The script lives outside the repo and is kept +in this file purely so the spec can be reproduced or extended later. + +The structure follows AsyncAPI 3.0 (https://www.asyncapi.com/docs/reference/specification/v3.0.0). +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + + +# --------------------------------------------------------------------- +# Reused data schemas (from the "Types" section of the docs) +# --------------------------------------------------------------------- + +# All shared schemas keep ``additionalProperties: true`` so that +# server-side field additions don't require a spec bump to keep clients +# parsing successfully. Channel-specific fields are optional for the +# same reason. + +SHARED_SCHEMAS: Dict[str, Dict[str, Any]] = { + "Transaction": { + "type": "object", + "additionalProperties": True, + "description": "Transaction as emitted on the `account_tx` channel.", + "properties": { + "hash": {"type": "string"}, + "type": {"type": "integer"}, + "info": {"type": "string", "description": "JSON object encoded as string; shape depends on tx type."}, + "event_info": {"type": "string", "description": "JSON object encoded as string; shape depends on tx type."}, + "status": {"type": "integer"}, + "transaction_index": {"type": "integer"}, + "l1_address": {"type": "string"}, + "account_index": {"type": "integer"}, + "nonce": {"type": "integer"}, + "expire_at": {"type": "integer"}, + "block_height": {"type": "integer"}, + "queued_at": {"type": "integer"}, + "executed_at": {"type": "integer"}, + "sequence_index": {"type": "integer"}, + "parent_hash": {"type": "string"}, + "api_key_index": {"type": "integer"}, + "transaction_time": {"type": "integer"}, + }, + }, + "Order": { + "type": "object", + "additionalProperties": True, + "properties": { + "order_index": {"type": "integer"}, + "client_order_index": {"type": "integer"}, + "order_id": {"type": "string"}, + "client_order_id": {"type": "string"}, + "market_index": {"type": "integer"}, + "owner_account_index": {"type": "integer"}, + "initial_base_amount": {"type": "string"}, + "price": {"type": "string"}, + "nonce": {"type": "integer"}, + "remaining_base_amount": {"type": "string"}, + "is_ask": {"type": "boolean"}, + "base_size": {"type": "integer"}, + "base_price": {"type": "integer"}, + "filled_base_amount": {"type": "string"}, + "filled_quote_amount": {"type": "string"}, + "side": {"type": "string"}, + "type": { + "type": "string", + "enum": [ + "limit", + "market", + "stop-loss", + "stop-loss-limit", + "take-profit", + "take-profit-limit", + "twap", + "twap-sub", + "liquidation", + ], + }, + "time_in_force": { + "type": "string", + "enum": [ + "good-till-time", + "immediate-or-cancel", + "post-only", + "Unknown", + ], + }, + "reduce_only": {"type": "boolean"}, + "trigger_price": {"type": "string"}, + "order_expiry": {"type": "integer"}, + "status": {"type": "string"}, + "trigger_status": {"type": "string"}, + "trigger_time": {"type": "integer"}, + "parent_order_index": {"type": "integer"}, + "parent_order_id": {"type": "string"}, + "to_trigger_order_id_0": {"type": "string"}, + "to_trigger_order_id_1": {"type": "string"}, + "to_cancel_order_id_0": {"type": "string"}, + "integrator_fee_collector_index": {"type": "string"}, + "integrator_taker_fee": {"type": "string"}, + "integrator_maker_fee": {"type": "string"}, + "block_height": {"type": "integer"}, + }, + }, + "Trade": { + "type": "object", + "additionalProperties": True, + "properties": { + "trade_id": {"type": "integer"}, + "market_id": {"type": "integer"}, + "size": {"type": "string"}, + "price": {"type": "string"}, + "usd_amount": {"type": "string"}, + "ask_id": {"type": "integer"}, + "ask_account_id": {"type": "integer"}, + "bid_id": {"type": "integer"}, + "bid_account_id": {"type": "integer"}, + "is_maker_ask": {"type": "boolean"}, + "block_height": {"type": "integer"}, + "timestamp": {"type": "integer"}, + "type": {"type": "string"}, + }, + }, + "Position": { + "type": "object", + "additionalProperties": True, + "properties": { + "market_id": {"type": "integer"}, + "sign": {"type": "integer"}, + "position": {"type": "string"}, + "avg_entry_price": {"type": "string"}, + "position_value": {"type": "string"}, + "unrealized_pnl": {"type": "string"}, + "realized_pnl": {"type": "string"}, + "margin_mode": {"type": "integer"}, + "allocated_margin": {"type": "string"}, + "liquidation_price": {"type": "string"}, + }, + }, + "PoolShares": { + "type": "object", + "additionalProperties": True, + "properties": { + "pool_account_index": {"type": "integer"}, + "owner_account_index": {"type": "integer"}, + "shares_amount": {"type": "string"}, + "entry_usdc_amount": {"type": "string"}, + }, + }, + "Asset": { + "type": "object", + "additionalProperties": True, + "properties": { + "symbol": {"type": "string"}, + "asset_id": {"type": "integer"}, + "balance": {"type": "string"}, + "locked_balance": {"type": "string"}, + }, + }, + "PositionFunding": { + "type": "object", + "additionalProperties": True, + "properties": { + "timestamp": {"type": "integer"}, + "market_id": {"type": "integer"}, + "funding_id": {"type": "integer"}, + "change": {"type": "string"}, + "rate": {"type": "string"}, + "position_size": {"type": "string"}, + "position_side": {"type": "string", "enum": ["long", "short"]}, + "discount": {"type": "string"}, + }, + }, +} + + +# --------------------------------------------------------------------- +# Channels. +# +# Each entry: id, address (with {param} placeholders), parameters, +# whether subscribe requires auth, and the message-payload extras +# (fields beyond the envelope's `type` + `channel`). +# --------------------------------------------------------------------- + + +def envelope_payload(extras: Dict[str, Any]) -> Dict[str, Any]: + """Build the JSON Schema for a server message payload. + + Every message carries ``type`` and ``channel``; everything else is + channel-specific and optional. ``additionalProperties: true`` is + deliberate (see the forward-compat policy in ws_messages.py). + """ + return { + "type": "object", + "additionalProperties": True, + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "channel": {"type": "string"}, + "timestamp": {"type": "integer"}, + **extras, + }, + } + + +CHANNELS: List[Dict[str, Any]] = [ + # ----- public market data ----- + { + "id": "order_book", + "address": "order_book/{market_id}", + "parameters": {"market_id": {"description": "Market index."}}, + "auth_required": False, + "title": "Order Book", + "description": "Order book snapshots and diffs for a given market. Snapshots ship on subscribe; subsequent messages are price-level diffs.", + "payload_extras": { + "order_book": { + "type": "object", + "additionalProperties": True, + "properties": { + "asks": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + "bids": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + "offset": {"type": "integer"}, + }, + }, + }, + }, + { + "id": "ticker", + "address": "ticker/{market_id}", + "parameters": {"market_id": {"description": "Market index."}}, + "auth_required": False, + "title": "Best Bid and Offer (BBO)", + "description": "Best bid/offer updates for a given market.", + "payload_extras": { + "ticker": {"type": "object", "additionalProperties": True}, + }, + }, + { + "id": "market_stats", + "address": "market_stats/{market_id}", + "parameters": { + "market_id": { + "description": "Market index, or the literal string `all` to receive stats for every market.", + }, + }, + "auth_required": False, + "title": "Market Stats", + "description": "Per-market rolling stats (volume, price change, etc.). Pass `all` as the market id to receive every market on one subscription.", + "payload_extras": { + "market_stats": {"type": "object", "additionalProperties": True}, + }, + }, + { + "id": "spot_market_stats", + "address": "spot_market_stats/{market_id}", + "parameters": { + "market_id": { + "description": "Spot market index, or the literal string `all`.", + }, + }, + "auth_required": False, + "title": "Spot Market Stats", + "description": "Per-spot-market rolling stats. Pass `all` to receive every spot market on one subscription.", + "payload_extras": { + "spot_market_stats": {"type": "object", "additionalProperties": True}, + }, + }, + { + "id": "trade", + "address": "trade/{market_id}", + "parameters": {"market_id": {"description": "Market index."}}, + "auth_required": False, + "title": "Trade", + "description": "Public trade stream for a given market.", + "payload_extras": { + "trades": { + "type": "array", + "items": {"$ref": "#/components/schemas/Trade"}, + }, + }, + }, + { + "id": "candle", + "address": "candle/{market_id}/{resolution}", + "parameters": { + "market_id": {"description": "Market index."}, + "resolution": { + "description": "Candle resolution, e.g. `1m`, `5m`, `1h`, `1d`.", + }, + }, + "auth_required": False, + "title": "Candlesticks", + "description": "Candlestick stream for a (market, resolution) pair.", + "payload_extras": { + "candle": {"type": "object", "additionalProperties": True}, + "resolution": {"type": "string"}, + }, + }, + { + "id": "height", + "address": "height", + "parameters": {}, + "auth_required": False, + "title": "Height", + "description": "Latest L2 block height.", + "payload_extras": { + "height": {"type": "integer"}, + }, + }, + # ----- account-scoped streams ----- + { + "id": "account_all", + "address": "account_all/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + # The docs subscribe example does not show an `auth` field for + # this channel. Marked unauthenticated in the spec for parity; + # see the README note next to AUTH_REQUIRED_PREFIXES in + # ws_client.py — the auth-required list is documented to match + # the docs page exactly. + "auth_required": False, + "title": "Account All", + "description": "Combined account stream: orders, positions, trades, funding histories/rates, and pool shares.", + "payload_extras": { + "account_id": {"type": "integer"}, + "orders": {"type": "array", "items": {"$ref": "#/components/schemas/Order"}}, + "positions": {"type": "array", "items": {"$ref": "#/components/schemas/Position"}}, + "trades": {"type": "array", "items": {"$ref": "#/components/schemas/Trade"}}, + "funding_histories": {"type": "array", "items": {"$ref": "#/components/schemas/PositionFunding"}}, + "funding_rates": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + "shares": {"type": "array", "items": {"$ref": "#/components/schemas/PoolShares"}}, + }, + }, + { + "id": "account_market", + "address": "account_market/{market_id}/{account_id}", + "parameters": { + "market_id": {"description": "Market index."}, + "account_id": {"description": "Account index."}, + }, + "auth_required": True, + "title": "Account Market", + "description": "Per-market view of a specific account (orders, positions, trades restricted to one market).", + "payload_extras": { + "account_id": {"type": "integer"}, + "market_id": {"type": "integer"}, + "orders": {"type": "array", "items": {"$ref": "#/components/schemas/Order"}}, + "positions": {"type": "array", "items": {"$ref": "#/components/schemas/Position"}}, + "trades": {"type": "array", "items": {"$ref": "#/components/schemas/Trade"}}, + }, + }, + { + "id": "user_stats", + "address": "user_stats/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": False, + "title": "Account Stats", + "description": "Aggregate stats for an account (collateral, portfolio value, etc.).", + "payload_extras": { + "account_id": {"type": "integer"}, + "stats": {"type": "object", "additionalProperties": True}, + }, + }, + { + "id": "account_tx", + "address": "account_tx/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": True, + "title": "Account Tx", + "description": "Transaction history for a specific account.", + "payload_extras": { + "account_id": {"type": "integer"}, + "txs": { + "type": "array", + "items": {"$ref": "#/components/schemas/Transaction"}, + }, + }, + }, + { + "id": "account_all_orders", + "address": "account_all_orders/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": True, + "title": "Account All Orders", + "description": "All orders across markets for an account.", + "payload_extras": { + "account_id": {"type": "integer"}, + "orders": {"type": "array", "items": {"$ref": "#/components/schemas/Order"}}, + }, + }, + { + "id": "account_orders", + "address": "account_orders/{market_id}/{account_id}", + "parameters": { + "market_id": {"description": "Market index."}, + "account_id": {"description": "Account index."}, + }, + "auth_required": True, + "title": "Account Orders", + "description": "Orders for an account scoped to a single market.", + "payload_extras": { + "account_id": {"type": "integer"}, + "market_id": {"type": "integer"}, + "orders": {"type": "array", "items": {"$ref": "#/components/schemas/Order"}}, + }, + }, + { + "id": "account_all_trades", + "address": "account_all_trades/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": False, + "title": "Account All Trades", + "description": "All trades for an account across markets. Snapshot keys trades by market index; updates may emit a flat list.", + "payload_extras": { + "account_id": {"type": "integer"}, + "trades": { + "oneOf": [ + {"type": "array", "items": {"$ref": "#/components/schemas/Trade"}}, + { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"$ref": "#/components/schemas/Trade"}, + }, + }, + ], + }, + "total_volume": {"type": "number"}, + "monthly_volume": {"type": "number"}, + }, + }, + { + "id": "account_all_positions", + "address": "account_all_positions/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": False, + "title": "Account All Positions", + "description": "All positions for an account, keyed by market index.", + "payload_extras": { + "account_id": {"type": "integer"}, + "positions": { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/Position"}, + }, + }, + }, + { + "id": "account_all_assets", + "address": "account_all_assets/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": True, + "title": "Account All Assets", + "description": "Per-asset balances for all spot markets for a specific account. `balance` is in coin terms, not USDC.", + "payload_extras": { + "account_id": {"type": "integer"}, + "assets": { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/Asset"}, + }, + }, + }, + { + "id": "account_spot_avg_entry_prices", + "address": "account_spot_avg_entry_prices/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": True, + "title": "Average Entry Prices", + "description": "Spot avg-entry-price stream. Each event accounts as a buy/sell at the index price; `last_trade_id` confirms the validity horizon.", + "payload_extras": { + "account_id": {"type": "integer"}, + "avg_entry_prices": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": True, + "properties": { + "asset_id": {"type": "integer"}, + "avg_entry_price": {"type": "string"}, + "asset_size": {"type": "string"}, + "last_trade_id": {"type": "integer"}, + }, + }, + }, + }, + }, + { + "id": "notification", + "address": "notification/{account_id}", + "parameters": {"account_id": {"description": "Account index."}}, + "auth_required": True, + "title": "Notification", + "description": "Per-account notification stream.", + "payload_extras": { + "account_id": {"type": "integer"}, + "notifications": { + "type": "array", + "items": {"type": "object", "additionalProperties": True}, + }, + }, + }, + { + "id": "pool_data", + "address": "pool_data/{account_id}", + "parameters": {"account_id": {"description": "Pool account index."}}, + "auth_required": True, + "title": "Pool Data", + "description": "Live data for a public pool account.", + "payload_extras": { + "account_id": {"type": "integer"}, + }, + }, + { + "id": "pool_info", + "address": "pool_info/{account_id}", + "parameters": {"account_id": {"description": "Pool account index."}}, + "auth_required": True, + "title": "Pool Info", + "description": "Public pool metadata.", + "payload_extras": { + "account_id": {"type": "integer"}, + }, + }, +] + + +# --------------------------------------------------------------------- +# Build the document +# --------------------------------------------------------------------- + + +def build_message_components() -> Dict[str, Dict[str, Any]]: + messages: Dict[str, Dict[str, Any]] = {} + # Per-channel server messages. + for ch in CHANNELS: + camel = "".join(p.title() for p in ch["id"].split("_")) + payload_schema = envelope_payload(ch["payload_extras"]) + subscribed_type = f"subscribed/{ch['id']}" + update_type = f"update/{ch['id']}" + messages[f"{camel}Subscribed"] = { + "name": f"{camel}Subscribed", + "title": f"{ch['title']} snapshot", + "summary": f"Initial snapshot delivered after a successful `subscribe` to `{ch['address']}`.", + "x-message-type": subscribed_type, + "contentType": "application/json", + "payload": payload_schema, + } + messages[f"{camel}Update"] = { + "name": f"{camel}Update", + "title": f"{ch['title']} update", + "summary": f"Live update for an existing subscription to `{ch['address']}`.", + "x-message-type": update_type, + "contentType": "application/json", + "payload": payload_schema, + } + + # Global / control-plane messages. + messages["Connected"] = { + "name": "Connected", + "title": "Connection welcome", + "summary": "Sent once when the WebSocket connection is established.", + "x-message-type": "connected", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type"], + "properties": {"type": {"const": "connected"}}, + }, + } + messages["ServerError"] = { + "name": "ServerError", + "title": "Server error", + "summary": "Server-emitted error frame.", + "x-message-type": "error", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type"], + "properties": { + "type": {"const": "error"}, + "message": {"type": "string"}, + "code": {"type": "integer"}, + }, + }, + } + messages["Pong"] = { + "name": "Pong", + "title": "Application-level pong", + "summary": "Reply to a client-sent `ping` frame.", + "x-message-type": "pong", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type"], + "properties": {"type": {"const": "pong"}}, + }, + } + messages["TxResponse"] = { + "name": "TxResponse", + "title": "Transaction submission response", + "summary": "Reply to `jsonapi/sendtx` or `jsonapi/sendtxbatch`.", + "x-message-type": "jsonapi/sendtx", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "id": {"type": "string"}, + "code": {"type": "integer"}, + "message": {"type": "string"}, + "tx_hash": {"type": "string"}, + "tx_hashes": {"type": "array", "items": {"type": "string"}}, + "error": {}, + }, + }, + } + + # Client → server messages. + messages["SubscribeRequest"] = { + "name": "SubscribeRequest", + "title": "Subscribe", + "summary": "Open a subscription on a channel.", + "x-message-type": "subscribe", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type", "channel"], + "properties": { + "type": {"const": "subscribe"}, + "channel": { + "type": "string", + "description": "Channel address. Use `/` as the path separator (e.g. `order_book/0`).", + }, + "auth": { + "type": "string", + "description": "Bearer token. Required for the channels listed under `securitySchemes.bearerToken`.", + }, + }, + }, + } + messages["UnsubscribeRequest"] = { + "name": "UnsubscribeRequest", + "title": "Unsubscribe", + "summary": "Cancel an existing subscription.", + "x-message-type": "unsubscribe", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type", "channel"], + "properties": { + "type": {"const": "unsubscribe"}, + "channel": {"type": "string"}, + }, + }, + } + messages["Ping"] = { + "name": "Ping", + "title": "Application-level ping", + "summary": "Heartbeat frame. The server replies with a `Pong`. Either WebSocket-level ping frames or this application-level frame satisfy the 2-minute idle requirement.", + "x-message-type": "ping", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type"], + "properties": {"type": {"const": "ping"}}, + }, + } + messages["SendTx"] = { + "name": "SendTx", + "title": "Send transaction", + "summary": "Submit a single signed transaction over the socket.", + "x-message-type": "jsonapi/sendtx", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type", "data"], + "properties": { + "type": {"const": "jsonapi/sendtx"}, + "data": { + "type": "object", + "additionalProperties": True, + "required": ["tx_type", "tx_info"], + "properties": { + "tx_type": {"type": "integer"}, + "tx_info": { + "description": "Signed payload produced by SignerClient. Usually a JSON-encoded string.", + }, + }, + }, + }, + }, + } + messages["SendTxBatch"] = { + "name": "SendTxBatch", + "title": "Send transaction batch", + "summary": "Submit up to 15 signed transactions in one message. `tx_infos` is a JSON-encoded list of JSON-encoded `tx_info` strings (double-encoded).", + "x-message-type": "jsonapi/sendtxbatch", + "contentType": "application/json", + "payload": { + "type": "object", + "additionalProperties": True, + "required": ["type", "data"], + "properties": { + "type": {"const": "jsonapi/sendtxbatch"}, + "data": { + "type": "object", + "additionalProperties": True, + "required": ["tx_types", "tx_infos"], + "properties": { + "tx_types": { + "type": "string", + "description": "JSON-encoded list of integer tx types, e.g. `\"[14,14]\"`.", + }, + "tx_infos": { + "type": "string", + "description": "JSON-encoded list of JSON-encoded tx_info strings.", + }, + }, + }, + }, + }, + } + + return messages + + +def build_channels() -> Dict[str, Dict[str, Any]]: + channels: Dict[str, Dict[str, Any]] = {} + for ch in CHANNELS: + camel = "".join(p.title() for p in ch["id"].split("_")) + params = { + name: {"description": spec["description"]} + for name, spec in ch["parameters"].items() + } + ch_doc: Dict[str, Any] = { + "address": ch["address"], + "title": ch["title"], + "description": ch["description"], + "messages": { + f"{camel}Subscribed": {"$ref": f"#/components/messages/{camel}Subscribed"}, + f"{camel}Update": {"$ref": f"#/components/messages/{camel}Update"}, + }, + } + if params: + ch_doc["parameters"] = params + channels[ch["id"]] = ch_doc + + # Single control-plane channel for everything that isn't tied to a + # subscription address (the WebSocket itself). + channels["_control"] = { + "address": "(connection)", + "title": "Connection control plane", + "description": "Frames not tied to a subscription address: client → server `subscribe`/`unsubscribe`/`ping`/`jsonapi/sendtx`/`jsonapi/sendtxbatch`, and server → client `connected`/`error`/`pong`/`jsonapi/*` responses.", + "messages": { + "SubscribeRequest": {"$ref": "#/components/messages/SubscribeRequest"}, + "UnsubscribeRequest": {"$ref": "#/components/messages/UnsubscribeRequest"}, + "Ping": {"$ref": "#/components/messages/Ping"}, + "SendTx": {"$ref": "#/components/messages/SendTx"}, + "SendTxBatch": {"$ref": "#/components/messages/SendTxBatch"}, + "Connected": {"$ref": "#/components/messages/Connected"}, + "ServerError": {"$ref": "#/components/messages/ServerError"}, + "Pong": {"$ref": "#/components/messages/Pong"}, + "TxResponse": {"$ref": "#/components/messages/TxResponse"}, + }, + } + return channels + + +def build_operations() -> Dict[str, Dict[str, Any]]: + operations: Dict[str, Dict[str, Any]] = {} + for ch in CHANNELS: + camel = "".join(p.title() for p in ch["id"].split("_")) + sub_op: Dict[str, Any] = { + "action": "send", + "channel": {"$ref": f"#/channels/{ch['id']}"}, + "summary": f"Subscribe to `{ch['address']}`.", + "messages": [{"$ref": "#/components/messages/SubscribeRequest"}], + } + if ch["auth_required"]: + sub_op["security"] = [{"$ref": "#/components/securitySchemes/bearerToken"}] + operations[f"subscribe_{ch['id']}"] = sub_op + + operations[f"receive_{ch['id']}"] = { + "action": "receive", + "channel": {"$ref": f"#/channels/{ch['id']}"}, + "summary": f"Receive snapshot + updates for `{ch['address']}`.", + "messages": [ + {"$ref": f"#/channels/{ch['id']}/messages/{camel}Subscribed"}, + {"$ref": f"#/channels/{ch['id']}/messages/{camel}Update"}, + ], + } + + # Control-plane operations. + operations["unsubscribe"] = { + "action": "send", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Cancel an existing subscription.", + "messages": [{"$ref": "#/components/messages/UnsubscribeRequest"}], + } + operations["ping"] = { + "action": "send", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Application-level heartbeat (server replies with `Pong`).", + "messages": [{"$ref": "#/components/messages/Ping"}], + } + operations["send_tx"] = { + "action": "send", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Submit a single signed transaction.", + "messages": [{"$ref": "#/components/messages/SendTx"}], + } + operations["send_tx_batch"] = { + "action": "send", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Submit up to 15 signed transactions in a single frame.", + "messages": [{"$ref": "#/components/messages/SendTxBatch"}], + } + operations["receive_connected"] = { + "action": "receive", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Connection welcome frame.", + "messages": [{"$ref": "#/channels/_control/messages/Connected"}], + } + operations["receive_error"] = { + "action": "receive", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Server error frame.", + "messages": [{"$ref": "#/channels/_control/messages/ServerError"}], + } + operations["receive_pong"] = { + "action": "receive", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Reply to a client `Ping`.", + "messages": [{"$ref": "#/channels/_control/messages/Pong"}], + } + operations["receive_tx_response"] = { + "action": "receive", + "channel": {"$ref": "#/channels/_control"}, + "summary": "Reply to `jsonapi/sendtx` or `jsonapi/sendtxbatch` (success or error).", + "messages": [{"$ref": "#/channels/_control/messages/TxResponse"}], + } + return operations + + +def build_document() -> Dict[str, Any]: + return { + "asyncapi": "3.0.0", + "info": { + "title": "Lighter WebSocket API", + "version": "1.0.0", + "description": ( + "Real-time market data, account state, and transaction submission " + "for the zkLighter exchange. Hand-mirrored from " + "https://apidocs.lighter.xyz/docs/websocket-reference. The schemas " + "are intentionally permissive (`additionalProperties: true`, all " + "channel-specific fields optional) so server-side additions do " + "not invalidate generated clients." + ), + "contact": { + "name": "Lighter API docs", + "url": "https://apidocs.lighter.xyz/docs/websocket-reference", + }, + }, + "defaultContentType": "application/json", + "servers": { + "mainnet": { + "host": "mainnet.zklighter.elliot.ai", + "pathname": "/stream", + "protocol": "wss", + "description": "Mainnet WebSocket gateway. Append `?readonly=true` to bypass IP region restrictions for read-only data.", + }, + "testnet": { + "host": "testnet.zklighter.elliot.ai", + "pathname": "/stream", + "protocol": "wss", + "description": "Testnet WebSocket gateway.", + }, + }, + "channels": build_channels(), + "operations": build_operations(), + "components": { + "messages": build_message_components(), + "schemas": SHARED_SCHEMAS, + "securitySchemes": { + "bearerToken": { + "type": "http", + "scheme": "bearer", + "description": ( + "Per-channel auth token passed in the `auth` field of the `subscribe` " + "message. Required for channels whose subscribe operation lists this " + "scheme under `security`. See the `apikeys` REST endpoint for token " + "generation." + ), + }, + }, + }, + } + + +if __name__ == "__main__": + import sys + + out = build_document() + json.dump(out, sys.stdout, indent=2, sort_keys=False) + sys.stdout.write("\n")