hyperliquid 1.6.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: ea2358a056d774b7c16f5630111fe847204db819d4cdb6c0c9a3c9d601e9977d
4
- data.tar.gz: cd896dd17790c385765d0484c5d1d57c8589ac4b8b8b238eae624dde135f47b1
3
+ metadata.gz: 14a1a12bfa26b33260f7f2b0eacd8aeeb6762fc5d7d678f44cd63d6b492dcdd2
4
+ data.tar.gz: b0d4516d805842818e5d71b904a1f707014446802ad8efe60a7b6a6ed6a27853
5
5
  SHA512:
6
- metadata.gz: dce41aa6805c512465cba2a66a33a277ad84b6130b06ca80641f45b45ecd8cea6e89d30a1ad592fd30d93f90ef06c4369633ba35df98b6b0429ccd3bfb1377c0
7
- data.tar.gz: 913c99238363b115273cfd31e2ec3ae8fe61d3de9b2e7367cd0a295b771d75e3b5f5dc050f97d535dfa66b10bae6fcda52dc54835c0e61ffee8b5f14fea2ba2a
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,33 @@
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
+
14
+ ## [1.7.0] - 2026-06-11
15
+
16
+ ### New Exchange actions
17
+
18
+ - `Exchange#authorize_aqav2_role(token:, role:)` — L1 action authorizing an AQAv2 role (e.g. `"treasury"`); supports `expires_after`.
19
+ - `Exchange#staking_link_disable_trading_user(trading_user:)` — user-signed action linking a staking account to a trading user (irreversible). Adds `STAKING_LINK_DISABLE_TRADING_USER_TYPES`.
20
+ - `Exchange#finalize_evm_contract(input:)` — L1 action linking a HyperCore spot token to a HyperEVM ERC-20 contract. Accepts a `Hash` (`{create: {nonce:}}`) or string variant (`"firstStorageSlot"` / `"customStorageSlot"`).
21
+ - HIP-4 `userOutcome` variants (L1 actions, not user-signed): `split_outcome(question:, outcome:, amount:)`, `merge_outcome(question:, amount:)`, `merge_question(question:, amount:)`, `negate_outcome(question:, outcome:, amount:)`. `merge_outcome` and `merge_question` accept `amount: nil` for the max-available case.
22
+
23
+ ### New Info methods
24
+
25
+ - `Info#settled_outcome(outcome:)` — returns settled prediction-market outcome data.
26
+
27
+ ### Fixes
28
+
29
+ - `Signing::MultiSig.payload_action` now normalizes `userSetAbstraction` long-form abstraction values (`"disabled"`, `"unifiedAccount"`, `"portfolioMargin"`) to their wire enum equivalents (`"i"`, `"u"`, `"p"`) in the L1 payload posted to `/exchange`, matching Python SDK 0.24.0. Pre-translated short codes pass through unchanged.
30
+
3
31
  ## [1.6.0] - 2026-05-26
4
32
 
5
33
  ### 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
@@ -1017,6 +1017,31 @@ module Hyperliquid
1017
1017
  post_action(action, signature, nonce, nil)
1018
1018
  end
1019
1019
 
1020
+ # Permanently disable a linked trading user, locking its funds
1021
+ # (`stakingLinkDisableTradingUser` user-signed action). Sent by the staking user.
1022
+ # After 1 year of locking, funds from the trading user are automatically transferred
1023
+ # to the staking user. **This action is irreversible.** The `trading_user` address is
1024
+ # lowercased to match the address-field convention used by other user-signed actions.
1025
+ # @param trading_user [String] Trading user address to disable
1026
+ # @return [Hash] Exchange response
1027
+ def staking_link_disable_trading_user(trading_user:)
1028
+ nonce = timestamp_ms
1029
+ trading_user_lower = trading_user.downcase
1030
+ action = {
1031
+ type: 'stakingLinkDisableTradingUser',
1032
+ signatureChainId: '0x66eee',
1033
+ hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
1034
+ tradingUser: trading_user_lower,
1035
+ nonce: nonce
1036
+ }
1037
+ signature = @signer.sign_user_signed_action(
1038
+ { tradingUser: trading_user_lower, nonce: nonce },
1039
+ 'HyperliquidTransaction:StakingLinkDisableTradingUser',
1040
+ Signing::EIP712::STAKING_LINK_DISABLE_TRADING_USER_TYPES
1041
+ )
1042
+ post_action(action, signature, nonce, nil)
1043
+ end
1044
+
1020
1045
  # Move assets between DEX instances on behalf of an agent's principal
1021
1046
  # (`agentSendAsset` L1 action). Unlike `send_asset` (which is user-signed),
1022
1047
  # this is signed by an agent and the destination must equal the agent's
@@ -1237,6 +1262,104 @@ module Hyperliquid
1237
1262
  post_action(action, signature, nonce, nil)
1238
1263
  end
1239
1264
 
