hyperliquid 1.9.0 → 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 +63 -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 -104
  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
@@ -482,6 +482,17 @@ module Hyperliquid
482
482
  @client.post(Constants::INFO_ENDPOINT, { type: 'perpDexLimits', dex: dex })
483
483
  end
484
484
 
485
+ # Retrieve a user's approval state on every HIP-3* (testnet-only) venue where the user
486
+ # has been approved
487
+ # @param user [String] Wallet address
488
+ # @return [Hash] { "dexToState" => [[dex, state_or_nil], ...] } — an Array of
489
+ # [dex name, { "isReduceOnly", "isBackstopLiquidatorDepositAllowed" }] pairs; state is nil
490
+ # on venues that later removed the user. (GitBook shows dexToState as an object; the
491
+ # server actually returns pairs.)
492
+ def user_star_state(user)
493
+ @client.post(Constants::INFO_ENDPOINT, { type: 'userStarState', user: user })
494
+ end
495
+
485
496
  # Retrieve perp DEX status (e.g. total net deposit) for a builder-deployed dex
486
497
  # @param dex [String] Perp dex name; the empty string represents the first perp dex
487
498
  # @return [Hash] Keys: totalNetDeposit (String)
@@ -511,9 +522,11 @@ module Hyperliquid
511
522
  end
512
523
 
513
524
  # Retrieve prediction market outcome metadata
514
- # @return [Hash] Hash with outcomes (each with outcome, name, description, sideSpecs)
515
- # and questions (each with question, name, description, fallbackOutcome,
516
- # namedOutcomes, settledNamedOutcomes)
525
+ # @return [Hash] Hash with outcomes (each with outcome, name, description, sideSpecs,
526
+ # quoteToken, and for deployer outcomes venue/deployerFeeScale), questions (each with
527
+ # question, name, description, fallbackOutcome, namedOutcomes, settledNamedOutcomes),
528
+ # deployers (each with deployer, venue, subDeployers as [variant, [addresses]] pairs)
529
+ # and feeScale
517
530
  def outcome_meta
518
531
  @client.post(Constants::INFO_ENDPOINT, { type: 'outcomeMeta' })
519
532
  end
@@ -533,6 +546,13 @@ module Hyperliquid
533
546
  @client.post(Constants::INFO_ENDPOINT, { type: 'outcomeTemplates' })
534
547
  end
535
548
 
549
+ # Retrieve deployer limits for a HIP-4 outcome venue
550
+ # @param venue [String] HIP-4 deployer venue name (2-4 lowercase ASCII letters)
551
+ # @return [Hash] Hash with nDailyOutcomesRemaining and nActiveOutcomesRemaining
552
+ def outcome_deployer_limits(venue)
553
+ @client.post(Constants::INFO_ENDPOINT, { type: 'outcomeDeployerLimits', venue: venue })
554
+ end
555
+
536
556
  # Retrieve USDC transfer routing configuration
537
557
  # @return [Hash] Hash with depositRoute and withdrawalRoute, each "bridge" or "cctp"
538
558
  def usdc_routing
@@ -615,14 +635,6 @@ module Hyperliquid
615
635
  @client.post(Constants::INFO_ENDPOINT, { type: 'tokenDetails', tokenId: token_id })
616
636
  end
617
637
 
618
- # Get supply, rate, and pending payment information for an aligned quote token
619
- # @param token [Integer] Token index
620
- # @return [Hash] Hash with isAligned, firstAlignedTime, evmMintedSupply,
621
- # dailyAmountOwed (array of [date, amount] tuples), predictedRate
622
- def aligned_quote_token_info(token)
623
- @client.post(Constants::INFO_ENDPOINT, { type: 'alignedQuoteTokenInfo', token: token })
624
- end
625
-
626
638
  # ============================
627
639
  # Info: Borrow/Lend (HIP-2)
628
640
  # ============================
@@ -8,14 +8,26 @@ module Hyperliquid
8
8
  # EIP-712 signature generation for Hyperliquid exchange operations
9
9
  # Implements the phantom agent signing scheme used by Hyperliquid
