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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9976736621e86406eb3de9e8e4827b59d1c96c86aac1efcff2ee700afa78429
4
- data.tar.gz: 79c0a9f8587b1af017f02539e2c35b27e7311ba7e00328c0147db636483aed28
3
+ metadata.gz: 3bb8af9ff28e902372f37910705473b980d432cf4a04485dbe5999e7fa6edc7e
4
+ data.tar.gz: e7176efc9fd95c1662b36ec7b8f5536192d8ce3e41624f9b341eca5e79de079a
5
5
  SHA512:
6
- metadata.gz: 89e3b1c854bdb152494abc51ed5f7a3dcfe3c354226d7d92ed994ef8f99d9d5fabcf91d81ddedb18bf083bcc9f3cc4952578b2bfe1d95cc2d8c35b359eb392b1
7
- data.tar.gz: c8fd08574cfa29c927bd5c848ca5bd578914bc82150af2ab0305c38ea84785b22f4f92bed0ca07e49f86f31c8dde089fd83c47e73c58678c807a0dacf5dc9b75
6
+ metadata.gz: 79d01ffef7d490e185e191285dab90adcd339b03c8f2936ab8eaccb1fba6bc832a76aa49e2cee3db354608a5ce53e92afda6c6efec5d8b2cafc0c1e9a51f7b44
7
+ data.tar.gz: c313423aab3b257d81b7447c840502025f1a92cf857064c614678be2022b533ce7e85a89bedf0aaedd371c5781425962ee48b8f8c9ac7be98f3940b08c756510
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.8
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 Claude Code (claude.ai/code) when working with code in this repository.
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
- AGENTS.md is the canonical source of truth; this file exists only as a Claude Code convenience entrypoint.
6
- For comprehensive architecture documentation and implementation details, see **[AGENTS.md](./AGENTS.md)**.
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`).
@@ -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
- def initialize(base_url:, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false)
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
- @connection = build_connection(base_url, timeout)
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
- def post(endpoint, body = {})
45
- response = @connection.post(endpoint) do |req|
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 build_connection(base_url, timeout)
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'
@@ -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
@@ -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!
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperliquid
4
- VERSION = '1.1.0'
4
+ VERSION = '1.3.0'
5
5
  end
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
- client = Client.new(base_url: base_url, timeout: timeout, retry_enabled: retry_enabled)
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
- dump_status(result)
20
- api_error?(result) || puts(green('Builder fee approved'))
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
@@ -32,7 +32,8 @@ SCRIPTS = [
32
32
  'test_11_builder_fee.rb',
33
33
  'test_12_staking.rb',
34
34
  'test_13_ws_l2_book.rb',
35
- 'test_14_ws_candle.rb'
35
+ 'test_14_ws_candle.rb',
36
+ 'test_15_explorer.rb'
36
37
  ].freeze
37
38
 
38
39
  def green(text)
@@ -22,7 +22,8 @@ SCRIPTS = [
22
22
  'test_10_vault.rb',
23
23
  'test_11_builder_fee.rb',
24
24
  'test_13_ws_l2_book.rb',
25
- 'test_14_ws_candle.rb'
25
+ 'test_14_ws_candle.rb',
26
+ 'test_15_explorer.rb'
26
27
  ].freeze
27
28
 
28
29
  def green(text)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperliquid
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
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/`.