hyperliquid 1.9.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/docs/API.md +374 -152
  4. data/docs/CONFIGURATION.md +25 -11
  5. data/docs/DEVELOPMENT.md +93 -15
  6. data/docs/ERRORS.md +2 -0
  7. data/docs/EXAMPLES.md +256 -3
  8. data/docs/WS.md +78 -20
  9. data/lib/hyperliquid/client.rb +19 -10
  10. data/lib/hyperliquid/exchange.rb +844 -22
  11. data/lib/hyperliquid/info.rb +27 -14
  12. data/lib/hyperliquid/signing/signer.rb +18 -1
  13. data/lib/hyperliquid/version.rb +1 -1
  14. data/lib/hyperliquid/ws/client.rb +192 -78
  15. data/lib/hyperliquid.rb +6 -0
  16. metadata +36 -34
  17. data/.rspec +0 -3
  18. data/.rubocop.yml +0 -49
  19. data/.ruby-version +0 -1
  20. data/CLAUDE.md +0 -108
  21. data/CODE_OF_CONDUCT.md +0 -132
  22. data/Rakefile +0 -12
  23. data/example.rb +0 -161
  24. data/scripts/test_01_spot_market_roundtrip.rb +0 -48
  25. data/scripts/test_02_spot_limit_order.rb +0 -48
  26. data/scripts/test_03_perp_market_roundtrip.rb +0 -58
  27. data/scripts/test_04_perp_limit_order.rb +0 -52
  28. data/scripts/test_05_update_leverage.rb +0 -50
  29. data/scripts/test_06_modify_order.rb +0 -67
  30. data/scripts/test_07_market_close.rb +0 -55
  31. data/scripts/test_08_usd_class_transfer.rb +0 -36
  32. data/scripts/test_09_sub_account_lifecycle.rb +0 -51
  33. data/scripts/test_10_vault.rb +0 -68
  34. data/scripts/test_11_builder_fee.rb +0 -81
  35. data/scripts/test_12_staking.rb +0 -70
  36. data/scripts/test_13_ws_l2_book.rb +0 -83
  37. data/scripts/test_14_ws_candle.rb +0 -81
  38. data/scripts/test_15_explorer.rb +0 -71
  39. data/scripts/test_16_send_to_evm_with_data.rb +0 -79
  40. data/scripts/test_17_create_vault.rb +0 -73
  41. data/scripts/test_18_user_portfolio_margin.rb +0 -72
  42. data/scripts/test_19_spot_user.rb +0 -55
  43. data/scripts/test_20_explorer_ws.rb +0 -92
  44. data/scripts/test_all.rb +0 -96
  45. data/scripts/test_automated.rb +0 -82
  46. data/scripts/test_helpers.rb +0 -238
  47. data/sig/hyperliquid.rbs +0 -4
  48. data/test_integration.rb +0 -17
@@ -3,10 +3,11 @@
3
3
  ## Basic Options
4
4
 
5
5
  ```ruby
6
- # Custom timeout (default: 30 seconds)
6
+ # Custom timeout in seconds (default: 30), applied separately to connecting,
7
+ # sending the request and waiting for the response
7
8
  sdk = Hyperliquid.new(timeout: 60)
8
9
 
9
- # Enable retry logic for handling transient failures (default: disabled)
10
+ # Retry transient failures of read requests (default: disabled; /exchange is never retried)
10
11
  sdk = Hyperliquid.new(retry_enabled: true)
11
12
 
12
13
  # Enable trading with a private key
@@ -36,18 +37,31 @@ sdk.base_url # => "https://api.hyperliquid.xyz"
36
37
  sdk.exchange # => nil if no private_key, Hyperliquid::Exchange instance otherwise
37
38
  ```
38
39
 
40
+ ## Timeout
41
+
42
+ `timeout:` (seconds, default 30) is used for every phase of a request: opening the connection, writing the request and reading the response. Each phase can take up to that long, so a slow response raises `Hyperliquid::TimeoutError` once no data has arrived for `timeout` seconds.
43
+
39
44
  ## Retry Configuration
40
45
 
41
- By default, retry logic is **disabled** for predictable API behavior. When enabled, the SDK will automatically retry requests that fail due to:
46
+ Retries are **disabled** by default. With `retry_enabled: true`, only read requests are retried:
47
+
48
+ - `POST /info`: every `sdk.info` method except `tx_details` and `user_details`
49
+ - `POST /explorer` on the RPC host: `sdk.info.tx_details` and `sdk.info.user_details`
50
+
51
+ **`POST /exchange` is never retried**, whatever the failure. Resending a signed action could execute it twice (for example, place the same order twice), so a failed exchange request always raises after exactly one attempt. Check the result (for example with `open_orders` or `order_status`) before you send the action again.
42
52
 