10
10
  class Signer
11
+ # Accepted private key format: 32 bytes as 64 hex characters, optional 0x prefix
12
+ PRIVATE_KEY_FORMAT = /\A(0x)?\h{64}\z/
13
+ private_constant :PRIVATE_KEY_FORMAT
14
+
11
15
  # Initialize a new signer
12
16
  # @param private_key [String] Ethereum private key (hex string with or without 0x prefix)
13
17
  # @param testnet [Boolean] Whether to sign for testnet (default: false)
18
+ # @raise [ArgumentError] if private_key is not 64 hex characters (optionally 0x-prefixed);
19
+ # the message never includes the supplied value
14
20
  def initialize(private_key:, testnet: false)
15
21
  @testnet = testnet
16
22
  @key = Eth::Key.new(priv: normalize_private_key(private_key))
17
23
  end
18
24
 
25
+ # Redacted representation: never exposes the private key.
26
+ # @return [String]
27
+ def inspect
28
+ "#<#{self.class.name} address=#{address} testnet=#{@testnet}>"
29
+ end
30
+
19
31
  # Get the wallet address
20
32
  # @return [String] Checksummed Ethereum address
21
33
  def address
@@ -104,10 +116,15 @@ module Hyperliquid
104
116
 
105
117
  private
106
118
 
107
- # Normalize private key format
119
+ # Validate and normalize the private key format
108
120
  # @param key [String] Private key with or without 0x prefix
109
121
  # @return [String] Private key with 0x prefix
122
+ # @raise [ArgumentError] if the key is not 64 hex characters (the message omits the value)
110
123
  def normalize_private_key(key)
124
+ unless key.is_a?(String) && PRIVATE_KEY_FORMAT.match?(key)
125
+ raise ArgumentError, 'private_key must be 64 hex characters (32 bytes), optionally 0x-prefixed'
126
+ end
127
+
111
128
  key.start_with?('0x') ? key : "0x#{key}"
112
129
  end
113
130
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperliquid
4
- VERSION = '1.9.0'
4
+ VERSION = '2.0.0'
5
5
  end
@@ -2,11 +2,43 @@
2
2
 
3
3
  require 'ws_lite'
4
4
  require 'json'
5
+ require 'zlib'
5
6
 
6
7
  module Hyperliquid
7
8
  module WS
8
9
  # Managed WebSocket client for subscribing to real-time data channels
9
10
  class Client
11
+ # Routing fields per subscription type. Identifier = [type, *field values].join(':').
12
+ # user/coin are downcased; dex nil => '' (server canonicalizes an omitted dex to ""); interval verbatim.
13
+ ROUTING_KEYS = {
14
+ 'allMids' => [], 'orderUpdates' => [], 'userEvents' => [],
15
+ 'notification' => [],
16
+ 'allDexsAssetCtxs' => [], 'outcomeMetaUpdates' => [], 'spotAssetCtxs' => [], 'fastAssetCtxs' => [],
17
+ 'l2Book' => %i[coin], 'trades' => %i[coin], 'bbo' => %i[coin],
18
+ 'activeAssetCtx' => %i[coin],
19
+ 'candle' => %i[coin interval],
20
+ 'userFills' => %i[user], 'userFundings' => %i[user],
21
+ 'userNonFundingLedgerUpdates' => %i[user], 'userTwapSliceFills' => %i[user],
22
+ 'userTwapHistory' => %i[user], 'userHistoricalOrders' => %i[user],
23
+ 'allDexsClearinghouseState' => %i[user], 'webData3' => %i[user],
24
+ 'spotState' => %i[user],
25
+ 'clearinghouseState' => %i[user dex], 'openOrders' => %i[user dex], 'twapStates' => %i[user dex],
26
+ 'activeAssetData' => %i[user coin],
27
+ 'assetCtxs' => %i[dex]
28
+ }.freeze
29
+
30
+ # Server channel names that differ from the subscription type.
31
+ CHANNEL_ALIASES = {
32
+ 'user' => 'userEvents',
33
+ 'activeSpotAssetCtx' => 'activeAssetCtx'
34
+ }.freeze
35
+
36
+ # Payload omits this subscription field, so two subscriptions differing in it cannot be told apart.
37
+ EXCLUSIVE_FIELDS = {
38
+ 'orderUpdates' => :user, 'userEvents' => :user, 'userFills' => :aggregateByTime,
39
+ 'notification' => :user, 'spotState' => :ignorePortfolioMargin
40
+ }.freeze
41
+
10
42
  attr_reader :dropped_message_count, :explorer_dropped_message_count
