hyperliquid 1.7.0 → 1.8.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/.rubocop.yml +6 -0
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +9 -7
- data/lib/hyperliquid/constants.rb +4 -0
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid/ws/client.rb +300 -8
- data/lib/hyperliquid.rb +2 -1
- data/scripts/test_20_explorer_ws.rb +92 -0
- data/scripts/test_all.rb +2 -1
- data/scripts/test_automated.rb +2 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14a1a12bfa26b33260f7f2b0eacd8aeeb6762fc5d7d678f44cd63d6b492dcdd2
|
|
4
|
+
data.tar.gz: b0d4516d805842818e5d71b904a1f707014446802ad8efe60a7b6a6ed6a27853
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e0419a6fc7501f2c3a3b4085d35fc495a88840a295a2017a60934f5e906f51b98bdd0be178d2474eb9be0414a6b435fed19e258235e30043ca1808af8e7144a
|
|
7
|
+
data.tar.gz: 3de78e11757c22db4a7d59880fd14e0d28a3e31721054bb22d5368adf48a0e1fa2a9ca7c67fa75ffce2d88115a9973fa5fdc92f285b62836f657c4518c7fe585
|
data/.rubocop.yml
CHANGED
|
@@ -41,3 +41,9 @@ Metrics/CyclomaticComplexity:
|
|
|
41
41
|
Metrics/PerceivedComplexity:
|
|
42
42
|
Exclude:
|
|
43
43
|
- 'lib/hyperliquid/exchange.rb'
|
|
44
|
+
- 'lib/hyperliquid/ws/client.rb'
|
|
45
|
+
|
|
46
|
+
# Empty blocks are intentional in specs (no-op callbacks for subscription tests)
|
|
47
|
+
Lint/EmptyBlock:
|
|
48
|
+
Exclude:
|
|
49
|
+
- 'spec/**/*'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Ruby Hyperliquid SDK Changelog]
|
|
2
2
|
|
|
3
|
+
## [1.8.0] - 2026-06-16
|
|
4
|
+
|
|
5
|
+
### New WebSocket endpoints
|
|
6
|
+
|
|
7
|
+
- `WS::Client#subscribe_explorer_block` — subscribe to Explorer WebSocket for block notifications. Uses a separate connection, queue, dispatch thread, and ping thread from the main API WebSocket to avoid cross-contamination.
|
|
8
|
+
- `WS::Client#subscribe_explorer_txs` — subscribe to Explorer WebSocket for transaction notifications. Same dual-transport architecture; messages arrive as bare arrays duck-typed by field presence.
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
|
|
12
|
+
- Wired `test_20_explorer_ws` into the automated integration suite (was previously only in `test_all.rb`).
|
|
13
|
+
|
|
3
14
|
## [1.7.0] - 2026-06-11
|
|
4
15
|
|
|
5
16
|
### New Exchange actions
|
data/CLAUDE.md
CHANGED
|
@@ -26,12 +26,12 @@ ruby example.rb # example usage script
|
|
|
26
26
|
Integration scripts live in `scripts/` as standalone files (`test_NN_<name>.rb`). They require a real testnet private key and hit the live testnet API.
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_all.rb # all
|
|
30
|
-
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_automated.rb # CI-friendly subset
|
|
29
|
+
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_all.rb # all 20
|
|
30
|
+
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_automated.rb # CI-friendly subset (13)
|
|
31
31
|
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_08_usd_class_transfer.rb # single
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`test_automated.rb` is the unattended runner — same as `test_all.rb` but excludes scripts that require manual testnet preconditions (e.g. `test_09_sub_account_lifecycle` needs $100k traded volume; `test_12_staking` needs HYPE balance). Some included tests (e.g. `test_08`, `test_11`) are also coded to skip-with-warning when known testnet preconditions aren't met, so the suite stays green on a stable wallet.
|
|
34
|
+
`test_automated.rb` is the unattended runner — same as `test_all.rb` but excludes scripts that require manual testnet preconditions (e.g. `test_09_sub_account_lifecycle` needs $100k traded volume; `test_12_staking` needs HYPE balance; `test_20_explorer_ws` is new and not yet in automated). Some included tests (e.g. `test_08`, `test_11`) are also coded to skip-with-warning when known testnet preconditions aren't met, so the suite stays green on a stable wallet.
|
|
35
35
|
|
|
36
36
|
`test_integration.rb` at the project root is a thin convenience wrapper.
|
|
37
37
|
|
|
@@ -51,7 +51,9 @@ Hyperliquid.new(...)
|
|
|
51
51
|
- **Info path**: method builds `{ type: 'someType', ... }` body → `Client` POSTs to `/info` → parsed JSON returned.
|
|
52
52
|
- **Exchange path**: method builds action payload → `Signer` generates EIP-712 signature over msgpack-encoded action → `Client` POSTs signed payload to `/exchange` → parsed JSON returned.
|
|
53
53
|
- **Explorer RPC path** (`tx_details`, `user_details`): a separate base URL (`rpc.hyperliquid.xyz` / `rpc.hyperliquid-testnet.xyz`) with endpoint `/explorer`. `Client` holds a second Faraday connection for this, built lazily on first use; methods opt in via `client.post(EXPLORER_ENDPOINT, body, target: :explorer)`. The SDK wires this up automatically based on `testnet:`. Calling `target: :explorer` on a `Client` constructed without `explorer_base_url:` raises `ConfigurationError`. Don't add a public connection accessor — `target:` is the contract.
|
|
54
|
-
- **WebSocket path**: `WS::Client` manages
|
|
54
|
+
- **WebSocket path**: `WS::Client` manages two independent WebSocket connections:
|
|
55
|
+
- **Main API WS** (`wss://api.hyperliquid.xyz/ws`): subscribes to market data channels (`l2Book`, `trades`, `candle`, etc.). Messages arrive as `{channel, data}` envelopes; `compute_identifier` extracts routing keys (e.g. `l2Book:eth`, `candle:btc:1h`). Callbacks are dispatched via a bounded queue (1024, drops on overflow) on a dedicated thread.
|
|
56
|
+
- **Explorer WS** (`wss://rpc.hyperliquid.xyz/ws`): subscribes to block/transaction streams (`explorerBlock`, `explorerTxs`). Messages arrive as **bare arrays** (no envelope), so `identify_explorer_array` duck-types by field presence (`blockTime`/`height` for blocks, `action`/`hash` for txs). Uses a separate queue, dispatch thread, and subscription ID namespace to avoid cross-contamination with main-API WS. Both connections share the same 50s ping keepalive, automatic reconnection (exp backoff, 30s cap), and lifecycle hooks (`on(:open)`, `on(:close)`, `on(:error)`).
|
|
55
57
|
|
|
56
58
|
### Signing (Python SDK Parity)
|
|
57
59
|
|
|
@@ -79,13 +81,13 @@ Many Info methods accept a `dex:` kwarg (e.g. `meta(dex: 'foo')`, `user_state(us
|
|
|
79
81
|
|
|
80
82
|
### Testing
|
|
81
83
|
|
|
82
|
-
- **Unit tests** (`spec/`): RSpec + WebMock. WebMock resets between tests. Monkey-patching disabled. Test files mirror `lib/` structure. No live HTTP calls in unit tests.
|
|
83
|
-
- **Integration tests** (`scripts/`): run against testnet with a real private key. Each script is self-contained. Helpers (separators, status dumping, retry-on-oracle-bounce) live in `scripts/test_helpers.rb`.
|
|
84
|
+
- **Unit tests** (`spec/`): RSpec + WebMock. WebMock resets between tests. Monkey-patching disabled. Test files mirror `lib/` structure. No live HTTP calls in unit tests. The WS client spec (`spec/hyperliquid/ws/client_spec.rb`) includes comprehensive isolation tests verifying that explorer WS messages never route to main-API callbacks and vice versa — this is critical because the two transports share the same `WS::Client` class.
|
|
85
|
+
- **Integration tests** (`scripts/`): run against testnet with a real private key. Each script is self-contained. Helpers (separators, status dumping, retry-on-oracle-bounce) live in `scripts/test_helpers.rb`. `test_20_explorer_ws.rb` subscribes to `explorerBlock` on testnet and collects 3 block events (60s timeout) to verify the explorer WS transport works end-to-end.
|
|
84
86
|
- **`dump_status` / `check_result` helpers** in `test_helpers.rb` must guard against `result['response']` *itself* being a String for transfer-style actions (`usdClassTransfer`, `approveBuilderFee`) — not just `result['response']['data']`. This was a real bug fixed in 1.1.0; preserve the guards if refactoring those helpers.
|
|
85
87
|
|
|
86
88
|
### Code Style
|
|
87
89
|
|
|
88
|
-
RuboCop targets Ruby 3.3. Key relaxations: methods up to 50 lines, no class length limit (Info/Exchange are large by design), no block length limit in specs, no parameter list limit in Exchange. `scripts/`, `test_*.rb`, `local/`, and `vendor/` are excluded from linting.
|
|
90
|
+
RuboCop targets Ruby 3.3. Key relaxations: methods up to 50 lines, no class length limit (Info/Exchange are large by design), no block length limit in specs, no parameter list limit in Exchange, empty blocks allowed in specs (intentional no-op callbacks). `scripts/`, `test_*.rb`, `local/`, and `vendor/` are excluded from linting. The WS client's `initialize` method was refactored to extract `init_main_ws_state` and `init_explorer_ws_state` helpers to reduce ABC size (33 assignments across two transports).
|
|
89
91
|
|
|
90
92
|
Predicate methods follow Ruby style (`vip?`, `connected?`, `testnet?`) — not `is_vip` / `is_connected`. RuboCop's `Naming/PredicateName` enforces this.
|
|
91
93
|
|
|
@@ -21,6 +21,10 @@ module Hyperliquid
|
|
|
21
21
|
WS_PING_INTERVAL = 50 # seconds between pings
|
|
22
22
|
WS_MAX_QUEUE_SIZE = 1024 # max queued messages before dropping
|
|
23
23
|
|
|
24
|
+
# Explorer WebSocket URLs (used by subscribe_explorer_block / subscribe_explorer_txs)
|
|
25
|
+
MAINNET_EXPLORER_WS_URL = 'wss://rpc.hyperliquid.xyz/ws'
|
|
26
|
+
TESTNET_EXPLORER_WS_URL = 'wss://rpc.hyperliquid-testnet.xyz/ws'
|
|
27
|
+
|
|
24
28
|
# Request timeouts (seconds)
|
|
25
29
|
DEFAULT_TIMEOUT = 30
|
|
26
30
|
DEFAULT_READ_TIMEOUT = 30
|
data/lib/hyperliquid/version.rb
CHANGED
|
@@ -7,33 +7,58 @@ module Hyperliquid
|
|
|
7
7
|
module WS
|
|
8
8
|
# Managed WebSocket client for subscribing to real-time data channels
|
|
9
9
|
class Client
|
|
10
|
-
attr_reader :dropped_message_count
|
|
10
|
+
attr_reader :dropped_message_count, :explorer_dropped_message_count
|
|
11
11
|
|
|
12
|
-
def initialize(testnet: false, max_queue_size: Constants::WS_MAX_QUEUE_SIZE, reconnect: true
|
|
12
|
+
def initialize(testnet: false, max_queue_size: Constants::WS_MAX_QUEUE_SIZE, reconnect: true,
|
|
13
|
+
explorer_ws_url: nil)
|
|
13
14
|
base_url = testnet ? Constants::TESTNET_API_URL : Constants::MAINNET_API_URL
|
|
14
15
|
@url = base_url.sub(%r{^https?://}, 'wss://') + Constants::WS_ENDPOINT
|
|
15
16
|
@max_queue_size = max_queue_size
|
|
16
17
|
@reconnect_enabled = reconnect
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
|
|
20
|
+
init_main_ws_state
|
|
21
|
+
init_explorer_ws_state(explorer_ws_url)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
17
25
|
|
|
26
|
+
def init_main_ws_state
|
|
18
27
|
@subscriptions = {} # identifier => [{ id:, callback: }]
|
|
19
28
|
@subscription_msgs = {} # subscription_id => { subscription:, identifier: }
|
|
20
29
|
@next_id = 0
|
|
21
|
-
@mutex = Mutex.new
|
|
22
30
|
@queue = Queue.new
|
|
23
31
|
@dropped_message_count = 0
|
|
24
|
-
|
|
25
32
|
@ws = nil
|
|
26
33
|
@connected = false
|
|
27
34
|
@closing = false
|
|
28
35
|
@dispatch_thread = nil
|
|
29
36
|
@ping_thread = nil
|
|
30
37
|
@pending_subscriptions = []
|
|
31
|
-
|
|
32
38
|
@lifecycle_callbacks = {}
|
|
33
39
|
@reconnect_attempts = 0
|
|
34
40
|
@connection_id = 0
|
|
35
41
|
end
|
|
36
42
|
|
|
43
|
+
def init_explorer_ws_state(explorer_ws_url)
|
|
44
|
+
@explorer_ws_url = explorer_ws_url
|
|
45
|
+
@explorer_ws = nil
|
|
46
|
+
@explorer_connected = false
|
|
47
|
+
@explorer_closing = false
|
|
48
|
+
@explorer_connection_id = 0
|
|
49
|
+
@explorer_reconnect_attempts = 0
|
|
50
|
+
@explorer_subscriptions = {}
|
|
51
|
+
@explorer_subscription_msgs = {}
|
|
52
|
+
@explorer_next_id = 0
|
|
53
|
+
@explorer_queue = Queue.new
|
|
54
|
+
@explorer_dropped_message_count = 0
|
|
55
|
+
@explorer_dispatch_thread = nil
|
|
56
|
+
@explorer_ping_thread = nil
|
|
57
|
+
@explorer_pending_subscriptions = []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
public
|
|
61
|
+
|
|
37
62
|
def connect
|
|
38
63
|
@closing = false
|
|
39
64
|
@reconnect_attempts = 0
|
|
@@ -68,48 +93,88 @@ module Hyperliquid
|
|
|
68
93
|
sub_id
|
|
69
94
|
end
|
|
70
95
|
|
|
96
|
+
def subscribe_explorer_block(&)
|
|
97
|
+
raise ArgumentError, 'Block required for subscribe_explorer_block' unless block_given?
|
|
98
|
+
raise ConfigurationError, 'Explorer WebSocket URL not configured' unless @explorer_ws_url
|
|
99
|
+
|
|
100
|
+
subscribe_explorer({ type: 'explorerBlock' }, 'explorerBlock', &)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def subscribe_explorer_txs(&)
|
|
104
|
+
raise ArgumentError, 'Block required for subscribe_explorer_txs' unless block_given?
|
|
105
|
+
raise ConfigurationError, 'Explorer WebSocket URL not configured' unless @explorer_ws_url
|
|
106
|
+
|
|
107
|
+
subscribe_explorer({ type: 'explorerTxs' }, 'explorerTxs', &)
|
|
108
|
+
end
|
|
109
|
+
|
|
71
110
|
def unsubscribe(subscription_id)
|
|
72
111
|
sub_msg = nil
|
|
73
112
|
should_send = false
|
|
113
|
+
explorer = false
|
|
74
114
|
|
|
75
115
|
@mutex.synchronize do
|
|
76
116
|
sub_msg = @subscription_msgs.delete(subscription_id)
|
|
117
|
+
unless sub_msg
|
|
118
|
+
sub_msg = @explorer_subscription_msgs.delete(subscription_id)
|
|
119
|
+
explorer = true if sub_msg
|
|
120
|
+
end
|
|
77
121
|
return unless sub_msg
|
|
78
122
|
|
|
79
123
|
identifier = sub_msg[:identifier]
|
|
80
|
-
|
|
124
|
+
map = explorer ? @explorer_subscriptions : @subscriptions
|
|
125
|
+
callbacks = map[identifier]
|
|
81
126
|
return unless callbacks
|
|
82
127
|
|
|
83
128
|
callbacks.reject! { |entry| entry[:id] == subscription_id }
|
|
84
129
|
|
|
85
130
|
if callbacks.empty?
|
|
86
|
-
|
|
131
|
+
map.delete(identifier)
|
|
87
132
|
should_send = true
|
|
88
133
|
end
|
|
89
134
|
end
|
|
90
135
|
|
|
91
|
-
|
|
136
|
+
return unless should_send
|
|
137
|
+
|
|
138
|
+
if explorer
|
|
139
|
+
send_explorer_unsubscribe(sub_msg[:subscription]) if @explorer_connected
|
|
140
|
+
elsif @connected
|
|
141
|
+
send_unsubscribe(sub_msg[:subscription])
|
|
142
|
+
end
|
|
92
143
|
end
|
|
93
144
|
|
|
94
145
|
def close
|
|
95
146
|
@closing = true
|
|
96
147
|
@connected = false
|
|
148
|
+
@explorer_closing = true
|
|
149
|
+
@explorer_connected = false
|
|
97
150
|
|
|
98
151
|
@ping_thread&.kill
|
|
99
152
|
@ping_thread = nil
|
|
153
|
+
@explorer_ping_thread&.kill
|
|
154
|
+
@explorer_ping_thread = nil
|
|
100
155
|
|
|
101
156
|
@queue&.close if @queue.respond_to?(:close)
|
|
157
|
+
@explorer_queue&.close if @explorer_queue.respond_to?(:close)
|
|
158
|
+
|
|
102
159
|
@dispatch_thread&.join(5)
|
|
103
160
|
@dispatch_thread = nil
|
|
161
|
+
@explorer_dispatch_thread&.join(5)
|
|
162
|
+
@explorer_dispatch_thread = nil
|
|
104
163
|
|
|
105
164
|
@ws&.close
|
|
106
165
|
@ws = nil
|
|
166
|
+
@explorer_ws&.close
|
|
167
|
+
@explorer_ws = nil
|
|
107
168
|
end
|
|
108
169
|
|
|
109
170
|
def connected?
|
|
110
171
|
@connected
|
|
111
172
|
end
|
|
112
173
|
|
|
174
|
+
def explorer_connected?
|
|
175
|
+
@explorer_connected
|
|
176
|
+
end
|
|
177
|
+
|
|
113
178
|
def on(event, &callback)
|
|
114
179
|
@lifecycle_callbacks[event] = callback
|
|
115
180
|
end
|
|
@@ -335,6 +400,233 @@ module Hyperliquid
|
|
|
335
400
|
end
|
|
336
401
|
subs.each { |sub| send_subscribe(sub) }
|
|
337
402
|
end
|
|
403
|
+
|
|
404
|
+
# ── Explorer WebSocket ──────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
def subscribe_explorer(subscription, identifier, &callback)
|
|
407
|
+
sub_id = nil
|
|
408
|
+
|
|
409
|
+
@mutex.synchronize do
|
|
410
|
+
sub_id = @explorer_next_id
|
|
411
|
+
@explorer_next_id += 1
|
|
412
|
+
|
|
413
|
+
@explorer_subscriptions[identifier] ||= []
|
|
414
|
+
@explorer_subscriptions[identifier] << { id: sub_id, callback: callback }
|
|
415
|
+
@explorer_subscription_msgs[sub_id] = { subscription: subscription, identifier: identifier }
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
if @explorer_connected
|
|
419
|
+
send_explorer_subscribe(subscription)
|
|
420
|
+
else
|
|
421
|
+
@mutex.synchronize { @explorer_pending_subscriptions << subscription }
|
|
422
|
+
establish_explorer_connection unless @explorer_ws
|
|
423
|
+
start_explorer_dispatch_thread unless @explorer_dispatch_thread
|
|
424
|
+
start_explorer_ping_thread unless @explorer_ping_thread
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
sub_id
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def establish_explorer_connection
|
|
431
|
+
client = self
|
|
432
|
+
url = @explorer_ws_url
|
|
433
|
+
@explorer_connection_id += 1
|
|
434
|
+
active_id = @explorer_connection_id
|
|
435
|
+
|
|
436
|
+
@explorer_ws = ::WSLite.connect(url) do |ws|
|
|
437
|
+
ws.on :open do
|
|
438
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
439
|
+
|
|
440
|
+
client.send(:handle_explorer_open)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
ws.on :message do |msg|
|
|
444
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
445
|
+
|
|
446
|
+
client.send(:handle_explorer_message, msg.data)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
ws.on :error do |e|
|
|
450
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
451
|
+
|
|
452
|
+
client.send(:handle_explorer_error, e)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
ws.on :close do |e|
|
|
456
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
457
|
+
|
|
458
|
+
client.send(:handle_explorer_close, e)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def stale_explorer_connection?(id)
|
|
464
|
+
id != @explorer_connection_id
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def handle_explorer_open
|
|
468
|
+
@explorer_connected = true
|
|
469
|
+
@explorer_reconnect_attempts = 0
|
|
470
|
+
flush_explorer_pending_subscriptions
|
|
471
|
+
replay_explorer_subscriptions
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def handle_explorer_message(raw)
|
|
475
|
+
return if raw.nil? || raw.empty?
|
|
476
|
+
return if raw.start_with?('Websocket connection established')
|
|
477
|
+
|
|
478
|
+
data = parse_json(raw)
|
|
479
|
+
return unless data
|
|
480
|
+
|
|
481
|
+
if data.is_a?(Hash)
|
|
482
|
+
return if data['channel'] == 'pong'
|
|
483
|
+
|
|
484
|
+
return
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
unless data.is_a?(Array)
|
|
488
|
+
warn '[Hyperliquid::WS] Unknown explorer WS message shape'
|
|
489
|
+
return
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
return if data.empty?
|
|
493
|
+
|
|
494
|
+
identifier = identify_explorer_array(data)
|
|
495
|
+
return unless identifier
|
|
496
|
+
|
|
497
|
+
enqueue_explorer_message(identifier, data)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def identify_explorer_array(data)
|
|
501
|
+
first = data.first
|
|
502
|
+
return unless first.is_a?(Hash)
|
|
503
|
+
|
|
504
|
+
if first.key?('blockTime') && first.key?('hash') && first.key?('height') &&
|
|
505
|
+
first.key?('numTxs') && first.key?('proposer')
|
|
506
|
+
return 'explorerBlock'
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
if first.key?('action') && first.key?('block') && first.key?('error') &&
|
|
510
|
+
first.key?('hash') && first.key?('time') && first.key?('user')
|
|
511
|
+
return 'explorerTxs'
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
warn '[Hyperliquid::WS] Unknown explorer WS array shape'
|
|
515
|
+
nil
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def handle_explorer_error(error)
|
|
519
|
+
warn "[Hyperliquid::WS] Explorer WS error: #{error.message}"
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def handle_explorer_close(_event)
|
|
523
|
+
was_connected = @explorer_connected
|
|
524
|
+
@explorer_connected = false
|
|
525
|
+
|
|
526
|
+
attempt_explorer_reconnect if was_connected && !@explorer_closing && @reconnect_enabled
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def enqueue_explorer_message(identifier, data)
|
|
530
|
+
@mutex.synchronize do
|
|
531
|
+
if @explorer_queue.size >= @max_queue_size
|
|
532
|
+
@explorer_dropped_message_count += 1
|
|
533
|
+
if @explorer_dropped_message_count == 1 || (@explorer_dropped_message_count % 100).zero?
|
|
534
|
+
warn "[Hyperliquid::WS] Explorer queue full (#{@max_queue_size}). " \
|
|
535
|
+
"Dropped #{@explorer_dropped_message_count} message(s)."
|
|
536
|
+
end
|
|
537
|
+
return
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
@explorer_queue.push({ identifier: identifier, data: data })
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def start_explorer_dispatch_thread
|
|
544
|
+
@explorer_dispatch_thread = Thread.new do
|
|
545
|
+
loop do
|
|
546
|
+
msg = begin
|
|
547
|
+
@explorer_queue.pop
|
|
548
|
+
rescue ClosedQueueError
|
|
549
|
+
break
|
|
550
|
+
end
|
|
551
|
+
break if msg.nil?
|
|
552
|
+
|
|
553
|
+
callbacks = @mutex.synchronize { @explorer_subscriptions[msg[:identifier]]&.dup }
|
|
554
|
+
next unless callbacks
|
|
555
|
+
|
|
556
|
+
callbacks.each do |entry|
|
|
557
|
+
entry[:callback].call(msg[:data])
|
|
558
|
+
rescue StandardError => e
|
|
559
|
+
warn "[Hyperliquid::WS] Explorer callback error: #{e.message}"
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
@explorer_dispatch_thread.name = 'hl-explorer-ws-dispatch'
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def start_explorer_ping_thread
|
|
567
|
+
@explorer_ping_thread = Thread.new do
|
|
568
|
+
loop do
|
|
569
|
+
sleep Constants::WS_PING_INTERVAL
|
|
570
|
+
break if @explorer_closing
|
|
571
|
+
|
|
572
|
+
send_explorer_json({ method: 'ping' }) if @explorer_connected
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
@explorer_ping_thread.name = 'hl-explorer-ws-ping'
|
|
576
|
+
@explorer_ping_thread.report_on_exception = false
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def flush_explorer_pending_subscriptions
|
|
580
|
+
pending = @mutex.synchronize do
|
|
581
|
+
subs = @explorer_pending_subscriptions.dup
|
|
582
|
+
@explorer_pending_subscriptions.clear
|
|
583
|
+
subs
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
pending.each { |sub| send_explorer_subscribe(sub) }
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def send_explorer_subscribe(subscription)
|
|
590
|
+
send_explorer_json({ method: 'subscribe', subscription: subscription })
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def send_explorer_unsubscribe(subscription)
|
|
594
|
+
send_explorer_json({ method: 'unsubscribe', subscription: subscription })
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def send_explorer_json(hash)
|
|
598
|
+
@explorer_ws&.send(JSON.generate(hash))
|
|
599
|
+
rescue StandardError => e
|
|
600
|
+
warn "[Hyperliquid::WS] Explorer send error: #{e.message}"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def attempt_explorer_reconnect
|
|
604
|
+
Thread.new do
|
|
605
|
+
loop do
|
|
606
|
+
break if @explorer_closing
|
|
607
|
+
|
|
608
|
+
delay = [2**@explorer_reconnect_attempts, 30].min
|
|
609
|
+
@explorer_reconnect_attempts += 1
|
|
610
|
+
sleep delay
|
|
611
|
+
|
|
612
|
+
break if @explorer_closing
|
|
613
|
+
|
|
614
|
+
begin
|
|
615
|
+
establish_explorer_connection
|
|
616
|
+
break
|
|
617
|
+
rescue StandardError => e
|
|
618
|
+
warn "[Hyperliquid::WS] Explorer reconnect failed: #{e.message}"
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def replay_explorer_subscriptions
|
|
625
|
+
subs = @mutex.synchronize do
|
|
626
|
+
@explorer_subscription_msgs.values.map { |v| v[:subscription] }.uniq
|
|
627
|
+
end
|
|
628
|
+
subs.each { |sub| send_explorer_subscribe(sub) }
|
|
629
|
+
end
|
|
338
630
|
end
|
|
339
631
|
end
|
|
340
632
|
end
|
data/lib/hyperliquid.rb
CHANGED
|
@@ -58,7 +58,8 @@ module Hyperliquid
|
|
|
58
58
|
@info = Info.new(client)
|
|
59
59
|
@testnet = testnet
|
|
60
60
|
@exchange = nil
|
|
61
|
-
|
|
61
|
+
explorer_ws_url = testnet ? Constants::TESTNET_EXPLORER_WS_URL : Constants::MAINNET_EXPLORER_WS_URL
|
|
62
|
+
@ws = WS::Client.new(testnet: testnet, explorer_ws_url: explorer_ws_url)
|
|
62
63
|
|
|
63
64
|
return unless private_key
|
|
64
65
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Integration test for Explorer WebSocket subscription
|
|
5
|
+
# Connects to testnet explorer WS, subscribes to explorerBlock, and collects 3 block events
|
|
6
|
+
#
|
|
7
|
+
# Usage: ruby test_20_explorer_ws.rb
|
|
8
|
+
|
|
9
|
+
require_relative 'test_helpers'
|
|
10
|
+
|
|
11
|
+
puts '=' * 70
|
|
12
|
+
puts 'test_20: Explorer WebSocket subscription'
|
|
13
|
+
puts '=' * 70
|
|
14
|
+
|
|
15
|
+
# Initialize SDK with testnet
|
|
16
|
+
sdk = Hyperliquid::SDK.new(testnet: true)
|
|
17
|
+
|
|
18
|
+
puts "\n1. Testing explorer WebSocket connection and subscription..."
|
|
19
|
+
|
|
20
|
+
blocks_received = []
|
|
21
|
+
max_blocks = 3
|
|
22
|
+
timeout_seconds = 60
|
|
23
|
+
start_time = Time.now
|
|
24
|
+
|
|
25
|
+
puts " Subscribing to explorerBlock channel (expecting #{max_blocks} blocks within #{timeout_seconds}s)..."
|
|
26
|
+
puts " Explorer WS URL: wss://rpc.hyperliquid-testnet.xyz/ws"
|
|
27
|
+
|
|
28
|
+
# Subscribe to explorer blocks
|
|
29
|
+
sub_id = sdk.ws.subscribe_explorer_block do |block_array|
|
|
30
|
+
# Explorer messages arrive as arrays
|
|
31
|
+
block_array.each do |block|
|
|
32
|
+
blocks_received << block
|
|
33
|
+
elapsed = Time.now - start_time
|
|
34
|
+
puts " Block ##{blocks_received.size}: height=#{block['height']} hash=#{block['hash']} " \
|
|
35
|
+
"numTxs=#{block['numTxs']} proposer=#{block['proposer'][0..10]}... (#{elapsed.round(1)}s elapsed)"
|
|
36
|
+
|
|
37
|
+
break if blocks_received.size >= max_blocks
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
puts " Subscription ID: #{sub_id}"
|
|
42
|
+
|
|
43
|
+
# Wait for blocks with timeout
|
|
44
|
+
puts "\n2. Waiting for block events..."
|
|
45
|
+
loop do
|
|
46
|
+
break if blocks_received.size >= max_blocks
|
|
47
|
+
|
|
48
|
+
elapsed = Time.now - start_time
|
|
49
|
+
if elapsed > timeout_seconds
|
|
50
|
+
puts "\n ⚠ Timeout: Only received #{blocks_received.size}/#{max_blocks} blocks after #{timeout_seconds}s"
|
|
51
|
+
puts ' This may indicate testnet explorer is not producing blocks or WS connection issues'
|
|
52
|
+
break
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sleep 0.5
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Cleanup
|
|
59
|
+
puts "\n3. Cleanup..."
|
|
60
|
+
sdk.ws.unsubscribe(sub_id)
|
|
61
|
+
puts " Unsubscribed from #{sub_id}"
|
|
62
|
+
|
|
63
|
+
sdk.ws.close
|
|
64
|
+
puts ' WebSocket connection closed'
|
|
65
|
+
|
|
66
|
+
# Verify results
|
|
67
|
+
puts "\n4. Verification..."
|
|
68
|
+
if blocks_received.empty?
|
|
69
|
+
puts ' ❌ FAIL: No blocks received'
|
|
70
|
+
puts ' Explorer WebSocket may not be working on testnet, or connection failed'
|
|
71
|
+
exit 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
puts " ✓ Received #{blocks_received.size} block(s)"
|
|
75
|
+
|
|
76
|
+
# Verify block structure
|
|
77
|
+
blocks_received.each_with_index do |block, idx|
|
|
78
|
+
required_fields = %w[height hash numTxs proposer blockTime]
|
|
79
|
+
missing = required_fields - block.keys
|
|
80
|
+
unless missing.empty?
|
|
81
|
+
puts " ⚠ Block ##{idx + 1} missing fields: #{missing.join(', ')}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if blocks_received.size >= max_blocks
|
|
86
|
+
puts " ✓ PASS: Successfully received #{max_blocks} explorer blocks"
|
|
87
|
+
exit 0
|
|
88
|
+
else
|
|
89
|
+
puts " ⚠ PARTIAL: Received #{blocks_received.size}/#{max_blocks} blocks"
|
|
90
|
+
puts ' This is acceptable if testnet explorer is slow, but should be investigated'
|
|
91
|
+
exit 0 # Don't fail the test suite for partial results
|
|
92
|
+
end
|
data/scripts/test_all.rb
CHANGED
data/scripts/test_automated.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hyperliquid
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- carter2099
|
|
@@ -135,6 +135,7 @@ files:
|
|
|
135
135
|
- scripts/test_17_create_vault.rb
|
|
136
136
|
- scripts/test_18_user_portfolio_margin.rb
|
|
137
137
|
- scripts/test_19_spot_user.rb
|
|
138
|
+
- scripts/test_20_explorer_ws.rb
|
|
138
139
|
- scripts/test_all.rb
|
|
139
140
|
- scripts/test_automated.rb
|
|
140
141
|
- scripts/test_helpers.rb
|