hyperliquid 1.1.0 → 1.3.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/.ruby-version +1 -1
- data/CHANGELOG.md +42 -0
- data/CLAUDE.md +95 -3
- data/lib/hyperliquid/client.rb +31 -7
- data/lib/hyperliquid/constants.rb +5 -0
- data/lib/hyperliquid/errors.rb +4 -0
- data/lib/hyperliquid/exchange.rb +218 -0
- data/lib/hyperliquid/info.rb +198 -0
- data/lib/hyperliquid/signing/eip712.rb +17 -0
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid.rb +7 -1
- data/scripts/test_08_usd_class_transfer.rb +11 -0
- data/scripts/test_11_builder_fee.rb +12 -2
- data/scripts/test_15_explorer.rb +71 -0
- data/scripts/test_all.rb +2 -1
- data/scripts/test_automated.rb +2 -1
- metadata +2 -2
- data/AGENTS.md +0 -83
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3bb8af9ff28e902372f37910705473b980d432cf4a04485dbe5999e7fa6edc7e
|
|
4
|
+
data.tar.gz: e7176efc9fd95c1662b36ec7b8f5536192d8ce3e41624f9b341eca5e79de079a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79d01ffef7d490e185e191285dab90adcd339b03c8f2936ab8eaccb1fba6bc832a76aa49e2cee3db354608a5ce53e92afda6c6efec5d8b2cafc0c1e9a51f7b44
|
|
7
|
+
data.tar.gz: c313423aab3b257d81b7447c840502025f1a92cf857064c614678be2022b533ce7e85a89bedf0aaedd371c5781425962ee48b8f8c9ac7be98f3940b08c756510
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.4.
|
|
1
|
+
3.4.9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
## [Ruby Hyperliquid SDK Changelog]
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-04-30
|
|
4
|
+
|
|
5
|
+
### New transport
|
|
6
|
+
|
|
7
|
+
- Explorer RPC connection (`rpc.hyperliquid.xyz` / `rpc.hyperliquid-testnet.xyz`, endpoint `/explorer`). `Client` gains optional `explorer_base_url:` and `target: :explorer` routing, with a lazily-built second Faraday connection sharing the default retry/timeout config. The SDK wires this up automatically based on `testnet:`.
|
|
8
|
+
- New `Hyperliquid::ConfigurationError` raised when `target: :explorer` is used on a client constructed without an explorer URL.
|
|
9
|
+
|
|
10
|
+
### New Info endpoints
|
|
11
|
+
|
|
12
|
+
- `Info#tx_details(hash)` — explorer `txDetails` lookup
|
|
13
|
+
- `Info#user_details(user)` — explorer `userDetails` lookup
|
|
14
|
+
|
|
15
|
+
### New Exchange actions
|
|
16
|
+
|
|
17
|
+
- `Exchange#claim_rewards` — L1 `claimRewards`
|
|
18
|
+
- `Exchange#set_display_name(display_name:)` — L1 `setDisplayName` (max 20 chars; `""` clears)
|
|
19
|
+
- `Exchange#register_referrer(code:)` — L1 `registerReferrer`
|
|
20
|
+
- `Exchange#top_up_isolated_only_margin(coin:, leverage:, vault_address:)` — L1 `topUpIsolatedOnlyMargin`
|
|
21
|
+
- `Exchange#vault_modify(vault_address:, allow_deposits:, always_close_on_withdraw:)` — L1 `vaultModify`
|
|
22
|
+
- `Exchange#vault_distribute(vault_address:, usd:)` — L1 `vaultDistribute`
|
|
23
|
+
|
|
24
|
+
## [1.2.0] - 2026-04-27
|
|
25
|
+
|
|
26
|
+
### New Info endpoints
|
|
27
|
+
|
|
28
|
+
- HIP-2 borrow/lend: `Info#borrow_lend_user_state`, `Info#borrow_lend_reserve_state`, `Info#all_borrow_lend_reserve_states`, `Info#user_borrow_lend_interest`
|
|
29
|
+
- Perp metadata: `Info#perp_annotation`, `Info#perp_concise_annotations`, `Info#perp_categories`, `Info#outcome_meta`, `Info#aligned_quote_token_info`
|
|
30
|
+
- Builders / perp dexs: `Info#approved_builders`, `Info#all_perp_metas`, `Info#perp_dex_status`
|
|
31
|
+
- TWAP / web / sub-accounts: `Info#user_twap_slice_fills_by_time`, `Info#web_data2`, `Info#sub_accounts2`, `Info#twap_history`
|
|
32
|
+
- Margin / vaults: `Info#margin_table`, `Info#leading_vaults`
|
|
33
|
+
- Validator / network: `Info#validator_l1_votes`, `Info#gossip_root_ips`, `Info#legal_check`
|
|
34
|
+
|
|
35
|
+
### New Exchange actions
|
|
36
|
+
|
|
37
|
+
- `Exchange#user_set_abstraction(user:, abstraction:)` — user-signed `userSetAbstraction` (new `USER_SET_ABSTRACTION_TYPES` EIP-712 schema)
|
|
38
|
+
- `Exchange#agent_set_abstraction(abstraction:, vault_address:)` — L1 `agentSetAbstraction`
|
|
39
|
+
- `Exchange#gossip_priority_bid(slot_id:, ip:, max_gas:, vault_address:)` — L1 `gossipPriorityBid`
|
|
40
|
+
- `Exchange#convert_to_multi_sig_user(authorized_users:, threshold:)` — user-signed action (new `CONVERT_TO_MULTI_SIG_USER_TYPES` EIP-712 schema)
|
|
41
|
+
- `Exchange#expires_after=` — writer to set the global L1 expiration timestamp for subsequent actions
|
|
42
|
+
- `Exchange#use_big_blocks(enable:)` — `evmUserModify` action
|
|
43
|
+
- `Exchange#noop(nonce: nil, vault_address: nil)` — L1 noop, useful for burning a specific nonce slot
|
|
44
|
+
|
|
3
45
|
## [1.1.0] - 2026-04-24
|
|
4
46
|
|
|
5
47
|
### New Info endpoints
|
data/CLAUDE.md
CHANGED
|
@@ -1,6 +1,98 @@
|
|
|
1
1
|
# CLAUDE.md
|
|
2
2
|
|
|
3
|
-
This file provides guidance to
|
|
3
|
+
This file provides guidance to AI coding agents working with this repository. It is the canonical source of truth — keep it in sync as the SDK evolves.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Ruby SDK for the Hyperliquid decentralized exchange API. Three API surfaces: **Info** (read-only market data), **Exchange** (authenticated trading), and **WebSocket** (real-time streaming). Built on Faraday for HTTP, the `eth` gem for EIP-712 signing, `msgpack` for action serialization, and `ws_lite` for WebSocket connections.
|
|
8
|
+
|
|
9
|
+
Version is the single source of truth in `lib/hyperliquid/version.rb`; required Ruby version is in the gemspec.
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bin/setup # install dependencies
|
|
15
|
+
rake # run tests + linting (CI default)
|
|
16
|
+
rake spec # tests only
|
|
17
|
+
rake rubocop # linting only
|
|
18
|
+
bundle exec rspec spec/hyperliquid/cloid_spec.rb # single file
|
|
19
|
+
bundle exec rspec spec/hyperliquid/cloid_spec.rb:62 # single test by line
|
|
20
|
+
bin/console # IRB with SDK loaded
|
|
21
|
+
ruby example.rb # example usage script
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Integration Tests (Testnet)
|
|
25
|
+
|
|
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
|
+
|
|
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
|
|
31
|
+
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_08_usd_class_transfer.rb # single
|
|
32
|
+
```
|
|
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.
|
|
35
|
+
|
|
36
|
+
`test_integration.rb` at the project root is a thin convenience wrapper.
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
### Request Flow
|
|
41
|
+
|
|
42
|
+
All three API surfaces are reached through `Hyperliquid::SDK` (`lib/hyperliquid.rb`):
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Hyperliquid.new(...)
|
|
46
|
+
├── sdk.info → Info → Client → POST /info (always available)
|
|
47
|
+
├── sdk.exchange → Exchange → Client → POST /exchange (requires private_key)
|
|
48
|
+
└── sdk.ws → WS::Client → WSS /ws (real-time streaming)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **Info path**: method builds `{ type: 'someType', ... }` body → `Client` POSTs to `/info` → parsed JSON returned.
|
|
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
|
+
- **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.
|
|
55
|
+
|
|
56
|
+
### Signing (Python SDK Parity)
|
|
57
|
+
|
|
58
|
+
The signing chain in `lib/hyperliquid/signing/` must exactly match the official Python SDK:
|
|
59
|
+
|
|
60
|
+
1. **Action hash**: `keccak256(msgpack(action) + nonce(8B big-endian) + vault_flag + [vault_addr] + [expires_flag + expires_after])`
|
|
61
|
+
2. **Phantom agent**: `{ source: 'a'|'b', connectionId: action_hash }` (`a`=mainnet, `b`=testnet)
|
|
62
|
+
3. **EIP-712 signature** over phantom agent with Exchange domain (chain ID 1337)
|
|
63
|
+
|
|
64
|
+
Any change to signing must maintain parity with the Python SDK or transactions will be rejected by the exchange.
|
|
65
|
+
|
|
66
|
+
### Numeric Conversion
|
|
67
|
+
|
|
68
|
+
- **`float_to_wire`** (in Exchange): converts to string with 8-decimal precision, validates rounding tolerance (`1e-12`), normalizes trailing zeros. No scientific notation.
|
|
69
|
+
- **Market order pricing** (`_slippage_price`): apply slippage (default 5%) to mid price → round to 5 significant figures → round to `(6 for perp, 8 for spot) - szDecimals` decimal places.
|
|
70
|
+
- **Spot vs perp**: assets with index `>= 10_000` are spot (`SPOT_ASSET_THRESHOLD` in Exchange). This affects decimal place calculations.
|
|
71
|
+
|
|
72
|
+
### HIP-3 (Builder-Deployed Perps)
|
|
73
|
+
|
|
74
|
+
Many Info methods accept a `dex:` kwarg (e.g. `meta(dex: 'foo')`, `user_state(user, dex: 'foo')`) to query a builder-deployed perp dex rather than the canonical perp market. `Info#perp_dexs` enumerates available dexes; `Info#perp_dex_limits(dex)` returns per-dex risk parameters.
|
|
75
|
+
|
|
76
|
+
### Testing
|
|
77
|
+
|
|
78
|
+
- **Unit tests** (`spec/`): RSpec + WebMock. WebMock resets between tests. Monkey-patching disabled. Test files mirror `lib/` structure. No live HTTP calls in unit tests.
|
|
79
|
+
- **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`.
|
|
80
|
+
- **`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.
|
|
81
|
+
|
|
82
|
+
### Code Style
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
Predicate methods follow Ruby style (`vip?`, `connected?`, `testnet?`) — not `is_vip` / `is_connected`. RuboCop's `Naming/PredicateName` enforces this.
|
|
87
|
+
|
|
88
|
+
### CI
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
## Release Flow
|
|
93
|
+
|
|
94
|
+
Releases happen from `main`. Day-to-day work lands on `dev`, then `dev` is merged into `main` and tagged at release time. `CHANGELOG.md` follows Keep-a-Changelog conventions; `lib/hyperliquid/version.rb` is the single source of version truth (gemspec reads it). Tags push automatically trigger the GitHub release workflow.
|
|
95
|
+
|
|
96
|
+
## Additional Docs
|
|
97
|
+
|
|
98
|
+
Detailed API reference, examples, WebSocket guide, configuration, and error handling in `docs/` (`API.md`, `EXAMPLES.md`, `WS.md`, `CONFIGURATION.md`, `ERRORS.md`, `DEVELOPMENT.md`).
|
data/lib/hyperliquid/client.rb
CHANGED
|
@@ -21,17 +21,24 @@ module Hyperliquid
|
|
|
21
21
|
}.freeze
|
|
22
22
|
|
|
23
23
|
# Initialize a new HTTP client
|
|
24
|
-
# @param base_url [String] The base URL for the API
|
|
24
|
+
# @param base_url [String] The base URL for the default API (info/exchange)
|
|
25
25
|
# @param timeout [Integer] Request timeout in seconds (default: Constants::DEFAULT_TIMEOUT)
|
|
26
26
|
# @param retry_enabled [Boolean] Whether to enable retry logic (default: false)
|
|
27
|
-
|
|
27
|
+
# @param explorer_base_url [String, nil] Optional base URL for the explorer RPC (used by
|
|
28
|
+
# tx_details / user_details). When nil, calls with target: :explorer raise ConfigurationError.
|
|
29
|
+
def initialize(base_url:, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false,
|
|
30
|
+
explorer_base_url: nil)
|
|
28
31
|
@retry_enabled = retry_enabled
|
|
29
|
-
@
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
@explorer_base_url = explorer_base_url
|
|
34
|
+
@connection = build_connection(base_url)
|
|
35
|
+
@explorer_connection = nil
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# Make a POST request to the API
|
|
33
39
|
# @param endpoint [String] The API endpoint to make the request to
|
|
34
40
|
# @param body [Hash] The request body as a hash (default: {})
|
|
41
|
+
# @param target [Symbol] Which connection to use; :default (info/exchange) or :explorer (RPC)
|
|
35
42
|
# @return [Hash, String] The parsed JSON response or raw response body
|
|
36
43
|
# @raise [NetworkError] When connection fails
|
|
37
44
|
# @raise [TimeoutError] When request times out
|
|
@@ -41,8 +48,10 @@ module Hyperliquid
|
|
|
41
48
|
# @raise [RateLimitError] When API returns 429 status
|
|
42
49
|
# @raise [ServerError] When API returns 5xx status
|
|
43
50
|
# @raise [ClientError] When API returns unexpected status
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
# @raise [ConfigurationError] When target: :explorer is requested but no explorer_base_url was configured
|
|
52
|
+
def post(endpoint, body = {}, target: :default)
|
|
53
|
+
connection = connection_for(target)
|
|
54
|
+
response = connection.post(endpoint) do |req|
|
|
46
55
|
req.headers['Content-Type'] = 'application/json'
|
|
47
56
|
req.body = body.to_json unless body.empty?
|
|
48
57
|
end
|
|
@@ -60,9 +69,24 @@ module Hyperliquid
|
|
|
60
69
|
|
|
61
70
|
private
|
|
62
71
|
|
|
63
|
-
def
|
|
72
|
+
def connection_for(target)
|
|
73
|
+
case target
|
|
74
|
+
when :default
|
|
75
|
+
@connection
|
|
76
|
+
when :explorer
|
|
77
|
+
unless @explorer_base_url
|
|
78
|
+
raise ConfigurationError,
|
|
79
|
+
'Explorer RPC URL not configured; pass explorer_base_url: when constructing the Client'
|
|
80
|
+
end
|
|
81
|
+
@explorer_connection ||= build_connection(@explorer_base_url)
|
|
82
|
+
else
|
|
83
|
+
raise ArgumentError, "Unknown post target: #{target.inspect} (expected :default or :explorer)"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_connection(base_url)
|
|
64
88
|
Faraday.new(url: base_url) do |conn|
|
|
65
|
-
conn.options.timeout = timeout
|
|
89
|
+
conn.options.timeout = @timeout
|
|
66
90
|
conn.options.read_timeout = Constants::DEFAULT_READ_TIMEOUT
|
|
67
91
|
conn.request :retry, DEFAULT_RETRY_OPTIONS if @retry_enabled
|
|
68
92
|
end
|
|
@@ -7,9 +7,14 @@ module Hyperliquid
|
|
|
7
7
|
MAINNET_API_URL = 'https://api.hyperliquid.xyz'
|
|
8
8
|
TESTNET_API_URL = 'https://api.hyperliquid-testnet.xyz'
|
|
9
9
|
|
|
10
|
+
# Explorer RPC URLs (used by Info#tx_details and Info#user_details)
|
|
11
|
+
MAINNET_RPC_URL = 'https://rpc.hyperliquid.xyz'
|
|
12
|
+
TESTNET_RPC_URL = 'https://rpc.hyperliquid-testnet.xyz'
|
|
13
|
+
|
|
10
14
|
# API endpoints
|
|
11
15
|
INFO_ENDPOINT = '/info'
|
|
12
16
|
EXCHANGE_ENDPOINT = '/exchange'
|
|
17
|
+
EXPLORER_ENDPOINT = '/explorer'
|
|
13
18
|
|
|
14
19
|
# WebSocket
|
|
15
20
|
WS_ENDPOINT = '/ws'
|
data/lib/hyperliquid/errors.rb
CHANGED
|
@@ -38,4 +38,8 @@ module Hyperliquid
|
|
|
38
38
|
|
|
39
39
|
# Error for WebSocket issues
|
|
40
40
|
class WebSocketError < Error; end
|
|
41
|
+
|
|
42
|
+
# Error for SDK configuration issues (e.g. calling an explorer-only method
|
|
43
|
+
# on a Client that wasn't configured with an explorer_base_url)
|
|
44
|
+
class ConfigurationError < Error; end
|
|
41
45
|
end
|
data/lib/hyperliquid/exchange.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'bigdecimal'
|
|
4
|
+
require 'json'
|
|
4
5
|
|
|
5
6
|
module Hyperliquid
|
|
6
7
|
# Exchange API client for write operations (orders, cancels, etc.)
|
|
@@ -675,6 +676,223 @@ module Hyperliquid
|
|
|
675
676
|
post_action(action, signature, nonce, vault_address)
|
|
676
677
|
end
|
|
677
678
|
|
|
679
|
+
# Set the agent abstraction mode (L1 action `agentSetAbstraction`).
|
|
680
|
+
# @param abstraction [String] One of 'u' (unified), 'p' (portfolio margin), 'i' (isolated/disabled)
|
|
681
|
+
# @param vault_address [String, nil] Vault address if acting on behalf of a vault
|
|
682
|
+
# @return [Hash] Exchange response
|
|
683
|
+
def agent_set_abstraction(abstraction:, vault_address: nil)
|
|
684
|
+
nonce = timestamp_ms
|
|
685
|
+
action = { type: 'agentSetAbstraction', abstraction: abstraction }
|
|
686
|
+
signature = @signer.sign_l1_action(
|
|
687
|
+
action, nonce,
|
|
688
|
+
vault_address: vault_address,
|
|
689
|
+
expires_after: @expires_after
|
|
690
|
+
)
|
|
691
|
+
post_action(action, signature, nonce, vault_address)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Set the abstraction mode for a user (`userSetAbstraction` user-signed action).
|
|
695
|
+
# The `user` address is lowercased to match the Python SDK and protocol expectations.
|
|
696
|
+
# @param user [String] Wallet address whose abstraction is being set
|
|
697
|
+
# @param abstraction [String] One of 'u' (unified), 'p' (portfolio margin), 'i' (isolated/disabled)
|
|
698
|
+
# @return [Hash] Exchange response
|
|
699
|
+
def user_set_abstraction(user:, abstraction:)
|
|
700
|
+
nonce = timestamp_ms
|
|
701
|
+
user_lower = user.downcase
|
|
702
|
+
action = {
|
|
703
|
+
type: 'userSetAbstraction',
|
|
704
|
+
signatureChainId: '0x66eee',
|
|
705
|
+
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
|
|
706
|
+
user: user_lower,
|
|
707
|
+
abstraction: abstraction,
|
|
708
|
+
nonce: nonce
|
|
709
|
+
}
|
|
710
|
+
signature = @signer.sign_user_signed_action(
|
|
711
|
+
{ user: user_lower, abstraction: abstraction, nonce: nonce },
|
|
712
|
+
'HyperliquidTransaction:UserSetAbstraction',
|
|
713
|
+
Signing::EIP712::USER_SET_ABSTRACTION_TYPES
|
|
714
|
+
)
|
|
715
|
+
post_action(action, signature, nonce, nil)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Update the global expiration timestamp applied to subsequent L1 actions.
|
|
719
|
+
# `expires_after` is not supported on user-signed actions (e.g. `usd_send`,
|
|
720
|
+
# `withdraw_from_bridge`) and must be nil for those calls to succeed.
|
|
721
|
+
# @param value [Integer, nil] Unix timestamp in milliseconds, or nil to clear
|
|
722
|
+
attr_writer :expires_after
|
|
723
|
+
|
|
724
|
+
# Toggle EVM big-blocks mode for this account (`evmUserModify` L1 action).
|
|
725
|
+
# When enabled, EVM transactions from this account are routed to big blocks.
|
|
726
|
+
# @param enable [Boolean] True to enable big blocks, false to disable
|
|
727
|
+
# @return [Hash] Exchange response
|
|
728
|
+
def use_big_blocks(enable:)
|
|
729
|
+
nonce = timestamp_ms
|
|
730
|
+
action = { type: 'evmUserModify', usingBigBlocks: enable }
|
|
731
|
+
signature = @signer.sign_l1_action(
|
|
732
|
+
action, nonce,
|
|
733
|
+
expires_after: @expires_after
|
|
734
|
+
)
|
|
735
|
+
post_action(action, signature, nonce, nil)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# No-op L1 action — useful for burning a specific nonce slot without side effects.
|
|
739
|
+
# @param nonce [Integer, nil] Nonce to consume (defaults to current timestamp_ms)
|
|
740
|
+
# @param vault_address [String, nil] Vault address if acting on behalf of a vault
|
|
741
|
+
# @return [Hash] Exchange response
|
|
742
|
+
def noop(nonce: nil, vault_address: nil)
|
|
743
|
+
nonce ||= timestamp_ms
|
|
744
|
+
action = { type: 'noop' }
|
|
745
|
+
signature = @signer.sign_l1_action(
|
|
746
|
+
action, nonce,
|
|
747
|
+
vault_address: vault_address,
|
|
748
|
+
expires_after: @expires_after
|
|
749
|
+
)
|
|
750
|
+
post_action(action, signature, nonce, vault_address)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Submit a priority-bid gossip message (L1 action `gossipPriorityBid`).
|
|
754
|
+
# Used by validators / priority bidders to gossip a bid for a given slot.
|
|
755
|
+
# @param slot_id [Integer] Slot identifier the bid applies to
|
|
756
|
+
# @param ip [String] Bidder IP address (string form expected by the protocol)
|
|
757
|
+
# @param max_gas [Integer] Maximum gas the bidder is willing to pay
|
|
758
|
+
# @param vault_address [String, nil] Vault address if acting on behalf of a vault
|
|
759
|
+
# @return [Hash] Exchange response
|
|
760
|
+
def gossip_priority_bid(slot_id:, ip:, max_gas:, vault_address: nil)
|
|
761
|
+
nonce = timestamp_ms
|
|
762
|
+
action = { type: 'gossipPriorityBid', slotId: slot_id, ip: ip, maxGas: max_gas }
|
|
763
|
+
signature = @signer.sign_l1_action(
|
|
764
|
+
action, nonce,
|
|
765
|
+
vault_address: vault_address,
|
|
766
|
+
expires_after: @expires_after
|
|
767
|
+
)
|
|
768
|
+
post_action(action, signature, nonce, vault_address)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Convert this account into a multi-sig user (`convertToMultiSigUser` user-signed action).
|
|
772
|
+
# The set of authorized signers and the threshold are JSON-encoded into the action's
|
|
773
|
+
# `signers` field as required by the Hyperliquid protocol.
|
|
774
|
+
# @param authorized_users [Array<String>] Authorized signer addresses; sorted before signing
|
|
775
|
+
# @param threshold [Integer] Number of signatures required to authorize an action
|
|
776
|
+
# @return [Hash] Exchange response
|
|
777
|
+
def convert_to_multi_sig_user(authorized_users:, threshold:)
|
|
778
|
+
nonce = timestamp_ms
|
|
779
|
+
sorted_users = authorized_users.sort
|
|
780
|
+
signers_json = JSON.generate({ authorizedUsers: sorted_users, threshold: threshold })
|
|
781
|
+
action = {
|
|
782
|
+
type: 'convertToMultiSigUser',
|
|
783
|
+
signatureChainId: '0x66eee',
|
|
784
|
+
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
|
|
785
|
+
signers: signers_json,
|
|
786
|
+
nonce: nonce
|
|
787
|
+
}
|
|
788
|
+
signature = @signer.sign_user_signed_action(
|
|
789
|
+
{ signers: signers_json, nonce: nonce },
|
|
790
|
+
'HyperliquidTransaction:ConvertToMultiSigUser',
|
|
791
|
+
Signing::EIP712::CONVERT_TO_MULTI_SIG_USER_TYPES
|
|
792
|
+
)
|
|
793
|
+
post_action(action, signature, nonce, nil)
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# Claim accrued referral-program rewards (`claimRewards` L1 action).
|
|
797
|
+
# @return [Hash] Exchange response
|
|
798
|
+
def claim_rewards
|
|
799
|
+
nonce = timestamp_ms
|
|
800
|
+
action = { type: 'claimRewards' }
|
|
801
|
+
signature = @signer.sign_l1_action(
|
|
802
|
+
action, nonce,
|
|
803
|
+
expires_after: @expires_after
|
|
804
|
+
)
|
|
805
|
+
post_action(action, signature, nonce, nil)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# Set the leaderboard display name (`setDisplayName` L1 action).
|
|
809
|
+
# Pass an empty string to remove the existing display name.
|
|
810
|
+
# @param display_name [String] Display name (max 20 characters)
|
|
811
|
+
# @return [Hash] Exchange response
|
|
812
|
+
def set_display_name(display_name:)
|
|
813
|
+
nonce = timestamp_ms
|
|
814
|
+
action = { type: 'setDisplayName', displayName: display_name }
|
|
815
|
+
signature = @signer.sign_l1_action(
|
|
816
|
+
action, nonce,
|
|
817
|
+
expires_after: @expires_after
|
|
818
|
+
)
|
|
819
|
+
post_action(action, signature, nonce, nil)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Register a new referral code for this account (`registerReferrer` L1 action).
|
|
823
|
+
# Distinct from `set_referrer`, which records a referrer the account was referred *by*.
|
|
824
|
+
# @param code [String] Referral code to create (1–20 characters)
|
|
825
|
+
# @return [Hash] Exchange response
|
|
826
|
+
def register_referrer(code:)
|
|
827
|
+
nonce = timestamp_ms
|
|
828
|
+
action = { type: 'registerReferrer', code: code }
|
|
829
|
+
signature = @signer.sign_l1_action(
|
|
830
|
+
action, nonce,
|
|
831
|
+
expires_after: @expires_after
|
|
832
|
+
)
|
|
833
|
+
post_action(action, signature, nonce, nil)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Top up isolated margin to target a specific leverage (`topUpIsolatedOnlyMargin` L1 action).
|
|
837
|
+
# @param coin [String] Asset symbol (perps only)
|
|
838
|
+
# @param leverage [String, Numeric] Target leverage (sent as float string per protocol)
|
|
839
|
+
# @param vault_address [String, nil] Vault address if acting on behalf of a vault
|
|
840
|
+
# @return [Hash] Exchange response
|
|
841
|
+
def top_up_isolated_only_margin(coin:, leverage:, vault_address: nil)
|
|
842
|
+
nonce = timestamp_ms
|
|
843
|
+
action = {
|
|
844
|
+
type: 'topUpIsolatedOnlyMargin',
|
|
845
|
+
asset: asset_index(coin),
|
|
846
|
+
leverage: leverage.to_s
|
|
847
|
+
}
|
|
848
|
+
signature = @signer.sign_l1_action(
|
|
849
|
+
action, nonce,
|
|
850
|
+
vault_address: vault_address,
|
|
851
|
+
expires_after: @expires_after
|
|
852
|
+
)
|
|
853
|
+
post_action(action, signature, nonce, vault_address)
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Modify a vault's configuration (`vaultModify` L1 action).
|
|
857
|
+
# Only the vault leader may submit this. Either flag may be omitted (sent as null).
|
|
858
|
+
# @param vault_address [String] Vault address being modified
|
|
859
|
+
# @param allow_deposits [Boolean, nil] Allow follower deposits (nil = unchanged)
|
|
860
|
+
# @param always_close_on_withdraw [Boolean, nil] Always close positions on withdrawal (nil = unchanged)
|
|
861
|
+
# @return [Hash] Exchange response
|
|
862
|
+
def vault_modify(vault_address:, allow_deposits: nil, always_close_on_withdraw: nil)
|
|
863
|
+
nonce = timestamp_ms
|
|
864
|
+
action = {
|
|
865
|
+
type: 'vaultModify',
|
|
866
|
+
vaultAddress: vault_address,
|
|
867
|
+
allowDeposits: allow_deposits,
|
|
868
|
+
alwaysCloseOnWithdraw: always_close_on_withdraw
|
|
869
|
+
}
|
|
870
|
+
signature = @signer.sign_l1_action(
|
|
871
|
+
action, nonce,
|
|
872
|
+
expires_after: @expires_after
|
|
873
|
+
)
|
|
874
|
+
post_action(action, signature, nonce, nil)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Distribute funds from a vault to followers (`vaultDistribute` L1 action).
|
|
878
|
+
# Only the vault leader may submit this. Pass `usd: 0` to close the vault.
|
|
879
|
+
# @param vault_address [String] Vault address
|
|
880
|
+
# @param usd [Numeric] USD amount to distribute (scaled to integer cents-of-cents internally)
|
|
881
|
+
# @return [Hash] Exchange response
|
|
882
|
+
def vault_distribute(vault_address:, usd:)
|
|
883
|
+
nonce = timestamp_ms
|
|
884
|
+
action = {
|
|
885
|
+
type: 'vaultDistribute',
|
|
886
|
+
vaultAddress: vault_address,
|
|
887
|
+
usd: float_to_usd_int(usd)
|
|
888
|
+
}
|
|
889
|
+
signature = @signer.sign_l1_action(
|
|
890
|
+
action, nonce,
|
|
891
|
+
expires_after: @expires_after
|
|
892
|
+
)
|
|
893
|
+
post_action(action, signature, nonce, nil)
|
|
894
|
+
end
|
|
895
|
+
|
|
678
896
|
# Clear the asset metadata cache
|
|
679
897
|
# Call this if metadata has been updated
|
|
680
898
|
def reload_metadata!
|
data/lib/hyperliquid/info.rb
CHANGED
|
@@ -134,6 +134,13 @@ module Hyperliquid
|
|
|
134
134
|
@client.post(Constants::INFO_ENDPOINT, { type: 'maxBuilderFee', user: user, builder: builder })
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
# Query approved builders for a user
|
|
138
|
+
# @param user [String] Wallet address
|
|
139
|
+
# @return [Array<String>] Array of approved builder addresses
|
|
140
|
+
def approved_builders(user)
|
|
141
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'approvedBuilders', user: user })
|
|
142
|
+
end
|
|
143
|
+
|
|
137
144
|
# Retrieve a user's historical orders
|
|
138
145
|
# @param user [String] Wallet address
|
|
139
146
|
# @param start_time [Integer, nil] Optional start timestamp in milliseconds
|
|
@@ -158,6 +165,20 @@ module Hyperliquid
|
|
|
158
165
|
@client.post(Constants::INFO_ENDPOINT, body)
|
|
159
166
|
end
|
|
160
167
|
|
|
168
|
+
# Retrieve a user's TWAP slice fills within a time range
|
|
169
|
+
# @param user [String] Wallet address
|
|
170
|
+
# @param start_time [Integer] Start timestamp in milliseconds
|
|
171
|
+
# @param end_time [Integer, nil] Optional end timestamp in milliseconds
|
|
172
|
+
# @param aggregate_by_time [Boolean, nil] If true, partial fills are aggregated when a
|
|
173
|
+
# crossing order fills multiple resting orders
|
|
174
|
+
# @return [Array]
|
|
175
|
+
def user_twap_slice_fills_by_time(user, start_time, end_time = nil, aggregate_by_time: nil)
|
|
176
|
+
body = { type: 'userTwapSliceFillsByTime', user: user, startTime: start_time }
|
|
177
|
+
body[:endTime] = end_time if end_time
|
|
178
|
+
body[:aggregateByTime] = aggregate_by_time unless aggregate_by_time.nil?
|
|
179
|
+
@client.post(Constants::INFO_ENDPOINT, body)
|
|
180
|
+
end
|
|
181
|
+
|
|
161
182
|
# Retrieve a user's subaccounts
|
|
162
183
|
# @param user [String]
|
|
163
184
|
# @return [Array]
|
|
@@ -165,6 +186,15 @@ module Hyperliquid
|
|
|
165
186
|
@client.post(Constants::INFO_ENDPOINT, { type: 'subaccounts', user: user })
|
|
166
187
|
end
|
|
167
188
|
|
|
189
|
+
# Retrieve a user's V2 subaccounts (per-dex clearinghouse + spot state)
|
|
190
|
+
# @param user [String] Wallet address
|
|
191
|
+
# @return [Array<Hash>, nil] Array of entries with name, subAccountUser, master,
|
|
192
|
+
# dexToClearinghouseState (array of [dex, state] tuples), and spotState; nil if
|
|
193
|
+
# the user has no subaccounts
|
|
194
|
+
def sub_accounts2(user)
|
|
195
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'subAccounts2', user: user })
|
|
196
|
+
end
|
|
197
|
+
|
|
168
198
|
# Retrieve details for a vault
|
|
169
199
|
# @param vault_address [String] Vault address
|
|
170
200
|
# @param user [String, nil] Optional wallet address
|
|
@@ -316,6 +346,58 @@ module Hyperliquid
|
|
|
316
346
|
@client.post(Constants::INFO_ENDPOINT, { type: 'maxMarketOrderNtls' })
|
|
317
347
|
end
|
|
318
348
|
|
|
349
|
+
# Retrieve L1 governance votes cast by validators
|
|
350
|
+
# @return [Array] Array of entries with expireTime (ms since epoch), action (hash with
|
|
351
|
+
# either `D` string or `C` array of strings), and votes (array of validator addresses)
|
|
352
|
+
def validator_l1_votes
|
|
353
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'validatorL1Votes' })
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Retrieve gossip root IPs
|
|
357
|
+
# @return [Array<String>] Array of dotted-quad IPv4 addresses
|
|
358
|
+
def gossip_root_ips
|
|
359
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'gossipRootIps' })
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Retrieve a user's legal verification status
|
|
363
|
+
# @param user [String] Wallet address
|
|
364
|
+
# @return [Hash] Keys: ipAllowed (Boolean), acceptedTerms (Boolean), userAllowed (Boolean)
|
|
365
|
+
def legal_check(user)
|
|
366
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'legalCheck', user: user })
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Retrieve the margin requirements table for a given id
|
|
370
|
+
# @param id [Integer] Margin table id
|
|
371
|
+
# @return [Hash] Keys: description (String), marginTiers (Array of { lowerBound, maxLeverage })
|
|
372
|
+
def margin_table(id)
|
|
373
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'marginTable', id: id })
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Retrieve the vaults a user is leading
|
|
377
|
+
# @param user [String] Wallet address
|
|
378
|
+
# @return [Array<Hash>] Array of { address (String), name (String) } entries
|
|
379
|
+
def leading_vaults(user)
|
|
380
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'leadingVaults', user: user })
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Retrieve a user's TWAP order history
|
|
384
|
+
# @param user [String] Wallet address
|
|
385
|
+
# @return [Array<Hash>] Array of entries with time (Integer, sec since epoch), state (Hash),
|
|
386
|
+
# status (Hash with `status` and optional `description`), and optional twapId (Integer)
|
|
387
|
+
def twap_history(user)
|
|
388
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'twapHistory', user: user })
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Retrieve comprehensive user and market data in a single response
|
|
392
|
+
# @param user [String] Wallet address
|
|
393
|
+
# @return [Hash] Aggregated payload containing keys including clearinghouseState,
|
|
394
|
+
# leadingVaults, totalVaultEquity, openOrders, agentAddress, agentValidUntil, cumLedger,
|
|
395
|
+
# meta, assetCtxs, serverTime, isVault, user, twapStates, spotState, spotAssetCtxs,
|
|
396
|
+
# optOutOfSpotDusting, perpsAtOpenInterestCap
|
|
397
|
+
def web_data2(user)
|
|
398
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'webData2', user: user })
|
|
399
|
+
end
|
|
400
|
+
|
|
319
401
|
# ============================
|
|
320
402
|
# Info: Perpetuals
|
|
321
403
|
# ============================
|
|
@@ -341,6 +423,12 @@ module Hyperliquid
|
|
|
341
423
|
@client.post(Constants::INFO_ENDPOINT, { type: 'metaAndAssetCtxs' })
|
|
342
424
|
end
|
|
343
425
|
|
|
426
|
+
# Get trading metadata for all perpetual dexs
|
|
427
|
+
# @return [Array<Hash>] Array of meta payloads (one per dex), each with universe and other fields
|
|
428
|
+
def all_perp_metas
|
|
429
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'allPerpMetas' })
|
|
430
|
+
end
|
|
431
|
+
|
|
344
432
|
# Get user's trading state
|
|
345
433
|
# @param user [String] Wallet address
|
|
346
434
|
# @param dex [String, nil] Optional perp dex name
|
|
@@ -384,6 +472,42 @@ module Hyperliquid
|
|
|
384
472
|
@client.post(Constants::INFO_ENDPOINT, { type: 'perpDexLimits', dex: dex })
|
|
385
473
|
end
|
|
386
474
|
|
|
475
|
+
# Retrieve perp DEX status (e.g. total net deposit) for a builder-deployed dex
|
|
476
|
+
# @param dex [String] Perp dex name; the empty string represents the first perp dex
|
|
477
|
+
# @return [Hash] Keys: totalNetDeposit (String)
|
|
478
|
+
def perp_dex_status(dex)
|
|
479
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'perpDexStatus', dex: dex })
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Retrieve perpetual asset categories
|
|
483
|
+
# @return [Array<Array(String, String)>] Array of [coin, category] tuples
|
|
484
|
+
def perp_categories
|
|
485
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'perpCategories' })
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Retrieve perp annotation for a single perpetual asset
|
|
489
|
+
# @param coin [String] Coin symbol (e.g., "BTC")
|
|
490
|
+
# @return [Hash, nil] Hash with category, description, and optional displayName/keywords;
|
|
491
|
+
# nil if no annotation exists for the coin
|
|
492
|
+
def perp_annotation(coin)
|
|
493
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'perpAnnotation', coin: coin })
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Retrieve concise annotations for all perpetual assets
|
|
497
|
+
# @return [Array<Array>] Array of [coin (String), annotation (Hash)] tuples; each
|
|
498
|
+
# annotation has category and optional displayName/keywords
|
|
499
|
+
def perp_concise_annotations
|
|
500
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'perpConciseAnnotations' })
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Retrieve prediction market outcome metadata
|
|
504
|
+
# @return [Hash] Hash with outcomes (each with outcome, name, description, sideSpecs)
|
|
505
|
+
# and questions (each with question, name, description, fallbackOutcome,
|
|
506
|
+
# namedOutcomes, settledNamedOutcomes)
|
|
507
|
+
def outcome_meta
|
|
508
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'outcomeMeta' })
|
|
509
|
+
end
|
|
510
|
+
|
|
387
511
|
# Retrieve a user's funding history
|
|
388
512
|
# @param user [String]
|
|
389
513
|
# @param start_time [Integer]
|
|
@@ -459,6 +583,80 @@ module Hyperliquid
|
|
|
459
583
|
def token_details(token_id)
|
|
460
584
|
@client.post(Constants::INFO_ENDPOINT, { type: 'tokenDetails', tokenId: token_id })
|
|
461
585
|
end
|
|
586
|
+
|
|
587
|
+
# Get supply, rate, and pending payment information for an aligned quote token
|
|
588
|
+
# @param token [Integer] Token index
|
|
589
|
+
# @return [Hash] Hash with isAligned, firstAlignedTime, evmMintedSupply,
|
|
590
|
+
# dailyAmountOwed (array of [date, amount] tuples), predictedRate
|
|
591
|
+
def aligned_quote_token_info(token)
|
|
592
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'alignedQuoteTokenInfo', token: token })
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# ============================
|
|
596
|
+
# Info: Borrow/Lend (HIP-2)
|
|
597
|
+
# ============================
|
|
598
|
+
|
|
599
|
+
# Retrieve a user's borrow/lend state across tokens
|
|
600
|
+
# @param user [String] Wallet address
|
|
601
|
+
# @return [Hash] Hash with tokenToState (array of [tokenId, state] tuples; state has
|
|
602
|
+
# borrow {basis, value} and supply {basis, value}), health, healthFactor
|
|
603
|
+
def borrow_lend_user_state(user)
|
|
604
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'borrowLendUserState', user: user })
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Retrieve borrow/lend reserve state for a single token
|
|
608
|
+
# @param token [Integer] Token index
|
|
609
|
+
# @return [Hash] Hash with borrowYearlyRate, supplyYearlyRate, balance, utilization,
|
|
610
|
+
# oraclePx, ltv, totalSupplied, totalBorrowed
|
|
611
|
+
def borrow_lend_reserve_state(token)
|
|
612
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'borrowLendReserveState', token: token })
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Retrieve borrow/lend reserve states for all tokens
|
|
616
|
+
# @return [Array<Array>] Array of [reserveId (Integer), state (Hash)] tuples; each
|
|
617
|
+
# state has borrowYearlyRate, supplyYearlyRate, balance, utilization, oraclePx,
|
|
618
|
+
# ltv, totalSupplied, totalBorrowed
|
|
619
|
+
def all_borrow_lend_reserve_states
|
|
620
|
+
@client.post(Constants::INFO_ENDPOINT, { type: 'allBorrowLendReserveStates' })
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Retrieve a user's borrow/lend interest accrual history
|
|
624
|
+
# @param user [String] Wallet address
|
|
625
|
+
# @param start_time [Integer] Start timestamp in milliseconds
|
|
626
|
+
# @param end_time [Integer, nil] Optional end timestamp in milliseconds
|
|
627
|
+
# @return [Array<Hash>] Array of {time, token, borrow, supply} entries; borrow and
|
|
628
|
+
# supply are decimal-string interest amounts
|
|
629
|
+
def user_borrow_lend_interest(user, start_time, end_time = nil)
|
|
630
|
+
body = { type: 'userBorrowLendInterest', user: user, startTime: start_time }
|
|
631
|
+
body[:endTime] = end_time if end_time
|
|
632
|
+
@client.post(Constants::INFO_ENDPOINT, body)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# ============================
|
|
636
|
+
# Info: Explorer RPC
|
|
637
|
+
# ============================
|
|
638
|
+
# The methods below are routed via a separate base URL (the explorer RPC) rather
|
|
639
|
+
# than the canonical /info endpoint. They require the Client to have been
|
|
640
|
+
# constructed with explorer_base_url:; the SDK does this automatically based on
|
|
641
|
+
# the testnet: flag.
|
|
642
|
+
|
|
643
|
+
# Retrieve transaction details by transaction hash
|
|
644
|
+
# @param hash [String] Transaction hash (66-char 0x-prefixed hex)
|
|
645
|
+
# @return [Hash] Hash with type ('txDetails') and tx (transaction details)
|
|
646
|
+
def tx_details(hash)
|
|
647
|
+
@client.post(Constants::EXPLORER_ENDPOINT, { type: 'txDetails', hash: hash }, target: :explorer)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Retrieve a user's transaction history from the explorer
|
|
651
|
+
# @param user [String] Wallet address
|
|
652
|
+
# @return [Hash] Hash with type ('userDetails') and txs (Array of transaction details)
|
|
653
|
+
def user_details(user)
|
|
654
|
+
@client.post(
|
|
655
|
+
Constants::EXPLORER_ENDPOINT,
|
|
656
|
+
{ type: 'userDetails', user: user.downcase },
|
|
657
|
+
target: :explorer
|
|
658
|
+
)
|
|
659
|
+
end
|
|
462
660
|
end
|
|
463
661
|
# rubocop:enable Metrics/ClassLength
|
|
464
662
|
end
|
|
@@ -104,6 +104,23 @@ module Hyperliquid
|
|
|
104
104
|
]
|
|
105
105
|
}.freeze
|
|
106
106
|
|
|
107
|
+
CONVERT_TO_MULTI_SIG_USER_TYPES = {
|
|
108
|
+
'HyperliquidTransaction:ConvertToMultiSigUser': [
|
|
109
|
+
{ name: :hyperliquidChain, type: 'string' },
|
|
110
|
+
{ name: :signers, type: 'string' },
|
|
111
|
+
{ name: :nonce, type: 'uint64' }
|
|
112
|
+
]
|
|
113
|
+
}.freeze
|
|
114
|
+
|
|
115
|
+
USER_SET_ABSTRACTION_TYPES = {
|
|
116
|
+
'HyperliquidTransaction:UserSetAbstraction': [
|
|
117
|
+
{ name: :hyperliquidChain, type: 'string' },
|
|
118
|
+
{ name: :user, type: 'address' },
|
|
119
|
+
{ name: :abstraction, type: 'string' },
|
|
120
|
+
{ name: :nonce, type: 'uint64' }
|
|
121
|
+
]
|
|
122
|
+
}.freeze
|
|
123
|
+
|
|
107
124
|
class << self
|
|
108
125
|
# Domain for L1 actions (orders, cancels, leverage, etc.)
|
|
109
126
|
# @return [Hash] EIP-712 domain configuration
|
data/lib/hyperliquid/version.rb
CHANGED
data/lib/hyperliquid.rb
CHANGED
|
@@ -46,7 +46,13 @@ module Hyperliquid
|
|
|
46
46
|
def initialize(testnet: false, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false,
|
|
47
47
|
private_key: nil, expires_after: nil)
|
|
48
48
|
base_url = testnet ? Constants::TESTNET_API_URL : Constants::MAINNET_API_URL
|
|
49
|
-
|
|
49
|
+
explorer_base_url = testnet ? Constants::TESTNET_RPC_URL : Constants::MAINNET_RPC_URL
|
|
50
|
+
client = Client.new(
|
|
51
|
+
base_url: base_url,
|
|
52
|
+
timeout: timeout,
|
|
53
|
+
retry_enabled: retry_enabled,
|
|
54
|
+
explorer_base_url: explorer_base_url
|
|
55
|
+
)
|
|
50
56
|
|
|
51
57
|
@info = Info.new(client)
|
|
52
58
|
@testnet = testnet
|
|
@@ -9,6 +9,17 @@ require_relative 'test_helpers'
|
|
|
9
9
|
sdk = build_sdk
|
|
10
10
|
separator('TEST 8: USD Class Transfer (Perp <-> Spot)')
|
|
11
11
|
|
|
12
|
+
# Unified-account wallets cannot use usdClassTransfer — perp and spot balances
|
|
13
|
+
# are merged, so the action is disabled at the exchange layer. Detect this up
|
|
14
|
+
# front and skip rather than hitting a predictable "Action disabled" failure.
|
|
15
|
+
abstraction = sdk.info.user_abstraction(sdk.exchange.address)
|
|
16
|
+
if abstraction == 'unifiedAccount'
|
|
17
|
+
puts "SKIPPED: Wallet has unified account active (abstraction=#{abstraction.inspect})."
|
|
18
|
+
puts ' usdClassTransfer is disabled by the exchange when perp/spot balances are unified.'
|
|
19
|
+
test_passed('Test 8 USD Class Transfer')
|
|
20
|
+
exit 0
|
|
21
|
+
end
|
|
22
|
+
|
|
12
23
|
puts 'Transferring $10 from perp to spot...'
|
|
13
24
|
result = sdk.exchange.usd_class_transfer(amount: '10', to_perp: false)
|
|
14
25
|
dump_status(result)
|
|
@@ -14,10 +14,20 @@ max_fee_rate = '0.01%'
|
|
|
14
14
|
perp_coin = 'BTC'
|
|
15
15
|
|
|
16
16
|
# Step 1: Approve builder fee
|
|
17
|
+
# On testnet, the builder may not meet the exchange's minimum balance for approval.
|
|
18
|
+
# That precondition failure is not an SDK bug — we downgrade it to a warning and
|
|
19
|
+
# continue, since the order-with-builder-fee flow (the thing we actually ship)
|
|
20
|
+
# can still be exercised if a prior approval is in place.
|
|
17
21
|
puts "Approving builder fee for #{builder_address} (max #{max_fee_rate})..."
|
|
18
22
|
result = sdk.exchange.approve_builder_fee(builder: builder_address, max_fee_rate: max_fee_rate)
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
if result.is_a?(Hash) && result['status'] == 'err' &&
|
|
24
|
+
result['response'].to_s.include?('insufficient balance')
|
|
25
|
+
puts red("WARNING: #{result['response']}")
|
|
26
|
+
puts ' Continuing — this is a testnet precondition, not an SDK failure.'
|
|
27
|
+
else
|
|
28
|
+
dump_status(result)
|
|
29
|
+
api_error?(result) || puts(green('Builder fee approved'))
|
|
30
|
+
end
|
|
21
31
|
puts
|
|
22
32
|
|
|
23
33
|
wait_with_countdown(WAIT_SECONDS, 'Waiting before placing order with builder...')
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Test 15: Explorer RPC (tx_details + user_details)
|
|
5
|
+
#
|
|
6
|
+
# Calls the explorer RPC for the wallet's recent transactions:
|
|
7
|
+
# 1. user_details(wallet_address) -> list of recent txs
|
|
8
|
+
# 2. tx_details(first tx hash) -> full details of one tx
|
|
9
|
+
#
|
|
10
|
+
# Requires HYPERLIQUID_PRIVATE_KEY (used to derive wallet address; no signing).
|
|
11
|
+
# A wallet with no testnet activity at all will produce a soft warning rather
|
|
12
|
+
# than fail — explorer testnet endpoint occasionally has its own state quirks.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_15_explorer.rb
|
|
16
|
+
|
|
17
|
+
require_relative 'test_helpers'
|
|
18
|
+
|
|
19
|
+
separator('TEST 15: Explorer RPC (tx_details + user_details)')
|
|
20
|
+
|
|
21
|
+
sdk = build_sdk
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
user_details_resp = sdk.info.user_details(sdk.exchange.address)
|
|
25
|
+
rescue Hyperliquid::Error => e
|
|
26
|
+
puts red("user_details FAILED: #{e.class}: #{e.message}")
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless user_details_resp.is_a?(Hash) && user_details_resp['type'] == 'userDetails'
|
|
31
|
+
puts red("user_details: unexpected response shape: #{user_details_resp.inspect}")
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
txs = user_details_resp['txs'] || []
|
|
36
|
+
puts green("user_details OK: #{txs.length} txs returned")
|
|
37
|
+
|
|
38
|
+
if txs.empty?
|
|
39
|
+
puts 'No txs on this wallet — skipping tx_details lookup.'
|
|
40
|
+
test_passed('Test 15 explorer RPC')
|
|
41
|
+
exit 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
first_hash = txs.first['hash']
|
|
45
|
+
unless first_hash.is_a?(String) && first_hash.start_with?('0x')
|
|
46
|
+
puts red("First tx hash missing or malformed: #{first_hash.inspect}")
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
puts "Looking up tx_details for #{first_hash}..."
|
|
51
|
+
begin
|
|
52
|
+
tx_resp = sdk.info.tx_details(first_hash)
|
|
53
|
+
rescue Hyperliquid::Error => e
|
|
54
|
+
puts red("tx_details FAILED: #{e.class}: #{e.message}")
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
unless tx_resp.is_a?(Hash) && tx_resp['type'] == 'txDetails'
|
|
59
|
+
puts red("tx_details: unexpected response shape: #{tx_resp.inspect}")
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
tx = tx_resp['tx']
|
|
64
|
+
unless tx.is_a?(Hash) && tx['hash'] == first_hash
|
|
65
|
+
puts red("tx_details: hash mismatch or missing tx: #{tx.inspect}")
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
puts green("tx_details OK: action=#{tx.dig('action', 'type')} time=#{tx['time']}")
|
|
70
|
+
|
|
71
|
+
test_passed('Test 15 explorer RPC')
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- carter2099
|
|
@@ -90,7 +90,6 @@ files:
|
|
|
90
90
|
- ".rspec"
|
|
91
91
|
- ".rubocop.yml"
|
|
92
92
|
- ".ruby-version"
|
|
93
|
-
- AGENTS.md
|
|
94
93
|
- CHANGELOG.md
|
|
95
94
|
- CLAUDE.md
|
|
96
95
|
- CODE_OF_CONDUCT.md
|
|
@@ -130,6 +129,7 @@ files:
|
|
|
130
129
|
- scripts/test_12_staking.rb
|
|
131
130
|
- scripts/test_13_ws_l2_book.rb
|
|
132
131
|
- scripts/test_14_ws_candle.rb
|
|
132
|
+
- scripts/test_15_explorer.rb
|
|
133
133
|
- scripts/test_all.rb
|
|
134
134
|
- scripts/test_automated.rb
|
|
135
135
|
- scripts/test_helpers.rb
|
data/AGENTS.md
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# AGENTS.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to AI coding agents working with this repository.
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
Ruby SDK (v1.0.1) for the Hyperliquid decentralized exchange API. Three API surfaces: **Info** (read-only market data), **Exchange** (authenticated trading), and **WebSocket** (real-time streaming). Built on Faraday for HTTP, the `eth` gem for EIP-712 signing, `msgpack` for action serialization, and `ws_lite` for WebSocket connections.
|
|
8
|
-
|
|
9
|
-
**Ruby**: >= 3.3.0 (CI tests 3.3, 3.4)
|
|
10
|
-
|
|
11
|
-
## Commands
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
bin/setup # install dependencies
|
|
15
|
-
rake # run tests + linting (CI default)
|
|
16
|
-
rake spec # tests only
|
|
17
|
-
rake rubocop # linting only
|
|
18
|
-
bundle exec rspec spec/hyperliquid/cloid_spec.rb # single file
|
|
19
|
-
bundle exec rspec spec/hyperliquid/cloid_spec.rb:62 # single test by line
|
|
20
|
-
bin/console # IRB with SDK loaded
|
|
21
|
-
ruby example.rb # example usage script
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
### Integration Tests (Testnet)
|
|
25
|
-
```bash
|
|
26
|
-
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_all.rb # all
|
|
27
|
-
HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_08_usd_class_transfer.rb # single
|
|
28
|
-
```
|
|
29
|
-
Integration tests live in `scripts/` as standalone files. `test_integration.rb` at project root is a convenience wrapper.
|
|
30
|
-
|
|
31
|
-
## Architecture
|
|
32
|
-
|
|
33
|
-
### Request Flow
|
|
34
|
-
|
|
35
|
-
The SDK has three parallel API surfaces, all routed through `Hyperliquid::SDK` (`lib/hyperliquid.rb`):
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
Hyperliquid.new(...)
|
|
39
|
-
├── sdk.info → Info → Client → POST /info (always available)
|
|
40
|
-
├── sdk.exchange → Exchange → Client → POST /exchange (requires private_key)
|
|
41
|
-
└── sdk.ws → WS::Client → WSS /ws (real-time streaming)
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
**Info path**: Method builds `{ type: 'someType' }` body → `Client` POSTs to `/info` → parsed JSON returned.
|
|
45
|
-
|
|
46
|
-
**Exchange path**: Method builds action payload → `Signer` generates EIP-712 signature over msgpack-encoded action → `Client` POSTs signed payload to `/exchange` → parsed JSON returned.
|
|
47
|
-
|
|
48
|
-
**WebSocket path**: `WS::Client` manages a persistent WSS connection with subscription tracking, automatic reconnection, ping keepalive (50s), and a bounded message queue (1024 max, drops oldest on overflow). Subscriptions are identified by a canonical key and dispatched via callbacks on a dedicated thread.
|
|
49
|
-
|
|
50
|
-
### Signing (Python SDK Parity)
|
|
51
|
-
|
|
52
|
-
The signing chain in `lib/hyperliquid/signing/` must exactly match the official Python SDK:
|
|
53
|
-
|
|
54
|
-
1. **Action hash**: `keccak256(msgpack(action) + nonce(8B big-endian) + vault_flag + [vault_addr] + [expires_flag + expires_after])`
|
|
55
|
-
2. **Phantom agent**: `{ source: 'a'|'b', connectionId: action_hash }` (a=mainnet, b=testnet)
|
|
56
|
-
3. **EIP-712 signature** over phantom agent with Exchange domain (chain ID 1337)
|
|
57
|
-
|
|
58
|
-
Any change to signing must maintain parity with the Python SDK or transactions will be rejected.
|
|
59
|
-
|
|
60
|
-
### Numeric Conversion
|
|
61
|
-
|
|
62
|
-
**float_to_wire** (in Exchange): Converts to string with 8 decimal precision, validates rounding tolerance (1e-12), normalizes trailing zeros. No scientific notation.
|
|
63
|
-
|
|
64
|
-
**Market order pricing** (_slippage_price): Apply slippage (default 5%) to mid price → round to 5 significant figures → round to `(6 for perp, 8 for spot) - szDecimals` decimal places.
|
|
65
|
-
|
|
66
|
-
**Spot vs Perp**: Assets with index >= 10,000 are spot (`SPOT_ASSET_THRESHOLD` in Exchange). This affects decimal place calculations.
|
|
67
|
-
|
|
68
|
-
### Testing
|
|
69
|
-
|
|
70
|
-
- **Unit tests** (`spec/`): RSpec + WebMock. WebMock resets between tests. Monkey-patching disabled. Test files mirror `lib/` structure.
|
|
71
|
-
- **Integration tests** (`scripts/`): Run against testnet with a real private key. Each script is self-contained.
|
|
72
|
-
|
|
73
|
-
### Code Style
|
|
74
|
-
|
|
75
|
-
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.
|
|
76
|
-
|
|
77
|
-
### CI
|
|
78
|
-
|
|
79
|
-
GitHub Actions (`.github/workflows/main.yml`): runs `bundle exec rake` (tests + lint) on Ruby 3.3 and 3.4 for pushes to main and all PRs. Release workflow creates GitHub releases from CHANGELOG.md on version tags.
|
|
80
|
-
|
|
81
|
-
## Additional Docs
|
|
82
|
-
|
|
83
|
-
Detailed API reference, examples, WebSocket guide, configuration, and error handling in `docs/`.
|