hyperliquid 1.9.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +26 -0
- data/docs/API.md +374 -152
- data/docs/CONFIGURATION.md +25 -11
- data/docs/DEVELOPMENT.md +93 -15
- data/docs/ERRORS.md +2 -0
- data/docs/EXAMPLES.md +253 -2
- data/docs/WS.md +78 -20
- data/lib/hyperliquid/client.rb +19 -10
- data/lib/hyperliquid/exchange.rb +844 -22
- data/lib/hyperliquid/info.rb +23 -11
- data/lib/hyperliquid/signing/signer.rb +18 -1
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid/ws/client.rb +192 -78
- data/lib/hyperliquid.rb +6 -0
- metadata +16 -34
- data/.rspec +0 -3
- data/.rubocop.yml +0 -49
- data/.ruby-version +0 -1
- data/CLAUDE.md +0 -108
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -12
- data/example.rb +0 -161
- data/scripts/test_01_spot_market_roundtrip.rb +0 -48
- data/scripts/test_02_spot_limit_order.rb +0 -48
- data/scripts/test_03_perp_market_roundtrip.rb +0 -58
- data/scripts/test_04_perp_limit_order.rb +0 -52
- data/scripts/test_05_update_leverage.rb +0 -50
- data/scripts/test_06_modify_order.rb +0 -67
- data/scripts/test_07_market_close.rb +0 -55
- data/scripts/test_08_usd_class_transfer.rb +0 -36
- data/scripts/test_09_sub_account_lifecycle.rb +0 -51
- data/scripts/test_10_vault.rb +0 -68
- data/scripts/test_11_builder_fee.rb +0 -81
- data/scripts/test_12_staking.rb +0 -70
- data/scripts/test_13_ws_l2_book.rb +0 -83
- data/scripts/test_14_ws_candle.rb +0 -81
- data/scripts/test_15_explorer.rb +0 -71
- data/scripts/test_16_send_to_evm_with_data.rb +0 -79
- data/scripts/test_17_create_vault.rb +0 -73
- data/scripts/test_18_user_portfolio_margin.rb +0 -72
- data/scripts/test_19_spot_user.rb +0 -55
- data/scripts/test_20_explorer_ws.rb +0 -92
- data/scripts/test_all.rb +0 -96
- data/scripts/test_automated.rb +0 -82
- data/scripts/test_helpers.rb +0 -238
- data/sig/hyperliquid.rbs +0 -4
- data/test_integration.rb +0 -17
data/lib/hyperliquid/info.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
|
data/lib/hyperliquid/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
157
|
-
@explorer_queue
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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
|
|
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 = @
|
|
411
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|