Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@
All notable changes to the `oxarchive` Rust SDK are tracked in this file.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com).

## [1.13.0] - 2026-10-08

### Added
- The `mempool` WebSocket channel (`ws::MEMPOOL_CHANNEL`): signed Hyperliquid
transactions as our Hyperliquid node receives them from its peers, before
they are included in a block, for every Hyperliquid product. It is live
only, served only at `wss://stream.0xarchive.io/ws`, and included with the
Pro, Scale and Enterprise plans. Every other channel stays on every plan.
`subscribe(MEMPOOL_CHANNEL, None)` streams every pending transaction our
Hyperliquid node receives; with a symbol, only the actions that reference
that market. `mempool` is
the only channel whose symbol is optional.
- `ServerMsg::Mempool`, with one `MempoolItem` (and its `MempoolSignature`)
per signed action. `ServerMsg::from_text()`, which the client uses to read
every message, builds it straight from the frame text, so
`MempoolItem::action` (a `Box<serde_json::value::RawValue>`) keeps the
exact bytes the server sent, key order included, for signature recovery.
`MempoolItem::action_value()` parses the action into a `serde_json::Value`
for inspection, which may not keep the original key order. `ServerMsg::mempool_items()` returns
a message's items. Unknown item fields are ignored.
- serde_json's `raw_value` feature is enabled.
- `ws::STREAM_WS_URL` (`wss://stream.0xarchive.io/ws`) and
`ws::DEFAULT_WS_URL` (`wss://api.0xarchive.io/ws`, the default `ws_url`).
Pass `STREAM_WS_URL` to `WsOptions::ws_url()` for a connection that
subscribes to `mempool`.
- `Capability::ws_endpoint` and `Capability::plans`, the fields
`/v1/capabilities` sets on the `mempool` row only. For `ws_endpoint`,
`None` means the default endpoint, `wss://api.0xarchive.io/ws`. For
`plans`, `None` means every plan. They are left out when serializing a row that
does not have them.

### Changed
- `ServerMsg` gained the `Mempool` variant; an exhaustive match on it needs
a new arm.

## [1.12.0] - 2026-10-05

### Upgrading from 1.8
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "oxarchive"
version = "1.12.0"
version = "1.13.0"
edition = "2021"
rust-version = "1.85"
description = "Rust SDK for async 0xArchive market data clients"
Expand Down Expand Up @@ -63,7 +63,7 @@ include = [
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_json = { version = "1", features = ["raw_value"] }
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["rt", "macros"] }
Expand Down
103 changes: 98 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ Or add directly to your `Cargo.toml`:

```toml
[dependencies]
oxarchive = "1.12"
oxarchive = "1.13"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
chrono = "0.4" # the examples below use it for time windows
```

For WebSocket support (real-time streaming and replay):

```toml
oxarchive = { version = "1.12", features = ["websocket"] }
oxarchive = { version = "1.13", features = ["websocket"] }
```

## Quick Start
Expand Down Expand Up @@ -196,8 +196,10 @@ println!("{} symbols, {} on HIP-3", symbols.len(), hip3.len());
one `Capability` row per venue and datatype: its REST routes, its WebSocket
channels, whether those stream live (`live`) and replay history (`replay`),
the first instant served (`available_from`), the cadence, the largest page
(`page_limit`), and the accepted `interval` values. The route is public and
uses no credits. Read replay availability from it rather than from a fixed
(`page_limit`), and the accepted `interval` values. The `mempool` row is the one
that sets `ws_endpoint` (the endpoint that serves it) and `plans` (the plans that
include it); rows without them use the default endpoint, `wss://api.0xarchive.io/ws`,
and are on every plan. The route is public and uses no credits. Read replay availability from it rather than from a fixed
list:

