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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +26 -0
  4. data/docs/API.md +374 -152
  5. data/docs/CONFIGURATION.md +25 -11
  6. data/docs/DEVELOPMENT.md +93 -15
  7. data/docs/ERRORS.md +2 -0
  8. data/docs/EXAMPLES.md +253 -2
  9. data/docs/WS.md +78 -20
  10. data/lib/hyperliquid/client.rb +19 -10
  11. data/lib/hyperliquid/exchange.rb +844 -22
  12. data/lib/hyperliquid/info.rb +23 -11
  13. data/lib/hyperliquid/signing/signer.rb +18 -1
  14. data/lib/hyperliquid/version.rb +1 -1
  15. data/lib/hyperliquid/ws/client.rb +192 -78
  16. data/lib/hyperliquid.rb +6 -0
  17. metadata +16 -34
  18. data/.rspec +0 -3
  19. data/.rubocop.yml +0 -49
  20. data/.ruby-version +0 -1
  21. data/CLAUDE.md +0 -108
  22. data/CODE_OF_CONDUCT.md +0 -132
  23. data/Rakefile +0 -12
  24. data/example.rb +0 -161
  25. data/scripts/test_01_spot_market_roundtrip.rb +0 -48
  26. data/scripts/test_02_spot_limit_order.rb +0 -48
  27. data/scripts/test_03_perp_market_roundtrip.rb +0 -58
  28. data/scripts/test_04_perp_limit_order.rb +0 -52
  29. data/scripts/test_05_update_leverage.rb +0 -50
  30. data/scripts/test_06_modify_order.rb +0 -67
  31. data/scripts/test_07_market_close.rb +0 -55
  32. data/scripts/test_08_usd_class_transfer.rb +0 -36
  33. data/scripts/test_09_sub_account_lifecycle.rb +0 -51
  34. data/scripts/test_10_vault.rb +0 -68
  35. data/scripts/test_11_builder_fee.rb +0 -81
  36. data/scripts/test_12_staking.rb +0 -70
  37. data/scripts/test_13_ws_l2_book.rb +0 -83
  38. data/scripts/test_14_ws_candle.rb +0 -81
  39. data/scripts/test_15_explorer.rb +0 -71
  40. data/scripts/test_16_send_to_evm_with_data.rb +0 -79
  41. data/scripts/test_17_create_vault.rb +0 -73
  42. data/scripts/test_18_user_portfolio_margin.rb +0 -72
  43. data/scripts/test_19_spot_user.rb +0 -55
  44. data/scripts/test_20_explorer_ws.rb +0 -92
  45. data/scripts/test_all.rb +0 -96
  46. data/scripts/test_automated.rb +0 -82
  47. data/scripts/test_helpers.rb +0 -238
  48. data/sig/hyperliquid.rbs +0 -4
  49. 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
- `Hyperliquid::WS::Client` is a managed WebSocket client backed by three background threads:
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** (`hl-ws-dispatch`): pops messages from the queue and invokes matching callbacks in order. If a callback is slow, only this thread blocks.
14
- - **Ping thread** (`hl-ws-ping`): sends `{"method":"ping"}` every 50 seconds to keep the connection alive.
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. Raw frame arrives on the read thread.
19
- 2. Non-JSON messages (e.g. `"Websocket connection established."`) and `pong` responses are discarded.
20
- 3. A channel identifier is computed from the message (e.g. `l2Book:eth`).
21
- 4. The message is pushed onto the bounded `Queue`. If the queue is full, the message is dropped and a warning is emitted.
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 channel type and its parameters:
27
-
28
- | Channel | Identifier format | Example |
29
- |----------------|------------------------------------|---------------------|
30
- | `allMids` | `allMids` | `allMids` |
31
- | `l2Book` | `l2Book:<coin>` | `l2Book:eth` |
32
- | `candle` | `candle:<coin>:<interval>` | `candle:eth:1h` |
33
- | `userEvents` | `userEvents:<user>` | `userEvents:0xabc` |
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
- The internal queue is bounded (default 1024 messages). When full, new messages are dropped (oldest retained). Warnings print on the 1st drop and every 100th drop. Monitor via `dropped_message_count`.
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), the client spawns a thread that retries with exponential backoff: 1s, 2s, 4s, ..., capped at 30s. On reconnect, all active subscriptions are replayed automatically.
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
- - `@subscriptions` and `@pending_subscriptions` are protected by a `Mutex` as they are accessed across the three threads.
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 the dispatch thread (never concurrently).
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.
@@ -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
- # Default retry configuration for API requests
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 [Integer] Request timeout in seconds (default: Constants::DEFAULT_TIMEOUT)
26
- # @param retry_enabled [Boolean] Whether to enable retry logic (default: false)
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