43
- - Network connectivity issues (connection failed, timeouts)
44
- - Server errors (5xx status codes)
45
- - Rate limiting (429 status codes)
53
+ A read request is retried when:
54
+
55
+ - the response status is 429, 502, 503 or 504
56
+ - the connection fails (`Faraday::ConnectionFailed`) or times out (`Faraday::TimeoutError`)
57
+
58
+ Other statuses (400, 401, 404, 500, …) are not retried and raise their error class immediately.
46
59
 
47
60
  **Retry Settings:**
48
- - Maximum retries: 2
49
- - Base interval: 0.5 seconds
50
- - Backoff factor: 2x (exponential backoff)
51
- - Randomness: ±50% to prevent thundering herd
61
+ - Maximum retries: 2 (at most 3 attempts per read request)
62
+ - Wait before retry *n*: 0.5 s × 2^(n−1), plus a random 0–0.25 s (0.5–0.75 s, then 1.0–1.25 s)
63
+ - A `Retry-After` or `RateLimit-Reset` response header that asks for a longer wait is honoured
64
+
65
+ When the last attempt still fails, the SDK raises the same error a single failure would raise (`RateLimitError` for 429, `ServerError` for 5xx, `NetworkError` or `TimeoutError` for network failures).
52
66
 
53
- **Note:** Retries are disabled by default to avoid unexpected delays in time-sensitive trading applications. Enable only when you want automatic handling of transient failures.
67
+ **Note:** Retries are disabled by default to avoid unexpected delays in time-sensitive trading applications. Enable them only if you want transient read failures handled automatically.
data/docs/DEVELOPMENT.md CHANGED
@@ -11,46 +11,124 @@ bin/setup
11
11
  ## Running Tests
12
12
 
13
13
  ```bash
14
- # Run all unit tests
14
+ # Default gate (what CI runs): unit tests, RuboCop, and Lint+Security on scripts/ and example.rb
15
+ rake
16
+
17
+ # Unit tests only (random order; reproduce a failure with its printed seed)
15
18
  rake spec
19
+ bundle exec rspec --seed 1234
16
20
 
17
- # Run unit tests and linting together (default)
18
- rake
21
+ # Default gate again inside a fresh clone of HEAD with a frozen bundle (run after committing)
22
+ rake verify
23
+
24
+ # Exit 0 and print DOCS_ONLY=yes iff every uncommitted change is docs-only
25
+ rake verify:docs_only
26
+
27
+ # Regenerate the Python-SDK parity fixtures and diff them against spec/fixtures/parity (python3 + network)
28
+ rake parity:check
29
+
30
+ # Run the Ruby 3.3 CI leg locally
31
+ RBENV_VERSION=3.3.12 bundle exec rake
19
32
  ```
20
33
 
34
+ A change is done when `rake` is green, `rake verify` is green on the commit, and `rake integration` reports `INTEGRATION GATE: PASS`. You can skip `rake integration` only when `rake verify:docs_only` prints `DOCS_ONLY=yes`. Unit and lint failures are never waived. See `CLAUDE.md` → Verification.
35
+
21
36
  ### Integration Testing (Testnet)
22
37
 
23
38
  Integration tests live in `scripts/` and execute real trades on testnet. No real funds are at risk. Get testnet funds from https://app.hyperliquid-testnet.xyz.
24
39
 