1265
+ # HIP-4: split `amount` quote tokens into `amount` Yes and `amount` No shares
1266
+ # of an outcome (`userOutcome` L1 action, splitOutcome variant).
1267
+ # @param outcome [Integer] Outcome identifier
1268
+ # @param amount [String, Numeric] Amount of quote tokens to split (UnsignedDecimal, coerced via to_s)
1269
+ # @return [Hash] Exchange response
1270
+ def split_outcome(outcome:, amount:)
1271
+ nonce = timestamp_ms
1272
+ action = {
1273
+ type: 'userOutcome',
1274
+ splitOutcome: { outcome: outcome, amount: amount.to_s }
1275
+ }
1276
+ signature = @signer.sign_l1_action(
1277
+ action, nonce,
1278
+ expires_after: @expires_after
1279
+ )
1280
+ post_action(action, signature, nonce, nil)
1281
+ end
1282
+
1283
+ # HIP-4: merge `amount` Yes and `amount` No shares of an outcome back into
1284
+ # `amount` quote tokens (`userOutcome` L1 action, mergeOutcome variant).
1285
+ # Pass `amount: nil` to merge the maximum available.
1286
+ # @param outcome [Integer] Outcome identifier
1287
+ # @param amount [String, Numeric, nil] Amount of shares to merge; nil = maximum available
1288
+ # @return [Hash] Exchange response
1289
+ def merge_outcome(outcome:, amount: nil)
1290
+ nonce = timestamp_ms
1291
+ action = {
1292
+ type: 'userOutcome',
1293
+ mergeOutcome: { outcome: outcome, amount: amount&.to_s }
1294
+ }
1295
+ signature = @signer.sign_l1_action(
1296
+ action, nonce,
1297
+ expires_after: @expires_after
1298
+ )
1299
+ post_action(action, signature, nonce, nil)
1300
+ end
1301
+
1302
+ # HIP-4: merge `amount` Yes shares from every outcome of a question into
1303
+ # `amount` quote tokens (`userOutcome` L1 action, mergeQuestion variant).
1304
+ # Pass `amount: nil` to merge the maximum available.
1305
+ # @param question [Integer] Question identifier
1306
+ # @param amount [String, Numeric, nil] Amount of shares to merge; nil = maximum available
1307
+ # @return [Hash] Exchange response
1308
+ def merge_question(question:, amount: nil)
1309
+ nonce = timestamp_ms
1310
+ action = {
1311
+ type: 'userOutcome',
1312
+ mergeQuestion: { question: question, amount: amount&.to_s }
1313
+ }
1314
+ signature = @signer.sign_l1_action(
1315
+ action, nonce,
1316
+ expires_after: @expires_after
1317
+ )
1318
+ post_action(action, signature, nonce, nil)
1319
+ end
1320
+
1321
+ # HIP-4: convert `amount` No shares from one outcome of a question into
1322
+ # `amount` Yes shares of every other outcome associated with that question
1323
+ # (`userOutcome` L1 action, negateOutcome variant).
1324
+ # @param question [Integer] Question identifier
1325
+ # @param outcome [Integer] Outcome identifier whose No shares are being negated
1326
+ # @param amount [String, Numeric] Amount of No shares to negate (UnsignedDecimal, coerced via to_s)
1327
+ # @return [Hash] Exchange response
1328
+ def negate_outcome(question:, outcome:, amount:)
1329
+ nonce = timestamp_ms
1330
+ action = {
1331
+ type: 'userOutcome',
1332
+ negateOutcome: { question: question, outcome: outcome, amount: amount.to_s }
1333
+ }
1334
+ signature = @signer.sign_l1_action(
1335
+ action, nonce,
1336
+ expires_after: @expires_after
1337
+ )
1338
+ post_action(action, signature, nonce, nil)
1339
+ end
1340
+
1341
+ # Finalize the link between a HyperCore spot token and an ERC-20 contract on
1342
+ # HyperEVM (`finalizeEvmContract` L1 action). `input` selects the verification method
1343
+ # and is passed through verbatim — accepts a Hash `{ create: { nonce: <int> } }` for an
1344
+ # EOA-deployed contract, or one of the strings `"firstStorageSlot"` / `"customStorageSlot"`
1345
+ # for contracts that store the finalizer address in a known storage slot.
1346
+ # @param token [Integer] HyperCore spot token identifier to link
1347
+ # @param input [Hash, String] Verification method (see above)
1348
+ # @return [Hash] Exchange response
1349
+ def finalize_evm_contract(token:, input:)
1350
+ nonce = timestamp_ms
1351
+ action = {
1352
+ type: 'finalizeEvmContract',
1353
+ token: token,
1354
+ input: input
1355
+ }
1356
+ signature = @signer.sign_l1_action(
1357
+ action, nonce,
1358
+ expires_after: @expires_after
1359
+ )
1360
+ post_action(action, signature, nonce, nil)
1361
+ end
1362
+
1240
1363
  # Opt in or out of spot dusting (`spotUser` L1 action).
1241
1364
  # Spot dusting is the protocol's automatic conversion of small spot balances.
1242
1365
  # Despite the generic action name, this method exclusively toggles that opt-out flag.