```rust
Expand Down Expand Up @@ -1850,6 +1852,8 @@ For large historical downloads, use the S3 Parquet bulk export in the [Data Cata

> Lighter live subscriptions are available for `lighter_orderbook`, `lighter_trades`, `lighter_open_interest`, and `lighter_funding` on mainnet, and for `rh_lighter_orderbook`, `rh_lighter_trades`, `rh_lighter_open_interest`, and `rh_lighter_funding` on Robinhood Chain. `lighter_candles`, `lighter_l3_orderbook`, and `rh_lighter_candles` remain replay-only. Every Lighter channel on both deployments supports historical replay.

> The `mempool` channel (pending Hyperliquid transactions) is served only at `wss://stream.0xarchive.io/ws` and is included with the Pro, Scale and Enterprise plans. See [Pending Transactions (Mempool)](#pending-transactions-mempool).

The client connects with `version=2026-10-01`. Under that version, error messages carry an `error_code` (see [Error Handling](#error-handling)), and Lighter replay rows have the same shapes as the live payloads. `client.capabilities()` lists which channels stream live and which replay.

### Real-time Streaming
Expand Down Expand Up @@ -1957,6 +1961,94 @@ If the lag persists, a notice such as `Stopped the lighter_trades stream for BTC
ends that subscription; subscribe again to resume. `lighter_orderbook` never sends an
older book in place of a newer one.

### Pending Transactions (Mempool)

The `mempool` channel streams signed Hyperliquid transactions (orders, cancels,
modifies, TWAPs, leverage changes, transfers and every other action type) as our
Hyperliquid node receives them from its peers, before they are included in a block.
It covers every Hyperliquid product: perps, HIP-3, HIP-4 and spot.

- **Live only.** Pending transactions are never stored. There is no replay, history,
REST route or export.
- **One endpoint.** It is served only at `wss://stream.0xarchive.io/ws`
(`oxarchive::ws::STREAM_WS_URL`), with the same API key and protocol. At
`wss://api.0xarchive.io/ws`, the default `ws_url`, a subscribe is answered with
`ErrorCode::EndpointUnsupported`. Use a separate connection for the channels served
at the default endpoint.
- **Plans.** It is included with the Pro, Scale and Enterprise plans; on other plans a
subscribe is answered with `ErrorCode::Forbidden`. Every other channel stays on every
plan, Free included. Each data message is metered like any other WebSocket message.

```rust
use oxarchive::ws::{OxArchiveWs, ServerMsg, WsOptions, MEMPOOL_CHANNEL, STREAM_WS_URL};

let mut ws = OxArchiveWs::new(WsOptions::new("your-api-key").ws_url(STREAM_WS_URL));
ws.connect().await?;
let mut rx = ws.rx.take().expect("receiver");

ws.subscribe(MEMPOOL_CHANNEL, Some("BTC")).await?; // actions that reference BTC
ws.subscribe(MEMPOOL_CHANNEL, Some("xyz:TSLA")).await?; // HIP-3; spot "HYPE-USDC", HIP-4 "#49720"
// ws.subscribe(MEMPOOL_CHANNEL, None).await?; // pending transactions our node receives (unfiltered)

while let Some(msg) = rx.recv().await {
match msg {
ServerMsg::Mempool { symbol, items, .. } => {
for item in items {
// item.action.get() is the action's exact bytes, for signature recovery.
let action = item.action_value()?; // parsed, for inspection only
println!("{symbol:?} {:?} {} {:?}", item.received_at, action["type"], item.symbols);
}
}
ServerMsg::Error { message, error_code } => eprintln!("{error_code:?}: {message}"),
_ => {}
}
}
```

The symbol is optional on this channel only. Without it you receive every pending
transaction our Hyperliquid node receives; with it, every action whose asset ids include that market, whole (an
order batch that touches `BTC` and `ETH` reaches both subscriptions). Symbols are
spelled as everywhere else: perps `BTC`, HIP-3 `xyz:TSLA`, spot `HYPE-USDC`
(`HYPE/USDC` is also accepted) and HIP-4 `#49720`. An unknown symbol is answered with
`ErrorCode::InvalidSymbol`. The `subscribed` acknowledgement carries the canonical
symbol, or `None` for the unfiltered stream.

The server sends one message per batch of transactions as it arrives. The client
delivers it as `ServerMsg::Mempool`, with `coin` and `symbol` set to the
subscription's symbol (`None` when unfiltered) and one `MempoolItem` per signed
action in `items`:

| Field | Description |
|-------|-------------|
| `received_at` | When our node received the transaction: an RFC 3339 UTC string with nanosecond precision. Not a block time. |
| `received_at_ms` | The same time in Unix milliseconds. |
| `symbols` | Markets the action's asset ids reference, in first-seen order without repeats. Empty for actions with no market, such as transfers, `noop`, `scheduleCancel` and validator actions. |
| `action` | The action exactly as signed, in Hyperliquid's exchange-action format: asset ids (`a` or `asset`) rather than symbols, prices and sizes as strings. It is a `Box<RawValue>` that keeps the exact bytes the server sent, key order included, for signature recovery (`item.action.get()`). `item.action_value()` parses it into a `serde_json::Value` for inspection, which may not keep the original key order. |
| `nonce` | The action's nonce. |
| `vault_address` | The vault or subaccount the action acts for, or `None`. |
| `expires_after_ms` | The action's `expiresAfter` in Unix milliseconds, or `None`. |
| `signature` | `MempoolSignature` with `r`, `s` and `v`. The signer's address is not included. |

The action's `type` names it, for example `order`, `cancel`, `cancelByCloid`,
`modify`, `batchModify`, `scheduleCancel`, `twapOrder`, `twapCancel`,
`updateLeverage`, `updateIsolatedMargin`, `noop`, `evmRawTx`, or a transfer such as
`usdSend`, `spotSend`, `usdClassTransfer` or `sendAsset`. Hyperliquid adds action
types, so handle types you do not recognise.

A pending transaction is not an executed one: it can still be rejected, expire or
never land in a block. The same signed action can occasionally arrive twice;
deduplicate on `signature` if that matters to you.

**Volume and limits.** The unfiltered stream is several megabytes per second before
compression, and this client does not negotiate permessage-deflate compression, so
subscribe with a symbol where you can. Unfiltered subscriptions are limited
server-wide, and when they are at capacity a subscribe without a symbol is answered
with `ErrorCode::RateLimited`; symbol subscriptions are not capped this way. A
connection that reads too slowly is disconnected, as on any channel, and the usual
per-connection limits apply (subscriptions per plan, 10 subscribe operations per
second). If the feed is temporarily unavailable, a subscribe is answered with
`ErrorCode::UpstreamUnavailable`.

### Historical Replay

Replay a bounded historical window with original timing preserved. Every replay
Expand Down Expand Up @@ -2037,6 +2129,7 @@ ws.replay_stop().await?;
| `funding` | Funding rate snapshots | Yes | Yes |
| `ticker` | Price and 24h volume | Yes | No |
| `all_tickers` | All market tickers | Yes | No |
| `mempool` | Pending transactions on every Hyperliquid product, before they are in a block. Symbol optional. Pro, Scale and Enterprise plans; served only at `wss://stream.0xarchive.io/ws`. | Yes | No |
| `orderbook_full` | Hyperliquid core full-depth L2 order book, aggregated from order-level data (every price level, no user attribution): an `l4_snapshot` frame (`ServerMsg::L4Snapshot`) with the whole book, then `l4_batch` frames (`ServerMsg::L4Batch`) of price-level changes (`side`, `px`, `sz`, `n`, `bn`; `sz` and `n` are `0` when a level is removed). | Yes | Yes, bulk and single-channel, from 2026-03-11 01:03 UTC |
| `lighter_orderbook` | Lighter L2 order book | Yes | Yes |
| `lighter_trades` | Lighter trades | Yes | Yes |
Expand Down Expand Up @@ -2095,7 +2188,7 @@ while let Some(msg) = rx.recv().await {

### Tier Limits

All self-serve tiers reach the published route families; Free covers the most recent rolling 30 days of history (30-day span per request or replay), and Build and above keep the retained archive. Schema availability remains family-specific; plans gate capacity and Free's 30-day history window, not route families, schemas, or served depth.
All self-serve tiers reach the published route families; Free covers the most recent rolling 30 days of history (30-day span per request or replay), and Build and above keep the retained archive. Schema availability remains family-specific; plans gate capacity and Free's 30-day history window, not route families, schemas, or served depth. The one exception is the live `mempool` channel, included with the Pro, Scale and Enterprise plans.

| Tier | Max Subscriptions | Max Connections | Max Replay Speed |
|------|------------------|-----------------|------------------|
Expand Down
4 changes: 3 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ pub enum ErrorCode {
NotFound,
/// Missing or invalid credentials.
Unauthorized,
/// The credentials do not grant access to this resource.
/// The credentials do not grant access to this resource, or the plan does
/// not include it (the `mempool` WebSocket channel on plans other than
/// Pro, Scale and Enterprise).
Forbidden,
/// The monthly credits are spent.
InsufficientCredits,
Expand Down
9 changes: 7 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
//! replay:
//!
//! ```toml
//! oxarchive = { version = "1.12", features = ["websocket"] }
//! oxarchive = { version = "1.13", features = ["websocket"] }
//! ```
//!
//! Live subscriptions cover the supported Hyperliquid channels and four
Expand All @@ -167,6 +167,11 @@
//! replay as bulk, single-channel streams. `client.capabilities()` lists
//! which channels stream live and which replay.
//!
//! The live-only `mempool` channel carries pending Hyperliquid transactions,
//! before they are in a block; its items decode into [`MempoolItem`]. It is
//! served only at `wss://stream.0xarchive.io/ws` (`ws::STREAM_WS_URL`) and
//! included with the Pro, Scale and Enterprise plans.
//!
//! Bulk streaming over WebSocket has been discontinued, so
//! `OxArchiveWs::stream` is deprecated. For large historical downloads, use
//! the S3 Parquet bulk export at <https://www.0xarchive.io/data>.
Expand Down Expand Up @@ -197,7 +202,7 @@ pub use types::{
LiquidationLevelBucket, LiquidationLevels, LiquidationLevelsHistoryItem,
TriggerLevelBucket, TriggerLevels, TriggerLevelsHistoryItem,
LighterLiveAssetCtx, LighterLiveData, LighterLiveLevel, LighterLiveMarketStats,
LighterLiveOrderBook, LighterLiveTrade,
LighterLiveOrderBook, LighterLiveTrade, MempoolItem, MempoolSignature,
AccountSummary, CumulativeFunding, LighterL1Account, LighterL1Accounts, LighterLiquidation,
LighterLiquidationVolume, MarketPosition, MarketPositionsSummary, MetaResponse, Position,
PositionChange, PositionLeverage, PositionsFreshness, ResponseMeta, WalletPositions,
Expand Down
96 changes: 96 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,93 @@ impl LighterLiveData {
}
}

// ---------------------------------------------------------------------------
// Mempool (pending Hyperliquid transactions, live only)
// ---------------------------------------------------------------------------

/// One signed action from a live `mempool` message.
///
/// The `mempool` channel streams signed Hyperliquid transactions as our
/// Hyperliquid node receives them from its peers, before they are included in
/// a block, for every Hyperliquid product (perps, HIP-3, HIP-4 and spot). It is
/// live only, served only at `wss://stream.0xarchive.io/ws`, and included
/// with the Pro, Scale and Enterprise plans.
///
/// A pending transaction is not an executed one: it can still be rejected,
/// expire or never land in a block. The same signed action can occasionally
/// arrive twice; deduplicate on [`signature`](Self::signature) if that matters
/// to you.
///
/// With the `websocket` feature, `OxArchiveWs` delivers each message's items
/// in `ServerMsg::Mempool`. Elsewhere, deserialize them with serde_json from
/// the message text, not from a `serde_json::Value`, which has already sorted
/// the action's keys. Fields this version does not know are ignored, and new
/// ones may be added in minor releases, so the struct cannot be built with a
/// literal outside this crate.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MempoolItem {
/// When our node received the transaction, as an RFC 3339 UTC string with
/// the node's nanosecond precision, for example
/// `2026-10-08T01:57:23.548737209Z`. Not a block time.
pub received_at: Option<String>,
/// When our node received the transaction, in Unix milliseconds.
pub received_at_ms: Option<i64>,
/// The markets the action's asset ids reference, spelled as everywhere
/// else (`BTC`, `xyz:TSLA`, `HYPE-USDC`, `#49720`), in first-seen order
/// without repeats. Empty for actions with no market, such as transfers,
/// `noop`, `scheduleCancel` and validator actions.
#[serde(default)]
pub symbols: Vec<String>,
/// The action exactly as signed, in Hyperliquid's exchange-action format:
/// asset ids (`a` or `asset`) rather than symbols, and prices and sizes as
/// strings. Its `type` names it, for example `order`, `cancel`,
/// `cancelByCloid`, `modify`, `batchModify`, `scheduleCancel`,
/// `twapOrder`, `twapCancel`, `updateLeverage`, `updateIsolatedMargin`,
/// `noop`, `evmRawTx`, or a transfer such as `usdSend`, `spotSend`,
/// `usdClassTransfer` or `sendAsset`. Hyperliquid adds action types, so
/// handle types you do not recognise.
///
/// This raw form keeps the exact bytes the server sent, key order
/// included, as signature recovery needs them; `action.get()` returns
/// them as a `&str`. To inspect the action,
/// [`action_value`](Self::action_value) parses it into a
/// `serde_json::Value`, which may not keep the original key order.
pub action: Box<serde_json::value::RawValue>,
/// The action's nonce.
pub nonce: Option<u64>,
/// The vault or subaccount the action acts for, or `None`.
pub vault_address: Option<String>,
/// The action's `expiresAfter` in Unix milliseconds, or `None`.
pub expires_after_ms: Option<i64>,
/// The signature over the action. The signer's address is not included;
/// it can be recovered from the signature and the action.
pub signature: Option<MempoolSignature>,
}

/// The `{r, s, v}` signature of a pending action, as signed.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MempoolSignature {
/// `r` as a hex string.
pub r: String,
/// `s` as a hex string.
pub s: String,
/// Recovery id.
pub v: u64,
}

impl MempoolItem {
/// The action parsed into a `serde_json::Value`, for inspection, for
/// example `item.action_value()?["type"]`.
///
/// The parsed form may not keep the original key order. Use the raw
/// [`action`](Self::action) for signature recovery.
pub fn action_value(&self) -> crate::Result<serde_json::Value> {
serde_json::from_str(self.action.get())
.map_err(|e| crate::Error::Deserialize(e.to_string()))
}
}

// ---------------------------------------------------------------------------
// Convenience / summary types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2366,6 +2453,15 @@ pub struct Capability {
/// Further detail, such as how replay behaves.
#[serde(default)]
pub notes: Option<String>,
/// The WebSocket endpoint that serves this row's channels, when it is not
/// the default endpoint: `wss://stream.0xarchive.io/ws` for `mempool`.
/// `None` means the default endpoint, `wss://api.0xarchive.io/ws`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ws_endpoint: Option<String>,
/// The plans that include it, when that is not every plan: `pro`, `scale`
/// and `enterprise` for `mempool`. `None` means every plan, Free included.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plans: Option<Vec<String>>,
}

impl Capability {
Expand Down
Loading
Loading