25
40
  ```bash
26
- # Run all integration tests
27
- HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_all.rb
41
+ # Run the integration gate (exits 2 when HYPERLIQUID_PRIVATE_KEY is unset)
42
+ HYPERLIQUID_PRIVATE_KEY=0x... rake integration
43
+
44
+ # Same runner without rake
45
+ HYPERLIQUID_PRIVATE_KEY=0x... ruby -rbundler/setup scripts/test_all.rb
28
46
 
29
47
  # Run a single integration test
30
- HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_08_usd_class_transfer.rb
48
+ HYPERLIQUID_PRIVATE_KEY=0x... ruby -rbundler/setup scripts/test_08_usd_class_transfer.rb
49
+
50
+ # Read-only wallet precondition check (the runner's pre-flight and post-flight)
51
+ HYPERLIQUID_PRIVATE_KEY=0x... ruby -rbundler/setup scripts/testnet_wallet_check.rb --assert
52
+
53
+ # Switch to standard abstraction and rebalance (manual only, never from a runner)
54
+ HYPERLIQUID_PRIVATE_KEY=0x... ruby -rbundler/setup scripts/testnet_wallet_check.rb --fix
55
+ ```
56
+
57
+ `scripts/test_all.rb` is the only runner.
58
+ - **Discovery.** It runs every `scripts/test_[0-9][0-9]_*.rb` it finds by glob, in sorted order, so a new script is picked up automatically. Deliberate exclusions go in its `OPT_OUT` hash, with a reason.
59
+ - **Lock.** It holds a `flock` on `tmp/integration.lock`, so only one gate runs at a time.
60
+ - **Wallet checks.** It runs `testnet_wallet_check.rb --assert` before the first script and after the last. A violation fails the gate as `precondition` or `leak`.
61
+ - **Process.** Each script runs under `ruby -rbundler/setup`, against the locked gems, in its own process group. The private key is redacted from the output.
62
+ - **Timeouts.** Each script has a timeout of `HL_SCRIPT_TIMEOUT` seconds (default 300) and is then killed (TIMEOUT). The whole run has a budget of `HL_GATE_BUDGET` seconds (default 2400); scripts left when it runs out are NOT_RUN.
63
+
64
+ Each script reports its result through its exit code:
65
+
66
+ | Status | Exit | Meaning | Blocks the gate |
67
+ |--------|------|---------|-----------------|
68
+ | PASS | 0 | Verified (for a rejection wire check: the pinned rejection arrived) | no |
69
+ | FAIL | 1 (or any other unlisted code) | Something is wrong | yes |
70
+ | INCONCLUSIVE | 75 | The environment gave no evidence either way | no (yes with `HL_GATE_STRICT=1`) |
71
+ | SKIPPED | 77 | The script deliberately tested nothing | no (yes with `HL_GATE_STRICT=1`) |
72
+ | GUARDED | 78 | Wallet/testnet state forced a weaker check that still proves signing | no (yes with `HL_GATE_STRICT=1`) |
73
+ | TIMEOUT | — | Exceeded `HL_SCRIPT_TIMEOUT` and was killed | yes |
74
+ | NOT_RUN | — | `HL_GATE_BUDGET` ran out before the script started | yes |
75
+
76
+ The runner's last stdout line is always:
77
+
78
+ ```text
79
+ INTEGRATION GATE: PASS|FAIL total=N pass=a guarded=b skipped=c inconclusive=d fail=e timeout=f not_run=g
31
80
  ```
32
81
 
33
- The convenience wrapper `ruby test_integration.rb` also runs all tests.
82
+ A JSON summary with per-script status, exit code, duration and `RESULT` line goes to `HL_GATE_SUMMARY`, default `tmp/integration-summary.json`. Report every INCONCLUSIVE, SKIPPED and GUARDED script by name.
83
+
84
+ Every script is safe to run unattended in its default mode, and a new script must be too: read-only, or rejection-only with a fail-closed guard. Destructive or locking variants are opt-in CLI args on individual scripts and are never run by the runner:
85
+
86
+ ```bash
87
+ ruby -rbundler/setup scripts/test_10_vault.rb deposit|withdraw # move $10 into / out of the test vault
88
+ ruby -rbundler/setup scripts/test_12_staking.rb delegate|undelegate # 0.1 HYPE, 1-day delegation lockup
89
+ ruby -rbundler/setup scripts/test_16_send_to_evm_with_data.rb live # burns 1 testnet USDC
90
+ ruby -rbundler/setup scripts/test_17_create_vault.rb live # locks $100 in a new vault
91
+ ```
34
92
 
35
- Available test scripts:
93
+ Available test scripts (every `scripts/test_NN_*.rb` has exactly one row here; `spec/call_surface_spec.rb` enforces it):
36
94
 
37
95
  | Script | Description |
38
96
  |--------|-------------|
39
97
  | `test_01_spot_market_roundtrip.rb` | Buy/sell PURR/USDC at market |
40
- | `test_02_spot_limit_order.rb` | Place and cancel a spot limit order |
98
+ | `test_02_spot_limit_order.rb` | Place a resting spot limit buy below market and cancel it |
41
99
  | `test_03_perp_market_roundtrip.rb` | Long/close BTC at market |
42
- | `test_04_perp_limit_order.rb` | Place and cancel a perp short |
43
- | `test_05_update_leverage.rb` | Set cross, isolated, and reset leverage |
100
+ | `test_04_perp_limit_order.rb` | Place a resting perp limit short above market and cancel it |
101
+ | `test_05_update_leverage.rb` | Set cross, isolated, and reset BTC leverage (requires no BTC position or orders) |
44
102
  | `test_06_modify_order.rb` | Place, modify, and cancel an order |
