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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa1051cb2277e69795819ed2ae8564e89ac3e6b34139d9112a8273f0604d2fa1
4
- data.tar.gz: 072416d6751fa766f0b4eeb5c416365ea96652b980301932d3ab32a549750b24
3
+ metadata.gz: 14a1a12bfa26b33260f7f2b0eacd8aeeb6762fc5d7d678f44cd63d6b492dcdd2
4
+ data.tar.gz: b0d4516d805842818e5d71b904a1f707014446802ad8efe60a7b6a6ed6a27853
5
5
  SHA512:
6
- metadata.gz: 41059a1ef505d93e061fdd575f42ca340c6eb1e8100f14adf176c9512bd65ce4c1afdd1ddeeb831a1833bce63b9ca065f602681c673bba9b96f7a298130b05a0
7
- data.tar.gz: 9cfb58786186bafc873dc9dd1747c2b87a645770fddc8d759ea3b5420e7367f1118de056de8e470dbe8bff06e44e55033ce063d839a3c81803fc418e3be68890
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 14
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 a persistent WSS connection with subscription tracking, automatic reconnection (exp backoff, 30s cap), 50s ping keepalive, and a bounded message queue (1024, drops oldest on overflow). Subscriptions are identified by a canonical key and dispatched via callbacks on a dedicated thread.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperliquid
4
- VERSION = '1.7.0'
4
+ VERSION = '1.8.0'
5
5
  end
@@ -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
- callbacks = @subscriptions[identifier]
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
- @subscriptions.delete(identifier)
131
+ map.delete(identifier)
87
132
  should_send = true
88
133
  end
89
134
  end
90
135
 
91
- send_unsubscribe(sub_msg[:subscription]) if should_send && @connected
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
- @ws = WS::Client.new(testnet: testnet)
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
@@ -37,7 +37,8 @@ SCRIPTS = [
37
37
  'test_16_send_to_evm_with_data.rb',
38
38
  'test_17_create_vault.rb',
39
39
  'test_18_user_portfolio_margin.rb',
40
- 'test_19_spot_user.rb'
40
+ 'test_19_spot_user.rb',
41
+ 'test_20_explorer_ws.rb'
41
42
  ].freeze
42
43
 
43
44
  def green(text)
@@ -23,7 +23,8 @@ SCRIPTS = [
23
23
  'test_11_builder_fee.rb',
24
24
  'test_13_ws_l2_book.rb',
25
25
  'test_14_ws_candle.rb',
26
- 'test_15_explorer.rb'
26
+ 'test_15_explorer.rb',
27
+ 'test_20_explorer_ws.rb'
27
28
  ].freeze
28
29
 
29
30
  def green(text)
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.7.0
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