@@ -1252,6 +1375,20 @@ module Hyperliquid
1252
1375
  post_action(action, signature, nonce, nil)
1253
1376
  end
1254
1377
 
1378
+ # Authorize an AQAv2 role (`authorizeAqav2Role` L1 action).
1379
+ # @param token [Integer] Token identifier
1380
+ # @param role [String] Role to authorize ("technical" or "treasury")
1381
+ # @return [Hash] Exchange response
1382
+ def authorize_aqav2_role(token:, role:)
1383
+ nonce = timestamp_ms
1384
+ action = { type: 'authorizeAqav2Role', token: token.to_i, role: role }
1385
+ signature = @signer.sign_l1_action(
1386
+ action, nonce,
1387
+ expires_after: @expires_after
1388
+ )
1389
+ post_action(action, signature, nonce, nil)
1390
+ end
1391
+
1255
1392
  # Clear the asset metadata cache
1256
1393
  # Call this if metadata has been updated
1257
1394
  def reload_metadata!
@@ -515,6 +515,13 @@ module Hyperliquid
515
515
  @client.post(Constants::INFO_ENDPOINT, { type: 'outcomeMeta' })
516
516
  end
517
517
 
518
+ # Retrieve information about a settled outcome
519
+ # @param outcome [Integer] Outcome identifier
520
+ # @return [Hash, nil] Hash with spec, settleFraction, and details; or nil if not settled
521
+ def settled_outcome(outcome:)
522
+ @client.post(Constants::INFO_ENDPOINT, { type: 'settledOutcome', outcome: outcome.to_i })
523
+ end
524
+
518
525
  # Retrieve a user's funding history
519
526
  # @param user [String]
520
527
  # @param start_time [Integer]
@@ -130,6 +130,14 @@ module Hyperliquid
130
130
  ]
131
131
  }.freeze
132
132
 
133
+ STAKING_LINK_DISABLE_TRADING_USER_TYPES = {
134
+ 'HyperliquidTransaction:StakingLinkDisableTradingUser': [
135
+ { name: :hyperliquidChain, type: 'string' },
136
+ { name: :tradingUser, type: 'address' },
137
+ { name: :nonce, type: 'uint64' }
138
+ ]
139
+ }.freeze
140
+
133
141
  MULTI_SIG_TYPES = {
134
142
  'HyperliquidTransaction:SendMultiSig': [
135
143
  { name: :hyperliquidChain, type: 'string' },
@@ -17,6 +17,16 @@ module Hyperliquid
17
17
  module MultiSig
18
18
  OUTER_PRIMARY_TYPE = 'HyperliquidTransaction:SendMultiSig'
19
19
 
20
+ # Wire-format normalization for userSetAbstraction inside multi_sig envelopes.
21
+ # Each co-signer's EIP-712 hash uses the human-readable abstraction string, but the
22
+ # L1 payload requires the single-char wire enum. Mirrors Python SDK 0.24.0's
23
+ # `_multi_sig_payload_action`.
24
+ USER_SET_ABSTRACTION_WIRE_VALUES = {
25
+ 'disabled' => 'i',
26
+ 'unifiedAccount' => 'u',
27
+ 'portfolioMargin' => 'p'
28
+ }.freeze
29
+
20
30
  # Build the outer multi-sig envelope (the action body posted to /exchange).
21
31
  # @param inner_action [Hash] The wrapped action (any L1 or user-signed action body)
22
32
  # @param multi_sig_user [String] Address of the multi-sig user (lowercased)
@@ -31,11 +41,26 @@ module Hyperliquid
31
41
  payload: {
32
42
  multiSigUser: multi_sig_user.downcase,
33
43
  outerSigner: outer_signer.downcase,
34
- action: inner_action
44
+ action: payload_action(inner_action)
35
45
  }
36
46
  }
37
47
  end
38
48
 
49
+ # Normalize an inner action for the L1 payload. Currently only userSetAbstraction
50
+ # needs translation (long-form abstraction string → wire enum); all other actions
51
+ # pass through verbatim.
52
+ # @param inner_action [Hash] Inner action (symbol or string keys)
53
+ # @return [Hash] Possibly-normalized copy (or the original if no change needed)
54
+ def self.payload_action(inner_action)
55
+ return inner_action unless (inner_action[:type] || inner_action['type']) == 'userSetAbstraction'
56
+
57
+ key = inner_action.key?(:abstraction) ? :abstraction : 'abstraction'
58
+ abstraction = inner_action[key]
59
+ return inner_action unless USER_SET_ABSTRACTION_WIRE_VALUES.key?(abstraction)
60
+
61
+ inner_action.merge(key => USER_SET_ABSTRACTION_WIRE_VALUES[abstraction])
62
+ end
63
+
39
64
  # Compute the multiSigActionHash that the submitter signs over.
40
65
  # Mirrors Python's `sign_multi_sig_action`: action_hash(envelope - type, vault, nonce, expires).
41
66
  # @param envelope [Hash] The multi-sig envelope (will have :type stripped before hashing)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperliquid
4
- VERSION = '1.6.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.6.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