11
43
 
12
44
  def initialize(testnet: false, max_queue_size: Constants::WS_MAX_QUEUE_SIZE, reconnect: true,
@@ -34,10 +66,11 @@ module Hyperliquid
34
66
  @closing = false
35
67
  @dispatch_thread = nil
36
68
  @ping_thread = nil
37
- @pending_subscriptions = []
38
69
  @lifecycle_callbacks = {}
39
70
  @reconnect_attempts = 0
71
+ @reconnect_generation = 0
40
72
  @connection_id = 0
73
+ @closed_connection_id = nil
41
74
  end
42
75
 
43
76
  def init_explorer_ws_state(explorer_ws_url)
@@ -46,15 +79,15 @@ module Hyperliquid
46
79
  @explorer_connected = false
47
80
  @explorer_closing = false
48
81
  @explorer_connection_id = 0
82
+ @explorer_closed_connection_id = nil
49
83
  @explorer_reconnect_attempts = 0
84
+ @explorer_reconnect_generation = 0
50
85
  @explorer_subscriptions = {}
51
86
  @explorer_subscription_msgs = {}
52
- @explorer_next_id = 0
53
87
  @explorer_queue = Queue.new
54
88
  @explorer_dropped_message_count = 0
55
89
  @explorer_dispatch_thread = nil
56
90
  @explorer_ping_thread = nil
57
- @explorer_pending_subscriptions = []
58
91
  end
59
92
 
60
93
  public
@@ -62,6 +95,9 @@ module Hyperliquid
62
95
  def connect
63
96
  @closing = false
64
97
  @reconnect_attempts = 0
98
+ # Supersedes any backoff thread still sleeping from before (e.g. close, then connect).
99
+ @reconnect_generation += 1
100
+ @queue = Queue.new if @queue.closed?
65
101
  establish_connection
66
102
  start_dispatch_thread
67
103
  start_ping_thread
@@ -75,6 +111,7 @@ module Hyperliquid
75
111
  sub_id = nil
76
112
 
77
113
  @mutex.synchronize do
114
+ ensure_exclusive!(subscription, identifier)
78
115
  sub_id = @next_id
79
116
  @next_id += 1
80
117
 
@@ -86,8 +123,7 @@ module Hyperliquid
86
123
  if @connected
87
124
  send_subscribe(subscription)
88
125
  else
89
- @mutex.synchronize { @pending_subscriptions << subscription }
90
- connect unless @ws
126
+ connect unless @ws # handle_open sends every registered subscription
91
127
  end
92
128
 
93
129
  sub_id
@@ -107,6 +143,7 @@ module Hyperliquid
107
143
  subscribe_explorer({ type: 'explorerTxs' }, 'explorerTxs', &)
108
144
  end
109
145
 
146
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
110
147
  def unsubscribe(subscription_id)
111
148
  sub_msg = nil
112
149
  should_send = false
@@ -147,14 +184,16 @@ module Hyperliquid
147
184
  @connected = false
148
185
  @explorer_closing = true
149
186
  @explorer_connected = false
187
+ @reconnect_generation += 1
188
+ @explorer_reconnect_generation += 1
150
189
 
151
190
  @ping_thread&.kill
152
191
  @ping_thread = nil
153
192
  @explorer_ping_thread&.kill
154
193
  @explorer_ping_thread = nil
155
194
 
156
- @queue&.close if @queue.respond_to?(:close)
157
- @explorer_queue&.close if @explorer_queue.respond_to?(:close)
195
+ @queue.close
196
+ @explorer_queue.close
158
197
 
159
198
  @dispatch_thread&.join(5)
160
199
  @dispatch_thread = nil
@@ -191,7 +230,7 @@ module Hyperliquid
191
230
  ws.on :open do
192
231
  next if client.send(:stale_connection?, active_id)
193
232
 
194
- client.send(:handle_open)
233
+ client.send(:handle_open, ws)
195
234
  end
196
235
 
197
236
  ws.on :message do |msg|
@@ -206,11 +245,10 @@ module Hyperliquid
206
245
  client.send(:handle_error, e)
207
246
  end
208
247
 
209
- ws.on :close do |e|
210
- next if client.send(:stale_connection?, active_id)
211
-
212
- client.send(:handle_close, e)
213
- end
248
+ # ws_lite 1.0.x emits its internal :__close for every close, but on a server-initiated
249
+ # close its read thread kills itself before emitting :close. Listen to both.
250
+ ws.on(:__close) { |e| client.send(:handle_socket_close, active_id, e) }
251
+ ws.on(:close) { |e| client.send(:handle_socket_close, active_id, e) }
214
252
  end
215
253
  end
216
254
 
@@ -218,14 +256,25 @@ module Hyperliquid
218
256
  id != @connection_id
219
257
  end
220
258
 
221
- def handle_open
259
+ # Handles each live connection's close once, whichever close event reports it first.
260
+ def handle_socket_close(id, event)
261
+ return if stale_connection?(id) || @closed_connection_id == id
262
+
263
+ @closed_connection_id = id
264
+ handle_close(event)
265
+ end
266
+
267
+ # Takes the socket from the :open event: on reconnect the read thread can emit :open before
268
+ # establish_connection has assigned @ws, and the replayed subscriptions must go to this socket.
269
+ def handle_open(socket)
270
+ @ws = socket
222
271
  @connected = true
223
272
  @reconnect_attempts = 0
224
- flush_pending_subscriptions
225
273
  replay_subscriptions
226
274
  @lifecycle_callbacks[:open]&.call
227
275
  end
228
276
 
277
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
229
278
  def handle_message(raw)
230
279
  return if raw.nil? || raw.empty?
231
280
 
@@ -238,10 +287,37 @@ module Hyperliquid
238
287
  return if channel == 'pong'
239
288
  return unless channel
240
289
 
241
- identifier = compute_identifier(channel, data['data'])
290
+ payload = data['data']
291
+ if channel == 'fastAssetCtxs'
292
+ payload = decode_compressed_payload(payload)
293
+ return if payload.nil?
294
+ end
295
+
296
+ identifier = compute_identifier(channel, payload)
242
297
  return unless identifier
243
298
 
244
- enqueue_message(identifier, data['data'])
299
+ enqueue_message(identifier, payload)
300
+ end
301
+
302
+ # fastAssetCtxs frames carry `data` as base64(raw DEFLATE, RFC 1951) of a UTF-8 JSON
303
+ # document. Returns the parsed JSON, or nil (after warning) when the payload cannot be
304
+ # decoded so a bad frame is dropped without raising into the ws_lite read thread.
305
+ def decode_compressed_payload(encoded)
306
+ unless encoded.is_a?(String)
307
+ warn "[Hyperliquid::WS] Failed to decode compressed message: expected String, got #{encoded.class}"
308
+ return nil
309
+ end
310
+
311
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
312
+ json = inflater.inflate(encoded.unpack1('m0'))
313
+ raise Zlib::BufError, 'truncated DEFLATE stream' unless inflater.finished?
314
+
315
+ JSON.parse(json.force_encoding(Encoding::UTF_8))
316
+ rescue ArgumentError, Zlib::Error, JSON::ParserError => e
317
+ warn "[Hyperliquid::WS] Failed to decode compressed message: #{e.class}: #{e.message}"
318
+ nil
319
+ ensure
320
+ inflater&.close
245
321
  end
246
322
 
247
323
  def handle_error(error)
@@ -264,35 +340,64 @@ module Hyperliquid
264
340
  end
265
341
 
266
342
  def compute_identifier(channel, data)
267
- case channel
268
- when 'l2Book' then "l2Book:#{data['coin'].downcase}"
269
- when 'trades' then data.is_a?(Array) && data[0] ? "trades:#{data[0]['coin'].downcase}" : nil
270
- when 'bbo' then "bbo:#{data['coin'].downcase}"
271
- when 'candle' then "candle:#{data['s'].downcase}:#{data['i']}"
272
- when 'allMids' then 'allMids'
273
- when 'orderUpdates' then 'orderUpdates'
274
- when 'userEvents' then "userEvents:#{data['user'].downcase}"
275
- when 'userFills' then "userFills:#{data['user'].downcase}"
276
- when 'userFundings' then "userFundings:#{data['user'].downcase}"
343
+ type = CHANNEL_ALIASES.fetch(channel, channel)
344
+ keys = ROUTING_KEYS[type]
345
+ return unless keys
346
+ return type if keys.empty?
347
+
348
+ fields = message_routing_fields(type, data)
349
+ fields && routing_identifier(type, fields)
350
+ end
351
+
352
+ def message_routing_fields(type, data)
353
+ case type
354
+ when 'trades' then data.is_a?(Array) && data[0] ? { coin: data[0]['coin'] } : nil
355
+ when 'candle' then { coin: data['s'], interval: data['i'] }
356
+ when 'webData3' then { user: data['userState']['user'] }
357
+ else { coin: data['coin'], user: data['user'], dex: data['dex'] }
277
358
  end
278
359
  end
279
360
 
280
361
  def subscription_identifier(subscription)
281
362
  type = sub_field(subscription, 'type')
282
- case type
283
- when 'l2Book' then "l2Book:#{sub_field(subscription, 'coin').downcase}"
284
- when 'trades' then "trades:#{sub_field(subscription, 'coin').downcase}"
285
- when 'bbo' then "bbo:#{sub_field(subscription, 'coin').downcase}"
286
- when 'candle'
287
- "candle:#{sub_field(subscription, 'coin').downcase}:#{sub_field(subscription, 'interval')}"
288
- when 'allMids' then 'allMids'
289
- when 'orderUpdates' then 'orderUpdates'
290
- when 'userEvents' then "userEvents:#{sub_field(subscription, 'user').downcase}"
291
- when 'userFills' then "userFills:#{sub_field(subscription, 'user').downcase}"
292
- when 'userFundings' then "userFundings:#{sub_field(subscription, 'user').downcase}"
293
- else
294
- raise Hyperliquid::WebSocketError, "Unsupported subscription type: #{type}"
363
+ keys = ROUTING_KEYS[type]
364
+ raise Hyperliquid::WebSocketError, "Unsupported subscription type: #{type}" unless keys
365
+
366
+ routing_identifier(type, keys.to_h { |key| [key, sub_field(subscription, key.to_s)] })
367
+ end
368
+
369
+ def routing_identifier(type, fields)
370
+ parts = ROUTING_KEYS.fetch(type).map do |key|
371
+ value = fields[key]
372
+ case key
373
+ when :dex then value.to_s.downcase
374
+ when :interval then value
375
+ else value.downcase
376
+ end
295
377
  end
378
+ [type, *parts].join(':')
379
+ end
380
+
381
+ def ensure_exclusive!(subscription, identifier)
382
+ type = sub_field(subscription, 'type')
383
+ field = EXCLUSIVE_FIELDS[type]
384
+ return unless field
385
+
386
+ existing = @subscription_msgs.each_value.find { |msg| msg[:identifier] == identifier }
387
+ return unless existing
388
+
389
+ mine = exclusive_value(subscription, field)
390
+ theirs = exclusive_value(existing[:subscription], field)
391
+ return if mine == theirs
392
+
393
+ raise Hyperliquid::WebSocketError,
394
+ "#{type} messages do not include #{field}; already subscribed with #{field}=#{theirs.inspect} " \
395
+ "on this connection (requested #{mine.inspect}). Unsubscribe first or use a separate WS::Client."
396
+ end
397
+
398
+ def exclusive_value(subscription, field)
399
+ value = sub_field(subscription, field.to_s)
400
+ field == :user ? value.to_s.downcase : value == true
296
401
  end
297
402
 
298
403
  def sub_field(subscription, key)
@@ -311,6 +416,8 @@ module Hyperliquid
311
416
  end
312
417
  end
313
418
  @queue.push({ identifier: identifier, data: data })
419
+ rescue ClosedQueueError
420
+ nil # close raced an in-flight frame; drop it instead of raising into the socket thread
314
421
  end
315
422
 
316
423
  def start_dispatch_thread
@@ -349,16 +456,6 @@ module Hyperliquid
349
456
  @ping_thread.report_on_exception = false
350
457
  end
351
458
 
352
- def flush_pending_subscriptions
353
- pending = @mutex.synchronize do
354
- subs = @pending_subscriptions.dup
355
- @pending_subscriptions.clear
356
- subs
357
- end
358
-
359
- pending.each { |sub| send_subscribe(sub) }
360
- end
361
-
362
459
  def send_subscribe(subscription)
363
460
  send_json({ method: 'subscribe', subscription: subscription })
364
461
  end
@@ -374,15 +471,16 @@ module Hyperliquid
374
471
  end
375
472
 
376
473
  def attempt_reconnect
474
+ generation = @reconnect_generation
377
475
  Thread.new do
378
476
  loop do
379
- break if @closing
477
+ break if reconnect_cancelled?(generation)
380
478
 
381
479
  delay = [2**@reconnect_attempts, 30].min
382
480
  @reconnect_attempts += 1
383
481
  sleep delay
384
482
 
385
- break if @closing
483
+ break if reconnect_cancelled?(generation)
386
484
 
387
485
  begin
388
486
  establish_connection
@@ -394,6 +492,11 @@ module Hyperliquid
394
492
  end
395
493
  end
396
494
 
495
+ # True once close or a later connect has superseded the backoff loop started at `generation`.
496
+ def reconnect_cancelled?(generation)
497
+ @closing || generation != @reconnect_generation
498
+ end
499
+
397
500
  def replay_subscriptions
398
501
  subs = @mutex.synchronize do
399
502
  @subscription_msgs.values.map { |v| v[:subscription] }.uniq
@@ -407,8 +510,8 @@ module Hyperliquid
407
510
  sub_id = nil
408
511
 
409
512
  @mutex.synchronize do
410
- sub_id = @explorer_next_id
411
- @explorer_next_id += 1
513
+ sub_id = @next_id
514
+ @next_id += 1
412
515
 
413
516
  @explorer_subscriptions[identifier] ||= []
414
517
  @explorer_subscriptions[identifier] << { id: sub_id, callback: callback }
@@ -418,15 +521,22 @@ module Hyperliquid
418
521
  if @explorer_connected
419
522
  send_explorer_subscribe(subscription)
420
523
  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
524
+ connect_explorer unless @explorer_ws # handle_explorer_open sends every registered subscription
425
525
  end
426
526
 
427
527
  sub_id
428
528
  end
429
529
 
530
+ def connect_explorer
531
+ @explorer_closing = false
532
+ @explorer_reconnect_attempts = 0
533
+ @explorer_reconnect_generation += 1
534
+ @explorer_queue = Queue.new if @explorer_queue.closed?
535
+ establish_explorer_connection
536
+ start_explorer_dispatch_thread
537
+ start_explorer_ping_thread
538
+ end
539
+
430
540
  def establish_explorer_connection
431
541
  client = self
432
542
  url = @explorer_ws_url
@@ -437,7 +547,7 @@ module Hyperliquid
437
547
  ws.on :open do
438
548
  next if client.send(:stale_explorer_connection?, active_id)
439
549
 
440
- client.send(:handle_explorer_open)
550
+ client.send(:handle_explorer_open, ws)
441
551
  end
442
552
 
443
553
  ws.on :message do |msg|
@@ -452,11 +562,9 @@ module Hyperliquid
452
562
  client.send(:handle_explorer_error, e)
453
563
  end
454
564
 
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
565
+ # See establish_connection: ws_lite 1.0.x may report a close only as :__close.
566
+ ws.on(:__close) { |e| client.send(:handle_explorer_socket_close, active_id, e) }
567
+ ws.on(:close) { |e| client.send(:handle_explorer_socket_close, active_id, e) }
460
568
  end
461
569
  end
462
570
 
@@ -464,13 +572,21 @@ module Hyperliquid
464
572
  id != @explorer_connection_id
465
573
  end
466
574
 
467
- def handle_explorer_open
575
+ def handle_explorer_socket_close(id, event)
576
+ return if stale_explorer_connection?(id) || @explorer_closed_connection_id == id
577
+
578
+ @explorer_closed_connection_id = id
579
+ handle_explorer_close(event)
580
+ end
581
+
582
+ def handle_explorer_open(socket)
583
+ @explorer_ws = socket
468
584
  @explorer_connected = true
469
585
  @explorer_reconnect_attempts = 0
470
- flush_explorer_pending_subscriptions
471
586
  replay_explorer_subscriptions
472
587
  end
473
588
 
589
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
474
590
  def handle_explorer_message(raw)
475
591
  return if raw.nil? || raw.empty?
476
592
  return if raw.start_with?('Websocket connection established')
@@ -497,6 +613,7 @@ module Hyperliquid
497
613
  enqueue_explorer_message(identifier, data)
498
614
  end
499
615
 
616
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
500
617
  def identify_explorer_array(data)
501
618
  first = data.first
502
619
  return unless first.is_a?(Hash)
@@ -538,6 +655,8 @@ module Hyperliquid
538
655
  end
539
656
  end
540
657
  @explorer_queue.push({ identifier: identifier, data: data })
658
+ rescue ClosedQueueError
659
+ nil # close raced an in-flight frame; drop it instead of raising into the socket thread
541
660
  end
542
661
 
543
662
  def start_explorer_dispatch_thread
@@ -576,16 +695,6 @@ module Hyperliquid
576
695
  @explorer_ping_thread.report_on_exception = false
577
696
  end
578
697
 
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
698
  def send_explorer_subscribe(subscription)
590
699
  send_explorer_json({ method: 'subscribe', subscription: subscription })
591
700
  end
@@ -601,15 +710,16 @@ module Hyperliquid
601
710
  end
602
711
 
603
712
  def attempt_explorer_reconnect
713
+ generation = @explorer_reconnect_generation
604
714
  Thread.new do
605
715
  loop do
606
- break if @explorer_closing
716
+ break if explorer_reconnect_cancelled?(generation)
607
717
 
608
718
  delay = [2**@explorer_reconnect_attempts, 30].min
609
719
  @explorer_reconnect_attempts += 1
610
720
  sleep delay
611
721
 
612
- break if @explorer_closing
722
+ break if explorer_reconnect_cancelled?(generation)
613
723
 
614
724
  begin
615
725
  establish_explorer_connection
@@ -621,6 +731,10 @@ module Hyperliquid
621
731
  end
622
732
  end
623
733
 
734
+ def explorer_reconnect_cancelled?(generation)
735
+ @explorer_closing || generation != @explorer_reconnect_generation
736
+ end
737
+
624
738
  def replay_explorer_subscriptions
625
739
  subs = @mutex.synchronize do
626
740
  @explorer_subscription_msgs.values.map { |v| v[:subscription] }.uniq
data/lib/hyperliquid.rb CHANGED
@@ -84,5 +84,11 @@ module Hyperliquid
84
84
  def base_url
85
85
  @testnet ? Constants::TESTNET_API_URL : Constants::MAINNET_API_URL
86
86
  end
87
+
88
+ # Redacted representation: never exposes the private key.
89
+ # @return [String]
90
+ def inspect
91
+ "#<#{self.class.name} testnet=#{@testnet} base_url=#{base_url} exchange=#{@exchange.inspect}>"
92
+ end
87
93
  end
88
94
  end