45
103
  | `test_07_market_close.rb` | Open a position and close via `market_close` |
46
- | `test_08_usd_class_transfer.rb` | Transfer USDC between perp and spot |
47
- | `test_09_sub_account_lifecycle.rb` | Create sub-account, deposit, withdraw |
48
- | `test_10_vault_transfer.rb` | Deposit/withdraw to a vault |
104
+ | `test_08_usd_class_transfer.rb` | Transfer USDC perp → spot → perp (GUARDED rejection wire check on a unified wallet) |
105
+ | `test_09_sub_account_lifecycle.rb` | Create (or reuse) a sub-account, deposit, withdraw (volume-gate rejection is a wire check) |
106
+ | `test_10_vault.rb` | Vault status (`deposit`/`withdraw` opt-in) |
107
+ | `test_11_builder_fee.rb` | Approve a builder fee, place an order with a builder, cancel (GUARDED if the builder is ineligible) |
108
+ | `test_12_staking.rb` | Staking status and undelegate rejection wire check (`delegate`/`undelegate` opt-in) |
109
+ | `test_13_ws_l2_book.rb` | WebSocket `l2Book` subscription (top of book; no key) |
110
+ | `test_14_ws_candle.rb` | WebSocket `candle` subscriptions with coin/interval routing checks (INCONCLUSIVE when testnet ETH has no trades) |
111
+ | `test_15_explorer.rb` | Explorer RPC `user_details` and `tx_details` (SKIPPED when the wallet has no txs) |
112
+ | `test_16_send_to_evm_with_data.rb` | Send USDC to HyperEVM with calldata (default: rejection wire check; `live` sends 1 USDC) |
113
+ | `test_17_create_vault.rb` | Create a vault (default: rejection wire check; `live` seeds a real vault with $100) |
114
+ | `test_18_user_portfolio_margin.rb` | Portfolio-margin toggle (rejection wire check; restores the original mode) |
115
+ | `test_19_spot_user.rb` | Opt out of then back into spot dusting |
116
+ | `test_20_explorer_ws.rb` | Explorer WebSocket block stream (3 blocks; INCONCLUSIVE on 1–2) |
117
+ | `test_21_ws_channel_parity.rb` | Subscribe to the parity WS channels (read-only) and check each delivers its own user/dex/coin |
118
+ | `test_22_outcome_deploy.rb` | HIP-4 outcome deployer actions (structured-rejection wire check) |
119
+ | `test_23_ws_fast_asset_ctxs.rb` | Subscribe to the compressed `fastAssetCtxs` channel (read-only) and check the decoded snapshot + deltas |
120
+ | `test_24_perp_deploy_wire.rb` | Send `perpDeploy` actions to a nonexistent dex; expect a structured rejection (proves signing) |
121
+ | `test_25_hip3_star.rb` | Read `user_star_state`; send a HIP-3* `star` action to a live star dex; expect a structured rejection (proves signing; SKIPPED when no star dex exists) |
122
+ | `test_26_spot_deploy_rejection.rb` | spotDeploy structured-rejection wire check on token 0 with a throwaway-key control (no state change) |
123
+ | `test_27_validator_actions.rb` | Validator-operator actions (`validator_l1_stream`, `c_signer_*`, `c_validator_change_profile`, `c_validator_unregister`) — rejection-only wire check behind a fail-closed non-validator safety guard; `c_validator_register` is never sent |
124
+
125
+ Each rejection wire check (`test_09`, `test_12`, `test_16`–`test_18`, `test_22`, `test_24`–`test_27`) also sends the same call with a never-funded throwaway key. It passes only when the wallet's rejection differs from that control's "does not exist" / "must deposit" rejection, which proves the server recovered this wallet as the signer.
49
126
 
50
127
  ## Linting
51
128
 
52
129
  ```bash
53
- rake rubocop
130
+ rake rubocop # lib/, spec/, config files
131
+ rake rubocop:scripts # Lint + Security departments only, on scripts/ and example.rb
54
132
  ```
55
133
 
56
134
  ## Interactive Console
data/docs/ERRORS.md CHANGED
@@ -36,3 +36,5 @@ end
36
36
  | `Hyperliquid::RateLimitError` | 429 errors |
37
37
  | `Hyperliquid::NetworkError` | Connection issues |
38
38
  | `Hyperliquid::TimeoutError` | Request timeouts |
