hyperliquid 1.9.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +26 -0
- data/docs/API.md +374 -152
- data/docs/CONFIGURATION.md +25 -11
- data/docs/DEVELOPMENT.md +93 -15
- data/docs/ERRORS.md +2 -0
- data/docs/EXAMPLES.md +253 -2
- data/docs/WS.md +78 -20
- data/lib/hyperliquid/client.rb +19 -10
- data/lib/hyperliquid/exchange.rb +844 -22
- data/lib/hyperliquid/info.rb +23 -11
- data/lib/hyperliquid/signing/signer.rb +18 -1
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid/ws/client.rb +192 -78
- data/lib/hyperliquid.rb +6 -0
- metadata +16 -34
- data/.rspec +0 -3
- data/.rubocop.yml +0 -49
- data/.ruby-version +0 -1
- data/CLAUDE.md +0 -108
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -12
- data/example.rb +0 -161
- data/scripts/test_01_spot_market_roundtrip.rb +0 -48
- data/scripts/test_02_spot_limit_order.rb +0 -48
- data/scripts/test_03_perp_market_roundtrip.rb +0 -58
- data/scripts/test_04_perp_limit_order.rb +0 -52
- data/scripts/test_05_update_leverage.rb +0 -50
- data/scripts/test_06_modify_order.rb +0 -67
- data/scripts/test_07_market_close.rb +0 -55
- data/scripts/test_08_usd_class_transfer.rb +0 -36
- data/scripts/test_09_sub_account_lifecycle.rb +0 -51
- data/scripts/test_10_vault.rb +0 -68
- data/scripts/test_11_builder_fee.rb +0 -81
- data/scripts/test_12_staking.rb +0 -70
- data/scripts/test_13_ws_l2_book.rb +0 -83
- data/scripts/test_14_ws_candle.rb +0 -81
- data/scripts/test_15_explorer.rb +0 -71
- data/scripts/test_16_send_to_evm_with_data.rb +0 -79
- data/scripts/test_17_create_vault.rb +0 -73
- data/scripts/test_18_user_portfolio_margin.rb +0 -72
- data/scripts/test_19_spot_user.rb +0 -55
- data/scripts/test_20_explorer_ws.rb +0 -92
- data/scripts/test_all.rb +0 -96
- data/scripts/test_automated.rb +0 -82
- data/scripts/test_helpers.rb +0 -238
- data/sig/hyperliquid.rbs +0 -4
- data/test_integration.rb +0 -17
data/docs/WS.md
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
# WebSocket Implementation
|
|
2
2
|
|
|
3
|
+
`Hyperliquid::WS::Client` (`sdk.ws`) manages two independent WebSocket connections:
|
|
4
|
+
|
|
5
|
+
| Connection | URL (mainnet / testnet) | Used by | Message shape |
|
|
6
|
+
|------------|-------------------------|---------|---------------|
|
|
7
|
+
| Main API | `wss://api.hyperliquid.xyz/ws` / `wss://api.hyperliquid-testnet.xyz/ws` | `subscribe` | `{ "channel": ..., "data": ... }` envelope |
|
|
8
|
+
| Explorer | `wss://rpc.hyperliquid.xyz/ws` / `wss://rpc.hyperliquid-testnet.xyz/ws` | `subscribe_explorer_block`, `subscribe_explorer_txs` | bare JSON array, no envelope |
|
|
9
|
+
|
|
10
|
+
Each connection has its own read, dispatch and ping threads, bounded queue and subscription table. Subscription ids come from one counter shared by both connections, so an id returned by `subscribe` or `subscribe_explorer_*` identifies exactly one subscription for `unsubscribe`. A message from one connection never reaches a callback registered on the other. Each connects on its first subscription; `connect` opens only the main connection; `close` closes both.
|
|
11
|
+
|
|
3
12
|
## Architecture
|
|
4
13
|
|
|
5
|
-
|
|
14
|
+
Per connection:
|
|
6
15
|
|
|
7
16
|
```
|
|
8
17
|
WS Read Thread ──> Bounded Queue (1024) ──> Dispatch Thread ──> User Callbacks
|
|
@@ -10,40 +19,89 @@ Ping Thread (every 50s)
|
|
|
10
19
|
```
|
|
11
20
|
|
|
12
21
|
- **Read thread** (`ws_lite`): receives frames, parses JSON, pushes onto the queue. Never blocks on user code.
|
|
13
|
-
- **Dispatch thread
|
|
14
|
-
- **Ping thread
|
|
22
|
+
- **Dispatch thread**: pops messages and calls the matching callbacks in order. A slow callback only blocks this thread. An exception raised by a callback is caught and printed as a warning; dispatch continues.
|
|
23
|
+
- **Ping thread**: sends `{"method":"ping"}` every 50 seconds to keep the connection alive.
|
|
15
24
|
|
|
16
25
|
## Message Flow
|
|
17
26
|
|
|
18
|
-
1.
|
|
19
|
-
2. Non-JSON
|
|
20
|
-
3.
|
|
21
|
-
4. The message is pushed onto the bounded
|
|
22
|
-
5. The dispatch thread pops the message, looks up callbacks by identifier, and calls each one.
|
|
27
|
+
1. A raw frame arrives on the read thread.
|
|
28
|
+
2. Non-JSON frames (e.g. `"Websocket connection established."`) and `pong` replies are discarded.
|
|
29
|
+
3. For the compressed `fastAssetCtxs` channel, `data` (base64 + raw DEFLATE JSON) is decoded on the read thread first. A frame that fails to decode is dropped with a `Failed to decode compressed message` warning. Then a routing identifier is computed: from the `channel` and `data` of a main-API envelope (e.g. `l2Book:eth`), or, for an explorer array, from the fields of its first element (`blockTime`, `hash`, `height`, `numTxs`, `proposer` → `explorerBlock`; `action`, `block`, `error`, `hash`, `time`, `user` → `explorerTxs`). Messages without an identifier are dropped.
|
|
30
|
+
4. The message is pushed onto the connection's bounded queue. If the queue is full, the message is dropped and a warning is printed.
|
|
31
|
+
5. The dispatch thread pops the message, looks up callbacks by identifier, and calls each one with the message's `data` (main API) or the whole array (explorer).
|
|
23
32
|
|
|
24
33
|
## Subscription Routing
|
|
25
34
|
|
|
26
|
-
Subscriptions are keyed by an identifier string derived from the
|
|
27
|
-
|
|
28
|
-
| Channel
|
|
29
|
-
|
|
30
|
-
| `allMids`
|
|
31
|
-
| `l2Book`
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
35
|
+
Subscriptions are keyed by an identifier string derived from the subscription type and its routing fields (the `ROUTING_KEYS` table in `ws/client.rb`: identifier = `[type, *field values].join(':')`). The echo channel is the `channel` the server puts on the message when it differs from the type.
|
|
36
|
+
|
|
37
|
+
| Channel | Identifier format | Example |
|
|
38
|
+
|---------|-------------------|---------|
|
|
39
|
+
| `allMids` | `allMids` | `allMids` |
|
|
40
|
+
| `l2Book` | `l2Book:<coin>` | `l2Book:eth` |
|
|
41
|
+
| `trades` | `trades:<coin>` | `trades:eth` |
|
|
42
|
+
| `bbo` | `bbo:<coin>` | `bbo:eth` |
|
|
43
|
+
| `candle` | `candle:<coin>:<interval>` | `candle:eth:1h` |
|
|
44
|
+
| `orderUpdates` | `orderUpdates` (exclusive: `user`) | `orderUpdates` |
|
|
45
|
+
| `userEvents` | `userEvents` (echo channel `user`; exclusive: `user`) | `userEvents` |
|
|
46
|
+
| `userFills` | `userFills:<user>` (exclusive: `aggregateByTime`) | `userFills:0xabc` |
|
|
47
|
+
| `userFundings` | `userFundings:<user>` | `userFundings:0xabc` |
|
|
48
|
+
| `userNonFundingLedgerUpdates` | `userNonFundingLedgerUpdates:<user>` | `userNonFundingLedgerUpdates:0xabc` |
|
|
49
|
+
| `userTwapSliceFills` | `userTwapSliceFills:<user>` | `userTwapSliceFills:0xabc` |
|
|
50
|
+
| `userTwapHistory` | `userTwapHistory:<user>` | `userTwapHistory:0xabc` |
|
|
51
|
+
| `userHistoricalOrders` | `userHistoricalOrders:<user>` | `userHistoricalOrders:0xabc` |
|
|
52
|
+
| `allDexsClearinghouseState` | `allDexsClearinghouseState:<user>` | `allDexsClearinghouseState:0xabc` |
|
|
53
|
+
| `webData3` | `webData3:<user>` (message side: `data.userState.user`) | `webData3:0xabc` |
|
|
54
|
+
| `clearinghouseState` | `clearinghouseState:<user>:<dex>` (`dex` `""` for the main dex) | `clearinghouseState:0xabc:xyz` |
|
|
55
|
+
| `openOrders` | `openOrders:<user>:<dex>` | `openOrders:0xabc:` |
|
|
56
|
+
| `twapStates` | `twapStates:<user>:<dex>` | `twapStates:0xabc:xyz` |
|
|
57
|
+
| `spotState` | `spotState:<user>` (exclusive: `ignorePortfolioMargin`) | `spotState:0xabc` |
|
|
58
|
+
| `notification` | `notification` (exclusive: `user`) | `notification` |
|
|
59
|
+
| `activeAssetCtx` | `activeAssetCtx:<coin>` (spot coins echo as channel `activeSpotAssetCtx`) | `activeAssetCtx:xyz:xyz100` |
|
|
60
|
+
| `activeAssetData` | `activeAssetData:<user>:<coin>` | `activeAssetData:0xabc:btc` |
|
|
61
|
+
| `assetCtxs` | `assetCtxs:<dex>` (`dex` `""` for the main dex) | `assetCtxs:xyz` |
|
|
62
|
+
| `allDexsAssetCtxs` | `allDexsAssetCtxs` | `allDexsAssetCtxs` |
|
|
63
|
+
| `spotAssetCtxs` | `spotAssetCtxs` (data is an Array) | `spotAssetCtxs` |
|
|
64
|
+
| `outcomeMetaUpdates` | `outcomeMetaUpdates` | `outcomeMetaUpdates` |
|
|
65
|
+
| `fastAssetCtxs` | `fastAssetCtxs` (the one channel whose `data` is decoded before routing) | `fastAssetCtxs` |
|
|
66
|
+
| `explorerBlock` | `explorerBlock` | `explorerBlock` |
|
|
67
|
+
| `explorerTxs` | `explorerTxs` | `explorerTxs` |
|
|
68
|
+
|
|
69
|
+
Coins and users are lowercased; intervals are kept verbatim (`1m` and `1M` differ). The user comes before coin/dex in multi-field identifiers because HIP-3 coins contain a colon (`xyz:XYZ100`). An omitted `dex` and `dex: ""` are the same subscription (the server canonicalizes an omitted dex to `""`), so both get the identifier ending in `:`. Server echo channels that differ from the subscription type are mapped back before routing: `user` → `userEvents`, `activeSpotAssetCtx` → `activeAssetCtx`. `subscribe` raises `Hyperliquid::WebSocketError` for a channel not in this table (including `activeSpotAssetCtx`, which is only an echo channel); `subscribe_explorer_*` raise `Hyperliquid::ConfigurationError` when the client has no explorer URL.
|
|
34
70
|
|
|
35
71
|
Multiple callbacks can be registered for the same identifier. The server unsubscribe message is only sent when the last callback for an identifier is removed.
|
|
36
72
|
|
|
73
|
+
### Exclusive channels
|
|
74
|
+
|
|
75
|
+
Some payloads omit a subscription field, so two subscriptions that differ only in that field cannot be told apart: `orderUpdates`, `userEvents` and `notification` messages carry no user, and `userFills`/`spotState` messages do not echo `aggregateByTime`/`ignorePortfolioMargin`. On these channels a `WS::Client` holds one value of that field per identifier: a subscription that conflicts with an active one (a second user on `orderUpdates`/`userEvents`/`notification`, or the same user's `userFills`/`spotState` with a different flag; an omitted flag counts as `false`) raises `Hyperliquid::WebSocketError` without registering anything. Same-value duplicates are allowed. The value is free again once its last subscription is unsubscribed; to follow several users at once, use one `WS::Client` per user.
|
|
76
|
+
|
|
77
|
+
## Snapshot + delta channels
|
|
78
|
+
|
|
79
|
+
`fastAssetCtxs` sends a full snapshot (every coin's `markPx`/`midPx`) as its first frame; later frames (about one per second) contain only the coins and fields that changed. Keep a cache and merge each frame into it:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
cache = {}
|
|
83
|
+
sdk.ws.subscribe({ type: 'fastAssetCtxs' }) do |ctxs|
|
|
84
|
+
cache.merge!(ctxs) { |_coin, old, new| old.merge(new) }
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- A callback added while the subscription is already active gets only deltas: the server sends no second snapshot for a duplicate subscribe.
|
|
89
|
+
- A dropped frame (watch `dropped_message_count`, or the decode warning) leaves the cache stale until the next snapshot. A snapshot arrives after a reconnect, or after unsubscribing every callback and subscribing again.
|
|
90
|
+
|
|
37
91
|
## Queue Overflow
|
|
38
92
|
|
|
39
|
-
|
|
93
|
+
Each connection's queue is bounded (`max_queue_size:`, default 1024 messages). When full, new messages are dropped (queued ones are kept). Warnings print on the 1st drop and every 100th drop. Monitor via `dropped_message_count` (main) and `explorer_dropped_message_count` (explorer).
|
|
40
94
|
|
|
41
95
|
## Reconnection
|
|
42
96
|
|
|
43
|
-
On unexpected disconnect (when `reconnect: true`, the default),
|
|
97
|
+
On unexpected disconnect (when `reconnect: true`, the default), each connection reconnects on its own thread with exponential backoff: 1s, 2s, 4s, ..., capped at 30s. On reconnect, that connection's active subscriptions are replayed automatically.
|
|
98
|
+
|
|
99
|
+
## Lifecycle Hooks
|
|
100
|
+
|
|
101
|
+
`on(:open)`, `on(:close)` and `on(:error)` fire for the main API connection only. The explorer connection has no hooks; its errors are printed as warnings. Each event holds one callback; registering another replaces it. `on(:open)` also fires after every successful reconnect.
|
|
44
102
|
|
|
45
103
|
## Thread Safety
|
|
46
104
|
|
|
47
|
-
-
|
|
105
|
+
- Subscription tables, pending-subscription lists and drop counters are protected by one `Mutex` shared by both connections' threads.
|
|
48
106
|
- Ruby's `Queue` is inherently thread-safe.
|
|
49
|
-
- Callbacks are invoked serially on
|
|
107
|
+
- Callbacks for one connection are invoked serially on its dispatch thread. Main-API and explorer callbacks run on different threads and can run concurrently.
|
data/lib/hyperliquid/client.rb
CHANGED
|
@@ -7,7 +7,15 @@ require 'json'
|
|
|
7
7
|
module Hyperliquid
|
|
8
8
|
# HTTP client for making requests to Hyperliquid API
|
|
9
9
|
class Client
|
|
10
|
-
#
|
|
10
|
+
# Paths whose POSTs are read-only queries and therefore safe to replay. Everything else,
|
|
11
|
+
# notably Constants::EXCHANGE_ENDPOINT (a replayed signed action can execute twice), is
|
|
12
|
+
# never retried.
|
|
13
|
+
RETRYABLE_PATHS = [Constants::INFO_ENDPOINT, Constants::EXPLORER_ENDPOINT].freeze
|
|
14
|
+
|
|
15
|
+
# Retry configuration applied when retry_enabled is true. Every SDK request is a POST,
|
|
16
|
+
# which faraday-retry treats as non-idempotent, so `methods: []` disables its method
|
|
17
|
+
# allowlist and `retry_if` decides per request path instead. Faraday::RetriableResponse
|
|
18
|
+
# must be listed for retry_statuses to trigger a retry.
|
|
11
19
|
DEFAULT_RETRY_OPTIONS = {
|
|
12
20
|
max: 2,
|
|
13
21
|
interval: 0.5,
|
|
@@ -16,14 +24,19 @@ module Hyperliquid
|
|
|
16
24
|
retry_statuses: [429, 502, 503, 504],
|
|
17
25
|
exceptions: [
|
|
18
26
|
Faraday::ConnectionFailed,
|
|
19
|
-
Faraday::TimeoutError
|
|
20
|
-
|
|
27
|
+
Faraday::TimeoutError,
|
|
28
|
+
Faraday::RetriableResponse
|
|
29
|
+
],
|
|
30
|
+
methods: [],
|
|
31
|
+
retry_if: ->(env, _exception) { RETRYABLE_PATHS.include?(env.url.path) }
|
|
21
32
|
}.freeze
|
|
22
33
|
|
|
23
34
|
# Initialize a new HTTP client
|
|
24
35
|
# @param base_url [String] The base URL for the default API (info/exchange)
|
|
25
|
-
# @param timeout [
|
|
26
|
-
#
|
|
36
|
+
# @param timeout [Numeric] Per-phase (connect, write, read) timeout in seconds
|
|
37
|
+
# (default: Constants::DEFAULT_TIMEOUT)
|
|
38
|
+
# @param retry_enabled [Boolean] Retry /info and /explorer reads on transient failures
|
|
39
|
+
# (default: false); /exchange is never retried. See DEFAULT_RETRY_OPTIONS.
|
|
27
40
|
# @param explorer_base_url [String, nil] Optional base URL for the explorer RPC (used by
|
|
28
41
|
# tx_details / user_details). When nil, calls with target: :explorer raise ConfigurationError.
|
|
29
42
|
def initialize(base_url:, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false,
|
|
@@ -57,10 +70,6 @@ module Hyperliquid
|
|
|
57
70
|
end
|
|
58
71
|
|
|
59
72
|
handle_response(response)
|
|
60
|
-
rescue Faraday::RetriableResponse => e
|
|
61
|
-
# After retries are exhausted, Faraday throws a RetriableResponse
|
|
62
|
-
# Catch and handle that here to bubble up the actual network error
|
|
63
|
-
handle_response(e.response)
|
|
64
73
|
rescue Faraday::ConnectionFailed => e
|
|
65
74
|
raise NetworkError, "Connection failed: #{e.message}"
|
|
66
75
|
rescue Faraday::TimeoutError => e
|
|
@@ -86,8 +95,8 @@ module Hyperliquid
|
|
|
86
95
|
|
|
87
96
|
def build_connection(base_url)
|
|
88
97
|
Faraday.new(url: base_url) do |conn|
|
|
98
|
+
# One value for open/read/write: a separate read_timeout would override it for reads.
|
|
89
99
|
conn.options.timeout = @timeout
|
|
90
|
-
conn.options.read_timeout = Constants::DEFAULT_READ_TIMEOUT
|
|
91
100
|
conn.request :retry, DEFAULT_RETRY_OPTIONS if @retry_enabled
|
|
92
101
|
end
|
|
93
102
|
end
|