hyperliquid 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +36 -0
- data/CLAUDE.md +9 -7
- data/lib/hyperliquid/constants.rb +4 -0
- data/lib/hyperliquid/exchange.rb +49 -13
- data/lib/hyperliquid/info.rb +19 -2
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid/ws/client.rb +300 -8
- data/lib/hyperliquid.rb +2 -1
- data/scripts/test_20_explorer_ws.rb +92 -0
- data/scripts/test_all.rb +2 -1
- data/scripts/test_automated.rb +2 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a6d420027ac68a2d2959293fd9735798e80ad39f484dd2f43f1f808d5964944
|
|
4
|
+
data.tar.gz: 89c4e1688d7a5ac9d4cba3a214de798ec333161a0d6053abeac5d6d4da4916f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7ca76a9847766153f734661dd91f9d62bb1c87e2c43aacb4dd63e20904a1f3ddff505b4e8f92eb80b0bf6624e584560c121a901e2a1865617062b0e1cc1963c4
|
|
7
|
+
data.tar.gz: 43d32462a0350f7f733f50cd1190b6acfde8647b141665998bf5e1fb6f6b3fc24c348ba04dbb6ca35d3aef8e8bce0770445ab44991e65d566c58a9ce01c928c3
|
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/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.4.
|
|
1
|
+
3.4.10
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
## [Ruby Hyperliquid SDK Changelog]
|
|
2
2
|
|
|
3
|
+
## [1.9.0] - 2026-08-31
|
|
4
|
+
|
|
5
|
+
### New Exchange actions
|
|
6
|
+
|
|
7
|
+
- `Exchange#activate_outcome_deployer(is_deactivate:)` activates or deactivates a HIP-4 outcome deployer.
|
|
8
|
+
|
|
9
|
+
### New Info methods
|
|
10
|
+
|
|
11
|
+
- `Info#outcome_templates` retrieves HIP-4 prediction-market deployer templates.
|
|
12
|
+
- `Info#usdc_routing` retrieves the active USDC deposit and withdrawal routing configuration.
|
|
13
|
+
|
|
14
|
+
### Extended API support
|
|
15
|
+
|
|
16
|
+
- Added fast-cancel support to `Exchange#cancel`, `Exchange#cancel_by_cloid`, and `l2Book` WebSocket subscriptions.
|
|
17
|
+
- Added always-place support to individual `Exchange#modify_order` and `Exchange#batch_modify` entries.
|
|
18
|
+
- Added optional sub-account routing to `Exchange#usd_class_transfer`.
|
|
19
|
+
- Added optional trigger and stop-price details to `Exchange#twap_order`.
|
|
20
|
+
- Added optional destination routing to `Exchange#reserve_request_weight`.
|
|
21
|
+
- Added optional DEX selection to `Info#margin_table`.
|
|
22
|
+
|
|
23
|
+
### Tooling
|
|
24
|
+
|
|
25
|
+
- Added weekly Dependabot checks for Bundler and GitHub Actions dependencies.
|
|
26
|
+
- Updated the repository Ruby version to 3.4.10.
|
|
27
|
+
|
|
28
|
+
## [1.8.0] - 2026-06-16
|
|
29
|
+
|
|
30
|
+
### New WebSocket endpoints
|
|
31
|
+
|
|
32
|
+
- `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.
|
|
33
|
+
- `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.
|
|
34
|
+
|
|
35
|
+
### Fixes
|
|
36
|
+
|
|
37
|
+
- Wired `test_20_explorer_ws` into the automated integration suite (was previously only in `test_all.rb`).
|
|
38
|
+
|
|
3
39
|
## [1.7.0] - 2026-06-11
|
|
4
40
|
|
|
5
41
|
### New Exchange actions
|
data/CLAUDE.md
CHANGED
|
@@ -26,8 +26,8 @@ ruby example.rb # example usage script
|
|
|
26
26
|
Integration scripts live in `scripts/` as standalone files (`test_NN_<name>.rb`). They require a real testnet private key and hit the live testnet API.
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_all.rb # all
|
|
30
|
-
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_automated.rb # CI-friendly subset
|
|
29
|
+
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_all.rb # all 20
|
|
30
|
+
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_automated.rb # CI-friendly subset (14)
|
|
31
31
|
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_08_usd_class_transfer.rb # single
|
|
32
32
|
```
|
|
33
33
|
|
|
@@ -51,7 +51,9 @@ Hyperliquid.new(...)
|
|
|
51
51
|
- **Info path**: method builds `{ type: 'someType', ... }` body → `Client` POSTs to `/info` → parsed JSON returned.
|
|
52
52
|
- **Exchange path**: method builds action payload → `Signer` generates EIP-712 signature over msgpack-encoded action → `Client` POSTs signed payload to `/exchange` → parsed JSON returned.
|
|
53
53
|
- **Explorer RPC path** (`tx_details`, `user_details`): a separate base URL (`rpc.hyperliquid.xyz` / `rpc.hyperliquid-testnet.xyz`) with endpoint `/explorer`. `Client` holds a second Faraday connection for this, built lazily on first use; methods opt in via `client.post(EXPLORER_ENDPOINT, body, target: :explorer)`. The SDK wires this up automatically based on `testnet:`. Calling `target: :explorer` on a `Client` constructed without `explorer_base_url:` raises `ConfigurationError`. Don't add a public connection accessor — `target:` is the contract.
|
|
54
|
-
- **WebSocket path**: `WS::Client` manages
|
|
54
|
+
- **WebSocket path**: `WS::Client` manages two independent WebSocket connections:
|
|
55
|
+
- **Main API WS** (`wss://api.hyperliquid.xyz/ws`): subscribes to market data channels (`l2Book`, `trades`, `candle`, etc.). Messages arrive as `{channel, data}` envelopes; `compute_identifier` extracts routing keys (e.g. `l2Book:eth`, `candle:btc:1h`). Callbacks are dispatched via a bounded queue (1024, drops on overflow) on a dedicated thread.
|
|
56
|
+
- **Explorer WS** (`wss://rpc.hyperliquid.xyz/ws`): subscribes to block/transaction streams (`explorerBlock`, `explorerTxs`). Messages arrive as **bare arrays** (no envelope), so `identify_explorer_array` duck-types by field presence (`blockTime`/`height` for blocks, `action`/`hash` for txs). Uses a separate queue, dispatch thread, and subscription ID namespace to avoid cross-contamination with main-API WS. Both connections share the same 50s ping keepalive, automatic reconnection (exp backoff, 30s cap), and lifecycle hooks (`on(:open)`, `on(:close)`, `on(:error)`).
|
|
55
57
|
|
|
56
58
|
### Signing (Python SDK Parity)
|
|
57
59
|
|
|
@@ -79,19 +81,19 @@ 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
|
|
|
92
94
|
### CI
|
|
93
95
|
|
|
94
|
-
GitHub Actions (`.github/workflows/main.yml`): runs `bundle exec rake` (tests + lint) on the Ruby matrix defined in that workflow, for pushes to `main` and on all PRs. The release workflow creates GitHub releases from `CHANGELOG.md` on version tags.
|
|
96
|
+
GitHub Actions (`.github/workflows/main.yml`): runs `bundle exec rake` (tests + lint) on the Ruby matrix defined in that workflow, for pushes to `main` and on all PRs. The release workflow creates GitHub releases from `CHANGELOG.md` on version tags. Dependabot checks Bundler and GitHub Actions dependencies weekly with a 14-day default cooldown; Bundler major updates use a 30-day cooldown.
|
|
95
97
|
|
|
96
98
|
## Release Flow
|
|
97
99
|
|
|
@@ -21,6 +21,10 @@ module Hyperliquid
|
|
|
21
21
|
WS_PING_INTERVAL = 50 # seconds between pings
|
|
22
22
|
WS_MAX_QUEUE_SIZE = 1024 # max queued messages before dropping
|
|
23
23
|
|
|
24
|
+
# Explorer WebSocket URLs (used by subscribe_explorer_block / subscribe_explorer_txs)
|
|
25
|
+
MAINNET_EXPLORER_WS_URL = 'wss://rpc.hyperliquid.xyz/ws'
|
|
26
|
+
TESTNET_EXPLORER_WS_URL = 'wss://rpc.hyperliquid-testnet.xyz/ws'
|
|
27
|
+
|
|
24
28
|
# Request timeouts (seconds)
|
|
25
29
|
DEFAULT_TIMEOUT = 30
|
|
26
30
|
DEFAULT_READ_TIMEOUT = 30
|
data/lib/hyperliquid/exchange.rb
CHANGED
|
@@ -144,13 +144,17 @@ module Hyperliquid
|
|
|
144
144
|
# @param coin [String] Asset symbol
|
|
145
145
|
# @param oid [Integer] Order ID
|
|
146
146
|
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
147
|
+
# @param fast [Boolean, nil] Whether to use fast cancel (optional)
|
|
147
148
|
# @return [Hash] Cancel response
|
|
148
|
-
def cancel(coin:, oid:, vault_address: nil)
|
|
149
|
+
def cancel(coin:, oid:, vault_address: nil, fast: nil)
|
|
149
150
|
nonce = timestamp_ms
|
|
150
151
|
|
|
152
|
+
cancel_entry = { a: asset_index(coin), o: oid }
|
|
153
|
+
cancel_entry[:f] = true if fast
|
|
154
|
+
|
|
151
155
|
action = {
|
|
152
156
|
type: 'cancel',
|
|
153
|
-
cancels: [
|
|
157
|
+
cancels: [cancel_entry]
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
signature = @signer.sign_l1_action(
|
|
@@ -165,14 +169,18 @@ module Hyperliquid
|
|
|
165
169
|
# @param coin [String] Asset symbol
|
|
166
170
|
# @param cloid [Cloid, String] Client order ID
|
|
167
171
|
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
172
|
+
# @param fast [Boolean, nil] Whether to use fast cancel (optional)
|
|
168
173
|
# @return [Hash] Cancel response
|
|
169
|
-
def cancel_by_cloid(coin:, cloid:, vault_address: nil)
|
|
174
|
+
def cancel_by_cloid(coin:, cloid:, vault_address: nil, fast: nil)
|
|
170
175
|
nonce = timestamp_ms
|
|
171
176
|
cloid_raw = normalize_cloid(cloid)
|
|
172
177
|
|
|
178
|
+
cancel_entry = { asset: asset_index(coin), cloid: cloid_raw }
|
|
179
|
+
cancel_entry[:f] = true if fast
|
|
180
|
+
|
|
173
181
|
action = {
|
|
174
182
|
type: 'cancelByCloid',
|
|
175
|
-
cancels: [
|
|
183
|
+
cancels: [cancel_entry]
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
signature = @signer.sign_l1_action(
|
|
@@ -233,15 +241,17 @@ module Hyperliquid
|
|
|
233
241
|
# @param reduce_only [Boolean] Reduce-only flag (default: false)
|
|
234
242
|
# @param cloid [Cloid, String, nil] Client order ID for the modified order (optional)
|
|
235
243
|
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
244
|
+
# @param always_place [Boolean, nil] Always place the order even if cancel fails (optional)
|
|
236
245
|
# @return [Hash] Modify response
|
|
237
246
|
def modify_order(oid:, coin:, is_buy:, size:, limit_px:,
|
|
238
247
|
order_type: { limit: { tif: 'Gtc' } },
|
|
239
|
-
reduce_only: false, cloid: nil, vault_address: nil)
|
|
248
|
+
reduce_only: false, cloid: nil, vault_address: nil, always_place: nil)
|
|
240
249
|
batch_modify(
|
|
241
250
|
modifies: [{
|
|
242
251
|
oid: oid, coin: coin, is_buy: is_buy, size: size,
|
|
243
252
|
limit_px: limit_px, order_type: order_type,
|
|
244
|
-
reduce_only: reduce_only, cloid: cloid
|
|
253
|
+
reduce_only: reduce_only, cloid: cloid,
|
|
254
|
+
always_place: always_place
|
|
245
255
|
}],
|
|
246
256
|
vault_address: vault_address
|
|
247
257
|
)
|
|
@@ -249,7 +259,7 @@ module Hyperliquid
|
|
|
249
259
|
|
|
250
260
|
# Modify multiple orders at once
|
|
251
261
|
# @param modifies [Array<Hash>] Array of modify hashes with keys:
|
|
252
|
-
# :oid, :coin, :is_buy, :size, :limit_px, :order_type, :reduce_only, :cloid
|
|
262
|
+
# :oid, :coin, :is_buy, :size, :limit_px, :order_type, :reduce_only, :cloid, :always_place
|
|
253
263
|
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
254
264
|
# @return [Hash] Batch modify response
|
|
255
265
|
def batch_modify(modifies:, vault_address: nil)
|
|
@@ -265,7 +275,9 @@ module Hyperliquid
|
|
|
265
275
|
reduce_only: m[:reduce_only] || false,
|
|
266
276
|
cloid: m[:cloid]
|
|
267
277
|
)
|
|
268
|
-
{ oid: normalize_oid(m[:oid]), order: order_wire }
|
|
278
|
+
entry = { oid: normalize_oid(m[:oid]), order: order_wire }
|
|
279
|
+
entry[:a] = true if m[:always_place]
|
|
280
|
+
entry
|
|
269
281
|
end
|
|
270
282
|
|
|
271
283
|
action = {
|
|
@@ -420,19 +432,25 @@ module Hyperliquid
|
|
|
420
432
|
# Move USDC between perp and spot accounts
|
|
421
433
|
# @param amount [String, Numeric] Amount to transfer
|
|
422
434
|
# @param to_perp [Boolean] True to move to perp, false to move to spot
|
|
435
|
+
# @param sub_account [String, nil] Optional sub-account address (0x-prefixed hex).
|
|
436
|
+
# When provided, the transfer targets the sub-account rather than the
|
|
437
|
+
# master account; the suffix ` subaccount:<address>` is appended to the
|
|
438
|
+
# signed amount string (mirrors TS SDK v0.33.1's amount union shape).
|
|
423
439
|
# @return [Hash] Transfer response
|
|
424
|
-
def usd_class_transfer(amount:, to_perp:)
|
|
440
|
+
def usd_class_transfer(amount:, to_perp:, sub_account: nil)
|
|
425
441
|
nonce = timestamp_ms
|
|
442
|
+
amount_str = amount.to_s
|
|
443
|
+
amount_str = "#{amount_str} subaccount:#{sub_account}" if sub_account
|
|
426
444
|
action = {
|
|
427
445
|
type: 'usdClassTransfer',
|
|
428
446
|
signatureChainId: '0x66eee',
|
|
429
447
|
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
|
|
430
|
-
amount:
|
|
448
|
+
amount: amount_str,
|
|
431
449
|
toPerp: to_perp,
|
|
432
450
|
nonce: nonce
|
|
433
451
|
}
|
|
434
452
|
signature = @signer.sign_user_signed_action(
|
|
435
|
-
{ amount:
|
|
453
|
+
{ amount: amount_str, toPerp: to_perp, nonce: nonce },
|
|
436
454
|
'HyperliquidTransaction:UsdClassTransfer',
|
|
437
455
|
Signing::EIP712::USD_CLASS_TRANSFER_TYPES
|
|
438
456
|
)
|
|
@@ -1211,8 +1229,9 @@ module Hyperliquid
|
|
|
1211
1229
|
# @param minutes [Integer] TWAP duration in minutes (5..1440)
|
|
1212
1230
|
# @param randomize [Boolean] Randomize order timing
|
|
1213
1231
|
# @param vault_address [String, nil] Vault address if acting on behalf of a vault
|
|
1232
|
+
# @param details [Hash, nil] Optional trigger/stop price config: { t: { p:, a: } | nil, s: <UnsignedDecimal> | nil }
|
|
1214
1233
|
# @return [Hash] Exchange response — on success `response.data.status.running.twapId`
|
|
1215
|
-
def twap_order(coin:, is_buy:, size:, reduce_only:, minutes:, randomize:, vault_address: nil)
|
|
1234
|
+
def twap_order(coin:, is_buy:, size:, reduce_only:, minutes:, randomize:, vault_address: nil, details: nil)
|
|
1216
1235
|
nonce = timestamp_ms
|
|
1217
1236
|
action = {
|
|
1218
1237
|
type: 'twapOrder',
|
|
@@ -1225,6 +1244,7 @@ module Hyperliquid
|
|
|
1225
1244
|
t: randomize
|
|
1226
1245
|
}
|
|
1227
1246
|
}
|
|
1247
|
+
action[:details] = details unless details.nil?
|
|
1228
1248
|
signature = @signer.sign_l1_action(
|
|
1229
1249
|
action, nonce,
|
|
1230
1250
|
vault_address: vault_address,
|
|
@@ -1251,10 +1271,12 @@ module Hyperliquid
|
|
|
1251
1271
|
|
|
1252
1272
|
# Reserve additional rate-limited actions for a fee (`reserveRequestWeight` L1 action).
|
|
1253
1273
|
# @param weight [Integer] Amount of request weight to reserve
|
|
1274
|
+
# @param destination [String, nil] Address of an existing user to reserve the weight for; nil omits the field
|
|
1254
1275
|
# @return [Hash] Exchange response
|
|
1255
|
-
def reserve_request_weight(weight:)
|
|
1276
|
+
def reserve_request_weight(weight:, destination: nil)
|
|
1256
1277
|
nonce = timestamp_ms
|
|
1257
1278
|
action = { type: 'reserveRequestWeight', weight: weight }
|
|
1279
|
+
action[:destination] = destination unless destination.nil?
|
|
1258
1280
|
signature = @signer.sign_l1_action(
|
|
1259
1281
|
action, nonce,
|
|
1260
1282
|
expires_after: @expires_after
|
|
@@ -1338,6 +1360,20 @@ module Hyperliquid
|
|
|
1338
1360
|
post_action(action, signature, nonce, nil)
|
|
1339
1361
|
end
|
|
1340
1362
|
|
|
1363
|
+
# Activate or deactivate the signer as an outcome deployer
|
|
1364
|
+
# (`activateOutcomeDeployer` L1 action, HIP-4).
|
|
1365
|
+
# @param is_deactivate [Boolean] True to deactivate, false to activate
|
|
1366
|
+
# @return [Hash] Exchange response
|
|
1367
|
+
def activate_outcome_deployer(is_deactivate:)
|
|
1368
|
+
nonce = timestamp_ms
|
|
1369
|
+
action = { type: 'activateOutcomeDeployer', isDeactivate: is_deactivate }
|
|
1370
|
+
signature = @signer.sign_l1_action(
|
|
1371
|
+
action, nonce,
|
|
1372
|
+
expires_after: @expires_after
|
|
1373
|
+
)
|
|
1374
|
+
post_action(action, signature, nonce, nil)
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1341
1377
|
# Finalize the link between a HyperCore spot token and an ERC-20 contract on
|
|
1342
1378
|
# HyperEVM (`finalizeEvmContract` L1 action). `input` selects the verification method
|
|
1343
1379
|
# and is passed through verbatim — accepts a Hash `{ create: { nonce: <int> } }` for an
|
data/lib/hyperliquid/info.rb
CHANGED
|
@@ -375,9 +375,12 @@ module Hyperliquid
|
|
|
375
375
|
|
|
376
376
|
# Retrieve the margin requirements table for a given id
|
|
377
377
|
# @param id [Integer] Margin table id
|
|
378
|
+
# @param dex [String, nil] DEX name (empty string for main dex); nil omits the field
|
|
378
379
|
# @return [Hash] Keys: description (String), marginTiers (Array of { lowerBound, maxLeverage })
|
|
379
|
-
def margin_table(id)
|
|
380
|
-
|
|
380
|
+
def margin_table(id, dex: nil)
|
|
381
|
+
body = { type: 'marginTable', id: id }
|
|
382
|
+
body[:dex] = dex unless dex.nil?
|
|
383
|
+
@client.post(Constants::INFO_ENDPOINT, body)
|
|
381
384
|
end
|
|
382
385
|
|
|
383
386
|
# Retrieve the vaults a user is leading
|
|
@@ -522,6 +525,20 @@ module Hyperliquid
|
|
|
522
525
|
@client.post(Constants::INFO_ENDPOINT, { type: 'settledOutcome', outcome: outcome.to_i })
|
|
523
526
|
end
|
|
524
527
|
|
|
528
|
+
# Retrieve outcome templates for HIP-4 prediction market deployers
|
|
529
|
+
# @return [Array<Hash>] Array of templates, each with id, role (standaloneOutcome
|
|
530
|
+
# with sideNames), name, description (both with {keyword} placeholders), and
|
|
531
|
+
# keywords (array of [name, type] where type is "dateTime", "date", "string", or "hlPerp")
|
|
532
|
+
def outcome_templates
|
|
533
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'outcomeTemplates' })
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Retrieve USDC transfer routing configuration
|
|
537
|
+
# @return [Hash] Hash with depositRoute and withdrawalRoute, each "bridge" or "cctp"
|
|
538
|
+
def usdc_routing
|
|
539
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'usdcRouting' })
|
|
540
|
+
end
|
|
541
|
+
|
|
525
542
|
# Retrieve a user's funding history
|
|
526
543
|
# @param user [String]
|
|
527
544
|
# @param start_time [Integer]
|
data/lib/hyperliquid/version.rb
CHANGED
|
@@ -7,33 +7,58 @@ module Hyperliquid
|
|
|
7
7
|
module WS
|
|
8
8
|
# Managed WebSocket client for subscribing to real-time data channels
|
|
9
9
|
class Client
|
|
10
|
-
attr_reader :dropped_message_count
|
|
10
|
+
attr_reader :dropped_message_count, :explorer_dropped_message_count
|
|
11
11
|
|
|
12
|
-
def initialize(testnet: false, max_queue_size: Constants::WS_MAX_QUEUE_SIZE, reconnect: true
|
|
12
|
+
def initialize(testnet: false, max_queue_size: Constants::WS_MAX_QUEUE_SIZE, reconnect: true,
|
|
13
|
+
explorer_ws_url: nil)
|
|
13
14
|
base_url = testnet ? Constants::TESTNET_API_URL : Constants::MAINNET_API_URL
|
|
14
15
|
@url = base_url.sub(%r{^https?://}, 'wss://') + Constants::WS_ENDPOINT
|
|
15
16
|
@max_queue_size = max_queue_size
|
|
16
17
|
@reconnect_enabled = reconnect
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
|
|
20
|
+
init_main_ws_state
|
|
21
|
+
init_explorer_ws_state(explorer_ws_url)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
17
25
|
|
|
26
|
+
def init_main_ws_state
|
|
18
27
|
@subscriptions = {} # identifier => [{ id:, callback: }]
|
|
19
28
|
@subscription_msgs = {} # subscription_id => { subscription:, identifier: }
|
|
20
29
|
@next_id = 0
|
|
21
|
-
@mutex = Mutex.new
|
|
22
30
|
@queue = Queue.new
|
|
23
31
|
@dropped_message_count = 0
|
|
24
|
-
|
|
25
32
|
@ws = nil
|
|
26
33
|
@connected = false
|
|
27
34
|
@closing = false
|
|
28
35
|
@dispatch_thread = nil
|
|
29
36
|
@ping_thread = nil
|
|
30
37
|
@pending_subscriptions = []
|
|
31
|
-
|
|
32
38
|
@lifecycle_callbacks = {}
|
|
33
39
|
@reconnect_attempts = 0
|
|
34
40
|
@connection_id = 0
|
|
35
41
|
end
|
|
36
42
|
|
|
43
|
+
def init_explorer_ws_state(explorer_ws_url)
|
|
44
|
+
@explorer_ws_url = explorer_ws_url
|
|
45
|
+
@explorer_ws = nil
|
|
46
|
+
@explorer_connected = false
|
|
47
|
+
@explorer_closing = false
|
|
48
|
+
@explorer_connection_id = 0
|
|
49
|
+
@explorer_reconnect_attempts = 0
|
|
50
|
+
@explorer_subscriptions = {}
|
|
51
|
+
@explorer_subscription_msgs = {}
|
|
52
|
+
@explorer_next_id = 0
|
|
53
|
+
@explorer_queue = Queue.new
|
|
54
|
+
@explorer_dropped_message_count = 0
|
|
55
|
+
@explorer_dispatch_thread = nil
|
|
56
|
+
@explorer_ping_thread = nil
|
|
57
|
+
@explorer_pending_subscriptions = []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
public
|
|
61
|
+
|
|
37
62
|
def connect
|
|
38
63
|
@closing = false
|
|
39
64
|
@reconnect_attempts = 0
|
|
@@ -68,48 +93,88 @@ module Hyperliquid
|
|
|
68
93
|
sub_id
|
|
69
94
|
end
|
|
70
95
|
|
|
96
|
+
def subscribe_explorer_block(&)
|
|
97
|
+
raise ArgumentError, 'Block required for subscribe_explorer_block' unless block_given?
|
|
98
|
+
raise ConfigurationError, 'Explorer WebSocket URL not configured' unless @explorer_ws_url
|
|
99
|
+
|
|
100
|
+
subscribe_explorer({ type: 'explorerBlock' }, 'explorerBlock', &)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def subscribe_explorer_txs(&)
|
|
104
|
+
raise ArgumentError, 'Block required for subscribe_explorer_txs' unless block_given?
|
|
105
|
+
raise ConfigurationError, 'Explorer WebSocket URL not configured' unless @explorer_ws_url
|
|
106
|
+
|
|
107
|
+
subscribe_explorer({ type: 'explorerTxs' }, 'explorerTxs', &)
|
|
108
|
+
end
|
|
109
|
+
|
|
71
110
|
def unsubscribe(subscription_id)
|
|
72
111
|
sub_msg = nil
|
|
73
112
|
should_send = false
|
|
113
|
+
explorer = false
|
|
74
114
|
|
|
75
115
|
@mutex.synchronize do
|
|
76
116
|
sub_msg = @subscription_msgs.delete(subscription_id)
|
|
117
|
+
unless sub_msg
|
|
118
|
+
sub_msg = @explorer_subscription_msgs.delete(subscription_id)
|
|
119
|
+
explorer = true if sub_msg
|
|
120
|
+
end
|
|
77
121
|
return unless sub_msg
|
|
78
122
|
|
|
79
123
|
identifier = sub_msg[:identifier]
|
|
80
|
-
|
|
124
|
+
map = explorer ? @explorer_subscriptions : @subscriptions
|
|
125
|
+
callbacks = map[identifier]
|
|
81
126
|
return unless callbacks
|
|
82
127
|
|
|
83
128
|
callbacks.reject! { |entry| entry[:id] == subscription_id }
|
|
84
129
|
|
|
85
130
|
if callbacks.empty?
|
|
86
|
-
|
|
131
|
+
map.delete(identifier)
|
|
87
132
|
should_send = true
|
|
88
133
|
end
|
|
89
134
|
end
|
|
90
135
|
|
|
91
|
-
|
|
136
|
+
return unless should_send
|
|
137
|
+
|
|
138
|
+
if explorer
|
|
139
|
+
send_explorer_unsubscribe(sub_msg[:subscription]) if @explorer_connected
|
|
140
|
+
elsif @connected
|
|
141
|
+
send_unsubscribe(sub_msg[:subscription])
|
|
142
|
+
end
|
|
92
143
|
end
|
|
93
144
|
|
|
94
145
|
def close
|
|
95
146
|
@closing = true
|
|
96
147
|
@connected = false
|
|
148
|
+
@explorer_closing = true
|
|
149
|
+
@explorer_connected = false
|
|
97
150
|
|
|
98
151
|
@ping_thread&.kill
|
|
99
152
|
@ping_thread = nil
|
|
153
|
+
@explorer_ping_thread&.kill
|
|
154
|
+
@explorer_ping_thread = nil
|
|
100
155
|
|
|
101
156
|
@queue&.close if @queue.respond_to?(:close)
|
|
157
|
+
@explorer_queue&.close if @explorer_queue.respond_to?(:close)
|
|
158
|
+
|
|
102
159
|
@dispatch_thread&.join(5)
|
|
103
160
|
@dispatch_thread = nil
|
|
161
|
+
@explorer_dispatch_thread&.join(5)
|
|
162
|
+
@explorer_dispatch_thread = nil
|
|
104
163
|
|
|
105
164
|
@ws&.close
|
|
106
165
|
@ws = nil
|
|
166
|
+
@explorer_ws&.close
|
|
167
|
+
@explorer_ws = nil
|
|
107
168
|
end
|
|
108
169
|
|
|
109
170
|
def connected?
|
|
110
171
|
@connected
|
|
111
172
|
end
|
|
112
173
|
|
|
174
|
+
def explorer_connected?
|
|
175
|
+
@explorer_connected
|
|
176
|
+
end
|
|
177
|
+
|
|
113
178
|
def on(event, &callback)
|
|
114
179
|
@lifecycle_callbacks[event] = callback
|
|
115
180
|
end
|
|
@@ -335,6 +400,233 @@ module Hyperliquid
|
|
|
335
400
|
end
|
|
336
401
|
subs.each { |sub| send_subscribe(sub) }
|
|
337
402
|
end
|
|
403
|
+
|
|
404
|
+
# ── Explorer WebSocket ──────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
def subscribe_explorer(subscription, identifier, &callback)
|
|
407
|
+
sub_id = nil
|
|
408
|
+
|
|
409
|
+
@mutex.synchronize do
|
|
410
|
+
sub_id = @explorer_next_id
|
|
411
|
+
@explorer_next_id += 1
|
|
412
|
+
|
|
413
|
+
@explorer_subscriptions[identifier] ||= []
|
|
414
|
+
@explorer_subscriptions[identifier] << { id: sub_id, callback: callback }
|
|
415
|
+
@explorer_subscription_msgs[sub_id] = { subscription: subscription, identifier: identifier }
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
if @explorer_connected
|
|
419
|
+
send_explorer_subscribe(subscription)
|
|
420
|
+
else
|
|
421
|
+
@mutex.synchronize { @explorer_pending_subscriptions << subscription }
|
|
422
|
+
establish_explorer_connection unless @explorer_ws
|
|
423
|
+
start_explorer_dispatch_thread unless @explorer_dispatch_thread
|
|
424
|
+
start_explorer_ping_thread unless @explorer_ping_thread
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
sub_id
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def establish_explorer_connection
|
|
431
|
+
client = self
|
|
432
|
+
url = @explorer_ws_url
|
|
433
|
+
@explorer_connection_id += 1
|
|
434
|
+
active_id = @explorer_connection_id
|
|
435
|
+
|
|
436
|
+
@explorer_ws = ::WSLite.connect(url) do |ws|
|
|
437
|
+
ws.on :open do
|
|
438
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
439
|
+
|
|
440
|
+
client.send(:handle_explorer_open)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
ws.on :message do |msg|
|
|
444
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
445
|
+
|
|
446
|
+
client.send(:handle_explorer_message, msg.data)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
ws.on :error do |e|
|
|
450
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
451
|
+
|
|
452
|
+
client.send(:handle_explorer_error, e)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
ws.on :close do |e|
|
|
456
|
+
next if client.send(:stale_explorer_connection?, active_id)
|
|
457
|
+
|
|
458
|
+
client.send(:handle_explorer_close, e)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def stale_explorer_connection?(id)
|
|
464
|
+
id != @explorer_connection_id
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def handle_explorer_open
|
|
468
|
+
@explorer_connected = true
|
|
469
|
+
@explorer_reconnect_attempts = 0
|
|
470
|
+
flush_explorer_pending_subscriptions
|
|
471
|
+
replay_explorer_subscriptions
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def handle_explorer_message(raw)
|
|
475
|
+
return if raw.nil? || raw.empty?
|
|
476
|
+
return if raw.start_with?('Websocket connection established')
|
|
477
|
+
|
|
478
|
+
data = parse_json(raw)
|
|
479
|
+
return unless data
|
|
480
|
+
|
|
481
|
+
if data.is_a?(Hash)
|
|
482
|
+
return if data['channel'] == 'pong'
|
|
483
|
+
|
|
484
|
+
return
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
unless data.is_a?(Array)
|
|
488
|
+
warn '[Hyperliquid::WS] Unknown explorer WS message shape'
|
|
489
|
+
return
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
return if data.empty?
|
|
493
|
+
|
|
494
|
+
identifier = identify_explorer_array(data)
|
|
495
|
+
return unless identifier
|
|
496
|
+
|
|
497
|
+
enqueue_explorer_message(identifier, data)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def identify_explorer_array(data)
|
|
501
|
+
first = data.first
|
|
502
|
+
return unless first.is_a?(Hash)
|
|
503
|
+
|
|
504
|
+
if first.key?('blockTime') && first.key?('hash') && first.key?('height') &&
|
|
505
|
+
first.key?('numTxs') && first.key?('proposer')
|
|
506
|
+
return 'explorerBlock'
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
if first.key?('action') && first.key?('block') && first.key?('error') &&
|
|
510
|
+
first.key?('hash') && first.key?('time') && first.key?('user')
|
|
511
|
+
return 'explorerTxs'
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
warn '[Hyperliquid::WS] Unknown explorer WS array shape'
|
|
515
|
+
nil
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def handle_explorer_error(error)
|
|
519
|
+
warn "[Hyperliquid::WS] Explorer WS error: #{error.message}"
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def handle_explorer_close(_event)
|
|
523
|
+
was_connected = @explorer_connected
|
|
524
|
+
@explorer_connected = false
|
|
525
|
+
|
|
526
|
+
attempt_explorer_reconnect if was_connected && !@explorer_closing && @reconnect_enabled
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def enqueue_explorer_message(identifier, data)
|
|
530
|
+
@mutex.synchronize do
|
|
531
|
+
if @explorer_queue.size >= @max_queue_size
|
|
532
|
+
@explorer_dropped_message_count += 1
|
|
533
|
+
if @explorer_dropped_message_count == 1 || (@explorer_dropped_message_count % 100).zero?
|
|
534
|
+
warn "[Hyperliquid::WS] Explorer queue full (#{@max_queue_size}). " \
|
|
535
|
+
"Dropped #{@explorer_dropped_message_count} message(s)."
|
|
536
|
+
end
|
|
537
|
+
return
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
@explorer_queue.push({ identifier: identifier, data: data })
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def start_explorer_dispatch_thread
|
|
544
|
+
@explorer_dispatch_thread = Thread.new do
|
|
545
|
+
loop do
|
|
546
|
+
msg = begin
|
|
547
|
+
@explorer_queue.pop
|
|
548
|
+
rescue ClosedQueueError
|
|
549
|
+
break
|
|
550
|
+
end
|
|
551
|
+
break if msg.nil?
|
|
552
|
+
|
|
553
|
+
callbacks = @mutex.synchronize { @explorer_subscriptions[msg[:identifier]]&.dup }
|
|
554
|
+
next unless callbacks
|
|
555
|
+
|
|
556
|
+
callbacks.each do |entry|
|
|
557
|
+
entry[:callback].call(msg[:data])
|
|
558
|
+
rescue StandardError => e
|
|
559
|
+
warn "[Hyperliquid::WS] Explorer callback error: #{e.message}"
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
@explorer_dispatch_thread.name = 'hl-explorer-ws-dispatch'
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def start_explorer_ping_thread
|
|
567
|
+
@explorer_ping_thread = Thread.new do
|
|
568
|
+
loop do
|
|
569
|
+
sleep Constants::WS_PING_INTERVAL
|
|
570
|
+
break if @explorer_closing
|
|
571
|
+
|
|
572
|
+
send_explorer_json({ method: 'ping' }) if @explorer_connected
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
@explorer_ping_thread.name = 'hl-explorer-ws-ping'
|
|
576
|
+
@explorer_ping_thread.report_on_exception = false
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def flush_explorer_pending_subscriptions
|
|
580
|
+
pending = @mutex.synchronize do
|
|
581
|
+
subs = @explorer_pending_subscriptions.dup
|
|
582
|
+
@explorer_pending_subscriptions.clear
|
|
583
|
+
subs
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
pending.each { |sub| send_explorer_subscribe(sub) }
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def send_explorer_subscribe(subscription)
|
|
590
|
+
send_explorer_json({ method: 'subscribe', subscription: subscription })
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def send_explorer_unsubscribe(subscription)
|
|
594
|
+
send_explorer_json({ method: 'unsubscribe', subscription: subscription })
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def send_explorer_json(hash)
|
|
598
|
+
@explorer_ws&.send(JSON.generate(hash))
|
|
599
|
+
rescue StandardError => e
|
|
600
|
+
warn "[Hyperliquid::WS] Explorer send error: #{e.message}"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def attempt_explorer_reconnect
|
|
604
|
+
Thread.new do
|
|
605
|
+
loop do
|
|
606
|
+
break if @explorer_closing
|
|
607
|
+
|
|
608
|
+
delay = [2**@explorer_reconnect_attempts, 30].min
|
|
609
|
+
@explorer_reconnect_attempts += 1
|
|
610
|
+
sleep delay
|
|
611
|
+
|
|
612
|
+
break if @explorer_closing
|
|
613
|
+
|
|
614
|
+
begin
|
|
615
|
+
establish_explorer_connection
|
|
616
|
+
break
|
|
617
|
+
rescue StandardError => e
|
|
618
|
+
warn "[Hyperliquid::WS] Explorer reconnect failed: #{e.message}"
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def replay_explorer_subscriptions
|
|
625
|
+
subs = @mutex.synchronize do
|
|
626
|
+
@explorer_subscription_msgs.values.map { |v| v[:subscription] }.uniq
|
|
627
|
+
end
|
|
628
|
+
subs.each { |sub| send_explorer_subscribe(sub) }
|
|
629
|
+
end
|
|
338
630
|
end
|
|
339
631
|
end
|
|
340
632
|
end
|
data/lib/hyperliquid.rb
CHANGED
|
@@ -58,7 +58,8 @@ module Hyperliquid
|
|
|
58
58
|
@info = Info.new(client)
|
|
59
59
|
@testnet = testnet
|
|
60
60
|
@exchange = nil
|
|
61
|
-
|
|
61
|
+
explorer_ws_url = testnet ? Constants::TESTNET_EXPLORER_WS_URL : Constants::MAINNET_EXPLORER_WS_URL
|
|
62
|
+
@ws = WS::Client.new(testnet: testnet, explorer_ws_url: explorer_ws_url)
|
|
62
63
|
|
|
63
64
|
return unless private_key
|
|
64
65
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Integration test for Explorer WebSocket subscription
|
|
5
|
+
# Connects to testnet explorer WS, subscribes to explorerBlock, and collects 3 block events
|
|
6
|
+
#
|
|
7
|
+
# Usage: ruby test_20_explorer_ws.rb
|
|
8
|
+
|
|
9
|
+
require_relative 'test_helpers'
|
|
10
|
+
|
|
11
|
+
puts '=' * 70
|
|
12
|
+
puts 'test_20: Explorer WebSocket subscription'
|
|
13
|
+
puts '=' * 70
|
|
14
|
+
|
|
15
|
+
# Initialize SDK with testnet
|
|
16
|
+
sdk = Hyperliquid::SDK.new(testnet: true)
|
|
17
|
+
|
|
18
|
+
puts "\n1. Testing explorer WebSocket connection and subscription..."
|
|
19
|
+
|
|
20
|
+
blocks_received = []
|
|
21
|
+
max_blocks = 3
|
|
22
|
+
timeout_seconds = 60
|
|
23
|
+
start_time = Time.now
|
|
24
|
+
|
|
25
|
+
puts " Subscribing to explorerBlock channel (expecting #{max_blocks} blocks within #{timeout_seconds}s)..."
|
|
26
|
+
puts " Explorer WS URL: wss://rpc.hyperliquid-testnet.xyz/ws"
|
|
27
|
+
|
|
28
|
+
# Subscribe to explorer blocks
|
|
29
|
+
sub_id = sdk.ws.subscribe_explorer_block do |block_array|
|
|
30
|
+
# Explorer messages arrive as arrays
|
|
31
|
+
block_array.each do |block|
|
|
32
|
+
blocks_received << block
|
|
33
|
+
elapsed = Time.now - start_time
|
|
34
|
+
puts " Block ##{blocks_received.size}: height=#{block['height']} hash=#{block['hash']} " \
|
|
35
|
+
"numTxs=#{block['numTxs']} proposer=#{block['proposer'][0..10]}... (#{elapsed.round(1)}s elapsed)"
|
|
36
|
+
|
|
37
|
+
break if blocks_received.size >= max_blocks
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
puts " Subscription ID: #{sub_id}"
|
|
42
|
+
|
|
43
|
+
# Wait for blocks with timeout
|
|
44
|
+
puts "\n2. Waiting for block events..."
|
|
45
|
+
loop do
|
|
46
|
+
break if blocks_received.size >= max_blocks
|
|
47
|
+
|
|
48
|
+
elapsed = Time.now - start_time
|
|
49
|
+
if elapsed > timeout_seconds
|
|
50
|
+
puts "\n ⚠ Timeout: Only received #{blocks_received.size}/#{max_blocks} blocks after #{timeout_seconds}s"
|
|
51
|
+
puts ' This may indicate testnet explorer is not producing blocks or WS connection issues'
|
|
52
|
+
break
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sleep 0.5
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Cleanup
|
|
59
|
+
puts "\n3. Cleanup..."
|
|
60
|
+
sdk.ws.unsubscribe(sub_id)
|
|
61
|
+
puts " Unsubscribed from #{sub_id}"
|
|
62
|
+
|
|
63
|
+
sdk.ws.close
|
|
64
|
+
puts ' WebSocket connection closed'
|
|
65
|
+
|
|
66
|
+
# Verify results
|
|
67
|
+
puts "\n4. Verification..."
|
|
68
|
+
if blocks_received.empty?
|
|
69
|
+
puts ' ❌ FAIL: No blocks received'
|
|
70
|
+
puts ' Explorer WebSocket may not be working on testnet, or connection failed'
|
|
71
|
+
exit 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
puts " ✓ Received #{blocks_received.size} block(s)"
|
|
75
|
+
|
|
76
|
+
# Verify block structure
|
|
77
|
+
blocks_received.each_with_index do |block, idx|
|
|
78
|
+
required_fields = %w[height hash numTxs proposer blockTime]
|
|
79
|
+
missing = required_fields - block.keys
|
|
80
|
+
unless missing.empty?
|
|
81
|
+
puts " ⚠ Block ##{idx + 1} missing fields: #{missing.join(', ')}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if blocks_received.size >= max_blocks
|
|
86
|
+
puts " ✓ PASS: Successfully received #{max_blocks} explorer blocks"
|
|
87
|
+
exit 0
|
|
88
|
+
else
|
|
89
|
+
puts " ⚠ PARTIAL: Received #{blocks_received.size}/#{max_blocks} blocks"
|
|
90
|
+
puts ' This is acceptable if testnet explorer is slow, but should be investigated'
|
|
91
|
+
exit 0 # Don't fail the test suite for partial results
|
|
92
|
+
end
|
data/scripts/test_all.rb
CHANGED
data/scripts/test_automated.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hyperliquid
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.9.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
|