39
+ | `Hyperliquid::WebSocketError` | Unsupported WebSocket subscription type |
40
+ | `Hyperliquid::ConfigurationError` | Missing configuration, e.g. explorer endpoint not set |
data/docs/EXAMPLES.md CHANGED
@@ -78,7 +78,9 @@ twap_fills_ranged = sdk.info.user_twap_slice_fills(user_address, start_time_ms,
78
78
 
79
79
  # Retrieve a user's subaccounts
80
80
  subaccounts = sdk.info.user_subaccounts(user_address)
81
- # => ["0x1111...", ...]
81
+ # => [{ "name" => "sub1", "subAccountUser" => "0x2222...", "master" => "0x...",
82
+ # "clearinghouseState" => {...}, "spotState" => {...} }, ...]
83
+ # => nil when the user has no subaccounts
82
84
 
83
85
  # Retrieve details for a vault
84
86
  vault_addr = "0x..."
@@ -134,6 +136,20 @@ signers = sdk.info.user_to_multi_sig_signers(user_address)
134
136
  # Get dex abstraction config for a user
135
137
  dex_abstraction = sdk.info.user_dex_abstraction(user_address)
136
138
  # => { "enabled" => true }
139
+
140
+ # Fills in a time range, newest first, with partial fills of one crossing order merged
141
+ fills = sdk.info.user_fills_by_time(user_address, start_time_ms, nil, aggregate_by_time: true, reversed: true)
142
+
143
+ # Recent trades and a block by height
144
+ sdk.info.recent_trades('BTC')
145
+ sdk.info.block_details(123_456)
146
+
147
+ # Vaults created in the last 2 hours, and vaults a user leads
148
+ sdk.info.vault_summaries
149
+ sdk.info.leading_vaults(user_address)
150
+
151
+ # Margin table (dex: '' is the main dex)
152
+ sdk.info.margin_table(1, dex: '')
137
153
  ```
138
154
 
139
155
  **Note:** `l2_book` and `candles_snapshot` work for both Perpetuals and Spot. For spot, use `"{BASE}/USDC"` when available (e.g., `"PURR/USDC"`). Otherwise, use the index alias `"@{index}"` from `spot_meta["universe"]`.
@@ -183,6 +199,29 @@ oi_capped = sdk.info.perps_at_open_interest_cap
183
199
  auction = sdk.info.perp_deploy_auction_status
184
200
  # => { "startTimeSeconds" => ..., "durationSeconds" => ..., "startGas" => "500.0", ... }
185
201
 
202
+ # Deploy a HIP-3 perp dex (deployer wallet only; mirrors the Python SDK's examples/perp_deploy.py).
203
+ # The first registration on a new dex passes `schema:` and pays the deploy-auction gas.
204
+ # `auction` comes from perp_deploy_auction_status above; `max_gas: nil` bids its currentGas.
205
+ sdk.exchange.perp_deploy_register_asset2(
206
+ dex: "test", coin: "test:TEST0", sz_decimals: 2, oracle_px: "10.0", margin_table_id: 10,
207
+ margin_mode: "noCross",
208
+ max_gas: 1_000_000_000_000, # native-token wei: 10k HYPE
209
+ schema: { full_name: "test dex", collateral_token: 0, oracle_updater: sdk.exchange.address }
210
+ )
211
+ # => { "status" => "ok", "response" => { "type" => "default" } }
212
+
213
+ # Push oracle prices (at most once every 2.5s); coins are full "dex:COIN" names.
214
+ # A real oracle updater runs this continuously.
215
+ 3.times do
216
+ sdk.exchange.perp_deploy_set_oracle(
217
+ dex: "test",
218
+ oracle_pxs: { "test:TEST0" => "12.0" },
219
+ all_mark_pxs: [{ "test:TEST0" => "12.1" }],
220
+ external_perp_pxs: { "test:TEST0" => "12.0" }
221
+ )
222
+ sleep 3
223
+ end
224
+
186
225
  # Retrieve User's Active Asset Data
187
226
  aad = sdk.info.active_asset_data(user_address, "APT")
188
227
  # => { "user" => user_address, "coin" => "APT", "leverage" => { "type" => "cross", "value" => 3 }, ... }
@@ -220,6 +259,31 @@ details = sdk.info.token_details("0x00000000000000000000000000000000")
220
259
  # => { "name" => "TEST", "maxSupply" => "...", "midPx" => "...", ... }
221
260
  ```
222
261
 
262
+ ### HIP-2 Borrow/Lend
263
+
264
+ ```ruby
265
+ sdk.info.all_borrow_lend_reserve_states
266
+ sdk.info.borrow_lend_reserve_state(0) # token index 0 = USDC
267
+ sdk.info.borrow_lend_user_state(user_address)
268
+ sdk.info.user_borrow_lend_interest(user_address, start_time_ms)
269
+ ```
270
+
271
+ ### HIP-4 Outcomes
272
+
273
+ ```ruby
274
+ sdk.info.outcome_meta
275
+ sdk.info.outcome_templates
276
+ sdk.info.settled_outcome(outcome: 5) # nil until settled
277
+ ```
278
+
279
+ ### Explorer RPC
280
+
281
+ ```ruby
282
+ # Routed to rpc.hyperliquid.xyz/explorer (rpc.hyperliquid-testnet.xyz with testnet: true)
283
+ txs = sdk.info.user_details(user_address)
284
+ sdk.info.tx_details('0x' + 'ab' * 32) # a 66-character transaction hash
285
+ ```
286
+
223
287
  ## Exchange API (Trading)
224
288
 
225
289
  ### Basic Orders
@@ -356,7 +420,7 @@ sdk.exchange.market_close(coin: 'BTC', size: 0.01, slippage: 0.03)
356
420
  cancel_time = (Time.now.to_f * 1000).to_i + 60_000 # 60 seconds from now
357
421
  sdk.exchange.schedule_cancel(time: cancel_time)
358
422
 
359
- # Activate schedule cancel without specifying a time (server default)
423
+ # Remove the scheduled cancel
360
424
  sdk.exchange.schedule_cancel
361
425
  ```
362
426
 
@@ -408,6 +472,34 @@ sdk.exchange.order(
408
472
  )
409
473
  ```
410
474
 
475
+ ### TWAP and Trailing Stop Orders
476
+
477
+ ```ruby
478
+ # Buy 1 ETH over 30 minutes
479
+ result = sdk.exchange.twap_order(coin: 'ETH', is_buy: true, size: 1, reduce_only: false,
480
+ minutes: 30, randomize: true)
481
+ twap_id = result.dig('response', 'data', 'status', 'running', 'twapId')
482
+ sdk.exchange.twap_cancel(coin: 'ETH', twap_id: twap_id)
483
+
484
+ # Reduce-only trailing sell of 0.01 BTC, 1.5% retracement, active once the price reaches 100000
485
+ sdk.exchange.trailing_stop(coin: 'BTC', is_buy: false, size: 0.01, reduce_only: true,
486
+ retracement: { pct: '1.5%' }, activation_px: '100000')
487
+ ```
488
+
489
+ ### Fast Cancel, Always-Place Modify, Order Expiry
490
+
491
+ ```ruby
492
+ sdk.exchange.cancel(coin: 'BTC', oid: 123, fast: true)
493
+ sdk.exchange.modify_order(oid: 123, coin: 'BTC', is_buy: true, size: 0.01, limit_px: 95_000,
494
+ always_place: true)
495
+
496
+ # Reject L1 actions that reach the exchange after this time (ms); nil clears it.
497
+ # Leave it nil around user-signed actions such as usd_send.
498
+ sdk.exchange.expires_after = (Time.now.to_f * 1000).to_i + 10_000
499
+ sdk.exchange.order(coin: 'BTC', is_buy: true, size: 0.01, limit_px: 90_000)
500
+ sdk.exchange.expires_after = nil
501
+ ```
502
+
411
503
  ### Transfers & Account Management
412
504
 
413
505
  ```ruby
@@ -421,7 +513,7 @@ sdk.exchange.usd_send(
421
513
  sdk.exchange.spot_send(
422
514
  amount: '50',
423
515
  destination: '0x...',
424
- token: 'PURR'
516
+ token: 'PURR:0xc4bf3f870c0e9465323c0b6ed28096c2' # testnet PURR; mainnet is PURR:0xc1fb593aeffbeb02f85e0308e9956a90 (see info.spot_meta)
425
517
  )
426
518
 
427
519
  # Move USDC between perp and spot accounts
@@ -442,6 +534,16 @@ sdk.exchange.send_asset(
442
534
  token: 'USDC',
443
535
  amount: '100'
444
536
  )
537
+
538
+ # Move USDC between perp and spot for a sub-account
539
+ sdk.exchange.usd_class_transfer(amount: 10, to_perp: true, sub_account: '0x...')
540
+
541
+ # Send USDC to HyperEVM with calldata for an ICoreReceiveWithData contract
542
+ sdk.exchange.send_to_evm_with_data(
543
+ token: 'USDC', amount: '5', source_dex: 'spot',
544
+ destination_recipient: '0x...', address_encoding: 'hex',
545
+ destination_chain_id: 998, gas_limit: 200_000, data: '0x'
546
+ )
445
547
  ```
446
548
 
447
549
  ### Sub-Account Management
@@ -490,8 +592,54 @@ sdk.exchange.vault_transfer(
490
592
  is_deposit: false,
491
593
  usd: 50
492
594
  )
595
+
596
+ # Create a vault (at least 100 USD), then manage it as leader
597
+ sdk.exchange.create_vault(name: 'My Vault', description: 'Trend following on BTC', initial_usd: 100)
598
+ sdk.exchange.vault_modify(vault_address: '0x...', allow_deposits: false)
599
+ sdk.exchange.vault_distribute(vault_address: '0x...', usd: 25)
600
+ ```
601
+
602
+ ### HIP-2 Borrow/Lend and HIP-4 Outcomes
603
+
604
+ ```ruby
605
+ sdk.exchange.borrow_lend(operation: 'supply', token: 0, amount: '100')
606
+ sdk.exchange.borrow_lend(operation: 'withdraw', token: 0) # amount nil = full position
607
+
608
+ sdk.exchange.split_outcome(outcome: 5, amount: '10') # 10 quote -> 10 Yes + 10 No
609
+ sdk.exchange.merge_outcome(outcome: 5) # merge the maximum back
493
610
  ```
494
611
 
612
+ ### Multi-Sig
613
+
614
+ ```ruby
615
+ # 1. Once: turn the multi-sig account into a 2-of-3 multi-sig user
616
+ owner = Hyperliquid.new(testnet: true, private_key: ENV['MULTI_SIG_OWNER_KEY'])
617
+ owner.exchange.convert_to_multi_sig_user(authorized_users: [addr_a, addr_b, addr_c], threshold: 2)
618
+
619
+ # 2. Each co-signer signs the same inner action and nonce, with the submitter as outer_signer
620
+ multi_sig_user = owner.exchange.address
621
+ submitter = Hyperliquid.new(testnet: true, private_key: ENV['SIGNER_A_KEY'])
622
+ nonce = (Time.now.to_f * 1000).to_i
623
+ inner_action = {
624
+ type: 'order',
625
+ orders: [{ a: 4, b: true, p: '1100', s: '0.2', r: false, t: { limit: { tif: 'Gtc' } } }],
626
+ grouping: 'na'
627
+ }
628
+ signatures = [ENV['SIGNER_A_KEY'], ENV['SIGNER_B_KEY']].map do |key|
629
+ Hyperliquid::Signing::MultiSig.sign_as_co_signer_l1(
630
+ signer: Hyperliquid::Signing::Signer.new(private_key: key, testnet: true),
631
+ inner_action: inner_action, multi_sig_user: multi_sig_user,
632
+ outer_signer: submitter.exchange.address, nonce: nonce
633
+ )
634
+ end
635
+
636
+ # 3. The submitter posts the envelope with the same nonce
637
+ submitter.exchange.multi_sig(multi_sig_user: multi_sig_user, inner_action: inner_action,
638
+ signatures: signatures, nonce: nonce)
639
+ ```
640
+
641
+ The inner action is the wire-format action body (here an order for asset 4). For a user-signed inner action, co-signers call `sign_as_co_signer_user_signed` with its EIP-712 primary type and type constant, e.g. `primary_type: 'HyperliquidTransaction:SendAsset', sign_types: Hyperliquid::Signing::EIP712::SEND_ASSET_TYPES`.
642
+
495
643
  ### Referral
496
644
 
497
645
  ```ruby
@@ -581,6 +729,39 @@ status = sdk.info.user_dex_abstraction(sdk.exchange.address)
581
729
  # => { "enabled" => true }
582
730
  ```
583
731
 
732
+ ### HIP-4 Outcome Deployment
733
+
734
+ For an activated outcome deployer (or a sub-deployer acting for its venue). Responses come back unmodified, so check `status`.
735
+
736
+ ```ruby
737
+ venue = 'abc'
738
+
739
+ # How many outcomes can this venue still deploy?
740
+ limits = sdk.info.outcome_deployer_limits(venue)
741
+ # => { "nDailyOutcomesRemaining" => 10, "nActiveOutcomesRemaining" => 90 }
742
+
743
+ # Deploy a standalone Yes/No outcome; keywords are sorted by the SDK
744
+ result = sdk.exchange.register_standalone_outcome_from_template(
745
+ venue: venue,
746
+ template_id: 'binaryPrice',
747
+ keyword_to_value: { perp: 'BTC', priceDescription: 'the Hyperliquid BTC perp trade',
748
+ seconds: 90, threshold: 84_793, time: '20261002-1900' },
749
+ deployer_fee_scale: '1'
750
+ )
751
+ raise result['response'].to_s unless result['status'] == 'ok'
752
+
753
+ # Settle it later: name, description and side names must match outcome_meta exactly
754
+ outcome = sdk.info.outcome_meta['outcomes'].reverse.find { |o| o['venue'] == venue }
755
+ sdk.exchange.settle_outcome(
756
+ venue: venue,
757
+ outcome: outcome['outcome'],
758
+ settle_fraction: '1', # first side (Yes) pays out in full
759
+ name: outcome['name'],
760
+ description: outcome['description'],
761
+ side_names: outcome['sideSpecs'].map { |s| s['name'] }
762
+ )
763
+ ```
764
+
584
765
  ## WebSocket
585
766
 
586
767
  ### l2Book (Order Book)
@@ -688,6 +869,60 @@ sleep 60
688
869
  sdk.ws.close
689
870
  ```
690
871
 
872
+ ### clearinghouseState (per dex)
873
+
874
+ ```ruby
875
+ sdk = Hyperliquid.new(testnet: true)
876
+ user = '0x...'
877
+
878
+ # Main dex (omit dex:) and a HIP-3 dex are separate subscriptions
879
+ sdk.ws.subscribe({ type: 'clearinghouseState', user: user }) do |data|
880
+ puts "main dex account value: #{data['clearinghouseState']['marginSummary']['accountValue']}"
881
+ end
882
+
883
+ sdk.ws.subscribe({ type: 'clearinghouseState', user: user, dex: 'xyz' }) do |data|
884
+ puts "#{data['dex']} positions: #{data['clearinghouseState']['assetPositions'].length}"
885
+ end
886
+
887
+ sleep 10
888
+ sdk.ws.close
889
+ ```
890
+
891
+ ### activeAssetCtx (Asset Context)
892
+
893
+ ```ruby
894
+ sdk = Hyperliquid.new(testnet: true)
895
+
896
+ # Perp, HIP-3 ('xyz:XYZ100') or spot ('PURR/USDC', '@107') coins
897
+ sdk.ws.subscribe({ type: 'activeAssetCtx', coin: 'BTC' }) do |data|
898
+ puts "#{data['coin']} mark=#{data['ctx']['markPx']} funding=#{data['ctx']['funding']}"
899
+ end
900
+
901
+ sdk.ws.subscribe({ type: 'activeAssetCtx', coin: 'PURR/USDC' }) do |data|
902
+ puts "#{data['coin']} mark=#{data['ctx']['markPx']}"
903
+ end
904
+
905
+ sleep 10
906
+ sdk.ws.close
907
+ ```
908
+
909
+ ### fastAssetCtxs (mark/mid prices, compressed)
910
+
911
+ ```ruby
912
+ sdk = Hyperliquid.new(testnet: true)
913
+ cache = {}
914
+
915
+ # First frame: snapshot of every coin. Later frames: only changed coins/fields.
916
+ # Frames arrive base64 + raw DEFLATE; the SDK decodes them before the callback.
917
+ sdk.ws.subscribe({ type: 'fastAssetCtxs' }) do |ctxs|
918
+ cache.merge!(ctxs) { |_coin, old, new| old.merge(new) }
919
+ puts "BTC mark=#{cache.dig('BTC', 'markPx')} mid=#{cache.dig('BTC', 'midPx')} (#{cache.size} coins)"
920
+ end
921
+
922
+ sleep 10
923
+ sdk.ws.close
924
+ ```
925
+
691
926
  ### Multiple Subscriptions
692
927
 
693
928
  ```ruby
@@ -709,6 +944,24 @@ sleep 10
709
944
  sdk.ws.close
710
945
  ```
711
946
 
947
+ ### Explorer Blocks and Transactions
948
+
949
+ ```ruby
950
+ sdk = Hyperliquid.new(testnet: true)
951
+
952
+ # Separate connection to rpc.hyperliquid(-testnet).xyz/ws; each message is an Array
953
+ sdk.ws.subscribe_explorer_block do |blocks|
954
+ blocks.each { |b| puts "block #{b['height']} with #{b['numTxs']} txs" }
955
+ end
956
+
957
+ sdk.ws.subscribe_explorer_txs do |txs|
958
+ txs.each { |tx| puts "#{tx['user']} #{tx.dig('action', 'type')} #{tx['hash']}" }
959
+ end
960
+
961
+ sleep 10
962
+ sdk.ws.close
963
+ ```
964
+
712
965
  ### Handling Reconnection
713
966
 
714
967
  ```ruby