hyperliquid 0.3.0 → 0.4.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.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperliquid
4
+ module Signing
5
+ # EIP-712 domain and type definitions for Hyperliquid
6
+ # These values are defined by the Hyperliquid protocol and must match exactly
7
+ class EIP712
8
+ # Hyperliquid L1 chain ID (same for mainnet and testnet)
9
+ L1_CHAIN_ID = 1337
10
+
11
+ # Source identifier for phantom agent
12
+ MAINNET_SOURCE = 'a'
13
+ TESTNET_SOURCE = 'b'
14
+
15
+ class << self
16
+ # Domain for L1 actions (orders, cancels, leverage, etc.)
17
+ # @return [Hash] EIP-712 domain configuration
18
+ def l1_action_domain
19
+ {
20
+ name: 'Exchange',
21
+ version: '1',
22
+ chainId: L1_CHAIN_ID,
23
+ verifyingContract: '0x0000000000000000000000000000000000000000'
24
+ }
25
+ end
26
+
27
+ # EIP-712 domain type definition
28
+ # @return [Array<Hash>] Domain type fields
29
+ def domain_type
30
+ [
31
+ { name: :name, type: 'string' },
32
+ { name: :version, type: 'string' },
33
+ { name: :chainId, type: 'uint256' },
34
+ { name: :verifyingContract, type: 'address' }
35
+ ]
36
+ end
37
+
38
+ # Agent type for phantom agent signing
39
+ # @return [Array<Hash>] Agent type fields
40
+ def agent_type
41
+ [
42
+ { name: :source, type: 'string' },
43
+ { name: :connectionId, type: 'bytes32' }
44
+ ]
45
+ end
46
+
47
+ # Get source identifier for phantom agent
48
+ # @param testnet [Boolean] Whether testnet
49
+ # @return [String] Source identifier ('a' for mainnet, 'b' for testnet)
50
+ def source(testnet:)
51
+ testnet ? TESTNET_SOURCE : MAINNET_SOURCE
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'eth'
4
+ require 'msgpack'
5
+
6
+ module Hyperliquid
7
+ module Signing
8
+ # EIP-712 signature generation for Hyperliquid exchange operations
9
+ # Implements the phantom agent signing scheme used by Hyperliquid
10
+ class Signer
11
+ # Initialize a new signer
12
+ # @param private_key [String] Ethereum private key (hex string with or without 0x prefix)
13
+ # @param testnet [Boolean] Whether to sign for testnet (default: false)
14
+ def initialize(private_key:, testnet: false)
15
+ @testnet = testnet
16
+ @key = Eth::Key.new(priv: normalize_private_key(private_key))
17
+ end
18
+
19
+ # Get the wallet address
20
+ # @return [String] Checksummed Ethereum address
21
+ def address
22
+ @key.address.to_s
23
+ end
24
+
25
+ # Sign an L1 action (orders, cancels, leverage updates, etc.)
26
+ # @param action [Hash] The action payload to sign
27
+ # @param nonce [Integer] Timestamp in milliseconds
28
+ # @param vault_address [String, nil] Optional vault address for vault trading
29
+ # @param expires_after [Integer, nil] Optional expiration timestamp in milliseconds
30
+ # @return [Hash] Signature with :r, :s, :v components
31
+ def sign_l1_action(action, nonce, vault_address: nil, expires_after: nil)
32
+ phantom_agent = construct_phantom_agent(action, nonce, vault_address, expires_after)
33
+
34
+ typed_data = {
35
+ types: {
36
+ EIP712Domain: EIP712.domain_type,
37
+ Agent: EIP712.agent_type
38
+ },
39
+ primaryType: 'Agent',
40
+ domain: EIP712.l1_action_domain,
41
+ message: phantom_agent
42
+ }
43
+
44
+ sign_typed_data(typed_data)
45
+ end
46
+
47
+ private
48
+
49
+ # Normalize private key format
50
+ # @param key [String] Private key with or without 0x prefix
51
+ # @return [String] Private key with 0x prefix
52
+ def normalize_private_key(key)
53
+ key.start_with?('0x') ? key : "0x#{key}"
54
+ end
55
+
56
+ # Construct the phantom agent for signing
57
+ # Maintains parity with Python SDK
58
+ # @param action [Hash] Action payload
59
+ # @param nonce [Integer] Nonce timestamp
60
+ # @param vault_address [String, nil] Optional vault address
61
+ # @param expires_after [Integer, nil] Optional expiration timestamp
62
+ # @return [Hash] Phantom agent with source and connectionId
63
+ def construct_phantom_agent(action, nonce, vault_address, expires_after)
64
+ # Compute action hash
65
+ # Maintains parity with Python SDK
66
+ # data = msgpack(action) + nonce(8 bytes BE) + vault_flag + [vault_addr] + [expires_flag + expires_after]
67
+ # - Note: expires_flag is only included if expires_after exists. A bit odd but that's what the
68
+ # Python SDK does.
69
+ data = action.to_msgpack
70
+ data += [nonce].pack('Q>') # 8-byte big-endian uint64
71
+
72
+ if vault_address.nil?
73
+ data += "\x00" # no vault flag
74
+ else
75
+ data += "\x01" # has vault flag
76
+ data += address_to_bytes(vault_address.downcase)
77
+ end
78
+
79
+ unless expires_after.nil?
80
+ data += "\x00" # expiration flag
81
+ data += [expires_after].pack('Q>') # 8-byte big-endian uint64
82
+ end
83
+
84
+ connection_id = Eth::Util.keccak256(data)
85
+
86
+ {
87
+ source: EIP712.source(testnet: @testnet),
88
+ connectionId: bin_to_hex(connection_id)
89
+ }
90
+ end
91
+
92
+ # Convert hex address to 20-byte binary
93
+ # @param address [String] Ethereum address with 0x prefix
94
+ # @return [String] 20-byte binary representation
95
+ def address_to_bytes(address)
96
+ [address.sub(/\A0x/i, '')].pack('H*')
97
+ end
98
+
99
+ # Sign EIP-712 typed data using eth gem's built-in method
100
+ # @param typed_data [Hash] Complete EIP-712 structure
101
+ # @return [Hash] Signature components :r, :s, :v
102
+ def sign_typed_data(typed_data)
103
+ signature = @key.sign_typed_data(typed_data)
104
+
105
+ # Parse signature hex string into components
106
+ # Format: r (64 hex chars) + s (64 hex chars) + v (2 hex chars)
107
+ {
108
+ r: "0x#{signature[0, 64]}",
109
+ s: "0x#{signature[64, 64]}",
110
+ v: signature[128, 2].to_i(16)
111
+ }
112
+ end
113
+
114
+ # Convert binary data to hex string with 0x prefix
115
+ # @param bin [String] Binary data
116
+ # @return [String] Hex string with 0x prefix
117
+ def bin_to_hex(bin)
118
+ "0x#{bin.unpack1('H*')}"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperliquid
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/hyperliquid.rb CHANGED
@@ -5,33 +5,61 @@ require_relative 'hyperliquid/constants'
5
5
  require_relative 'hyperliquid/errors'
6
6
  require_relative 'hyperliquid/client'
7
7
  require_relative 'hyperliquid/info'
8
+ require_relative 'hyperliquid/cloid'
9
+ require_relative 'hyperliquid/signing/eip712'
10
+ require_relative 'hyperliquid/signing/signer'
11
+ require_relative 'hyperliquid/exchange'
8
12
 
9
13
  # Ruby SDK for Hyperliquid API
10
- # Provides read-only access to Hyperliquid's decentralized exchange API
14
+ # Provides access to Hyperliquid's decentralized exchange API
15
+ # including both read (Info) and write (Exchange) operations
11
16
  module Hyperliquid
12
17
  # Create a new SDK instance
13
18
  # @param testnet [Boolean] Whether to use testnet (default: false for mainnet)
14
19
  # @param timeout [Integer] Request timeout in seconds (default: 30)
15
20
  # @param retry_enabled [Boolean] Whether to enable retry logic (default: false)
21
+ # @param private_key [String, nil] Ethereum private key for exchange operations (optional)
22
+ # @param expires_after [Integer, nil] Global order expiration timestamp in ms (optional)
16
23
  # @return [Hyperliquid::SDK] A new SDK instance
17
- def self.new(testnet: false, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false)
18
- SDK.new(testnet: testnet, timeout: timeout, retry_enabled: retry_enabled)
24
+ def self.new(testnet: false, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false,
25
+ private_key: nil, expires_after: nil)
26
+ SDK.new(
27
+ testnet: testnet,
28
+ timeout: timeout,
29
+ retry_enabled: retry_enabled,
30
+ private_key: private_key,
31
+ expires_after: expires_after
32
+ )
19
33
  end
20
34
 
21
35
  # Main SDK class
22
36
  class SDK
23
- attr_reader :info
37
+ attr_reader :info, :exchange
24
38
 
25
39
  # Initialize the SDK
26
40
  # @param testnet [Boolean] Whether to use testnet (default: false for mainnet)
27
41
  # @param timeout [Integer] Request timeout in seconds
28
42
  # @param retry_enabled [Boolean] Whether to enable retry logic (default: false)
29
- def initialize(testnet: false, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false)
43
+ # @param private_key [String, nil] Ethereum private key for exchange operations (optional)
44
+ # @param expires_after [Integer, nil] Global order expiration timestamp in ms (optional)
45
+ def initialize(testnet: false, timeout: Constants::DEFAULT_TIMEOUT, retry_enabled: false,
46
+ private_key: nil, expires_after: nil)
30
47
  base_url = testnet ? Constants::TESTNET_API_URL : Constants::MAINNET_API_URL
31
48
  client = Client.new(base_url: base_url, timeout: timeout, retry_enabled: retry_enabled)
32
49
 
33
50
  @info = Info.new(client)
34
51
  @testnet = testnet
52
+ @exchange = nil
53
+
54
+ return unless private_key
55
+
56
+ signer = Signing::Signer.new(private_key: private_key, testnet: testnet)
57
+ @exchange = Exchange.new(
58
+ client: client,
59
+ signer: signer,
60
+ info: @info,
61
+ expires_after: expires_after
62
+ )
35
63
  end
36
64
 
37
65
  # Check if using testnet
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Hyperliquid Ruby SDK - Testnet Integration Test
5
+ #
6
+ # This script tests the Exchange API against the live testnet:
7
+ # 1. Spot market roundtrip (buy PURR, sell PURR)
8
+ # 2. Spot limit order (place and cancel)
9
+ # 3. Perp market roundtrip (long BTC, close position)
10
+ # 4. Perp limit order (place short, cancel)
11
+ #
12
+ # Prerequisites:
13
+ # - Testnet wallet with USDC balance
14
+ # - Get testnet funds from: https://app.hyperliquid-testnet.xyz
15
+ #
16
+ # Usage:
17
+ # HYPERLIQUID_PRIVATE_KEY=0x... ruby test_integration.rb
18
+ #
19
+ # Note: This script executes real trades on testnet. No real funds are at risk.
20
+
21
+ require_relative 'lib/hyperliquid'
22
+ require 'json'
23
+
24
+ WAIT_SECONDS = 10
25
+ SPOT_SLIPPAGE = 0.40 # 40% for illiquid testnet spot markets
26
+ PERP_SLIPPAGE = 0.05 # 5% for perp markets
27
+
28
+ def separator(title)
29
+ puts
30
+ puts '=' * 60
31
+ puts title
32
+ puts '=' * 60
33
+ puts
34
+ end
35
+
36
+ def wait_with_countdown(seconds, message)
37
+ puts message
38
+ seconds.downto(1) do |i|
39
+ print "\r #{i} seconds remaining... "
40
+ sleep 1
41
+ end
42
+ puts "\r Done! "
43
+ puts
44
+ end
45
+
46
+ def check_result(result, operation)
47
+ status = result.dig('response', 'data', 'statuses', 0)
48
+
49
+ if status.is_a?(Hash) && status['error']
50
+ puts "FAILED: #{status['error']}"
51
+ return false
52
+ end
53
+
54
+ if status.is_a?(Hash) && status['resting']
55
+ puts "Order resting with OID: #{status['resting']['oid']}"
56
+ return status['resting']['oid']
57
+ end
58
+
59
+ if status == 'success' || (status.is_a?(Hash) && status['filled'])
60
+ puts "#{operation} successful!"
61
+ return true
62
+ end
63
+
64
+ puts "Result: #{status.inspect}"
65
+ true
66
+ end
67
+
68
+ # --- Main Script ---
69
+
70
+ private_key = ENV['HYPERLIQUID_PRIVATE_KEY']
71
+ unless private_key
72
+ puts 'Error: Set HYPERLIQUID_PRIVATE_KEY environment variable'
73
+ puts 'Usage: HYPERLIQUID_PRIVATE_KEY=0x... ruby test_integration.rb'
74
+ exit 1
75
+ end
76
+
77
+ sdk = Hyperliquid.new(
78
+ testnet: true,
79
+ private_key: private_key
80
+ )
81
+
82
+ puts 'Hyperliquid Ruby SDK - Testnet Integration Test'
83
+ puts '=' * 60
84
+ puts "Wallet: #{sdk.exchange.address}"
85
+ puts 'Network: Testnet'
86
+ puts "Testnet UI: https://app.hyperliquid-testnet.xyz"
87
+
88
+ # ============================================================
89
+ # TEST 1: Spot Market Roundtrip (PURR/USDC)
90
+ # ============================================================
91
+ separator('TEST 1: Spot Market Roundtrip (PURR/USDC)')
92
+
93
+ spot_coin = 'PURR/USDC'
94
+ spot_size = 5 # PURR has 0 decimals
95
+
96
+ mids = sdk.info.all_mids
97
+ spot_price = mids[spot_coin]&.to_f
98
+
99
+ if spot_price&.positive?
100
+ puts "#{spot_coin} mid: $#{spot_price}"
101
+ puts "Size: #{spot_size} PURR (~$#{(spot_size * spot_price).round(2)})"
102
+ puts "Slippage: #{(SPOT_SLIPPAGE * 100).to_i}%"
103
+ puts
104
+
105
+ # Buy
106
+ puts 'Placing market BUY...'
107
+ result = sdk.exchange.market_order(
108
+ coin: spot_coin,
109
+ is_buy: true,
110
+ size: spot_size,
111
+ slippage: SPOT_SLIPPAGE
112
+ )
113
+ check_result(result, 'Buy')
114
+
115
+ wait_with_countdown(WAIT_SECONDS, 'Waiting before sell...')
116
+
117
+ # Sell
118
+ puts 'Placing market SELL...'
119
+ result = sdk.exchange.market_order(
120
+ coin: spot_coin,
121
+ is_buy: false,
122
+ size: spot_size,
123
+ slippage: SPOT_SLIPPAGE
124
+ )
125
+ check_result(result, 'Sell')
126
+ else
127
+ puts "SKIPPED: Could not get #{spot_coin} price"
128
+ end
129
+
130
+ # ============================================================
131
+ # TEST 2: Spot Limit Order (Place and Cancel)
132
+ # ============================================================
133
+ separator('TEST 2: Spot Limit Order (Place and Cancel)')
134
+
135
+ if spot_price&.positive?
136
+ # Place limit buy well below market (won't fill)
137
+ limit_price = (spot_price * 0.50).round(2) # 50% below mid
138
+ puts "#{spot_coin} mid: $#{spot_price}"
139
+ puts "Limit price: $#{limit_price} (50% below mid - won't fill)"
140
+ puts "Size: #{spot_size} PURR"
141
+ puts
142
+
143
+ puts 'Placing limit BUY order...'
144
+ result = sdk.exchange.order(
145
+ coin: spot_coin,
146
+ is_buy: true,
147
+ size: spot_size,
148
+ limit_px: limit_price,
149
+ order_type: { limit: { tif: 'Gtc' } },
150
+ reduce_only: false
151
+ )
152
+ oid = check_result(result, 'Limit order')
153
+
154
+ if oid.is_a?(Integer)
155
+ wait_with_countdown(WAIT_SECONDS, 'Order resting. Waiting before cancel...')
156
+
157
+ puts "Canceling order #{oid}..."
158
+ result = sdk.exchange.cancel(coin: spot_coin, oid: oid)
159
+ check_result(result, 'Cancel')
160
+ end
161
+ else
162
+ puts "SKIPPED: Could not get #{spot_coin} price"
163
+ end
164
+
165
+ # ============================================================
166
+ # TEST 3: Perp Market Roundtrip (BTC Long)
167
+ # ============================================================
168
+ separator('TEST 3: Perp Market Roundtrip (BTC Long)')
169
+
170
+ perp_coin = 'BTC'
171
+ btc_price = mids[perp_coin]&.to_f
172
+
173
+ if btc_price&.positive?
174
+ # Get BTC metadata for size precision
175
+ meta = sdk.info.meta
176
+ btc_meta = meta['universe'].find { |a| a['name'] == perp_coin }
177
+ sz_decimals = btc_meta['szDecimals']
178
+
179
+ # Calculate size for ~$20 notional
180
+ perp_size = (20.0 / btc_price).ceil(sz_decimals)
181
+
182
+ puts "#{perp_coin} mid: $#{btc_price.round(2)}"
183
+ puts "Size: #{perp_size} BTC (~$#{(perp_size * btc_price).round(2)})"
184
+ puts "Slippage: #{(PERP_SLIPPAGE * 100).to_i}%"
185
+ puts
186
+
187
+ # Open long
188
+ puts 'Opening LONG position (market buy)...'
189
+ result = sdk.exchange.market_order(
190
+ coin: perp_coin,
191
+ is_buy: true,
192
+ size: perp_size,
193
+ slippage: PERP_SLIPPAGE
194
+ )
195
+ check_result(result, 'Long open')
196
+
197
+ wait_with_countdown(WAIT_SECONDS, 'Position open. Waiting before close...')
198
+
199
+ # Close long (sell to close)
200
+ puts 'Closing LONG position (market sell)...'
201
+ result = sdk.exchange.market_order(
202
+ coin: perp_coin,
203
+ is_buy: false,
204
+ size: perp_size,
205
+ slippage: PERP_SLIPPAGE
206
+ )
207
+ check_result(result, 'Long close')
208
+ else
209
+ puts "SKIPPED: Could not get #{perp_coin} price"
210
+ end
211
+
212
+ # ============================================================
213
+ # TEST 4: Perp Limit Order (Short, then Cancel)
214
+ # ============================================================
215
+ separator('TEST 4: Perp Limit Order (Short, then Cancel)')
216
+
217
+ if btc_price&.positive?
218
+ # Place limit sell well above market (won't fill)
219
+ limit_price = (btc_price * 1.50).round(0).to_i # 50% above mid, whole number tick
220
+ perp_size = (20.0 / btc_price).ceil(sz_decimals)
221
+
222
+ puts "#{perp_coin} mid: $#{btc_price.round(2)}"
223
+ puts "Limit price: $#{limit_price} (50% above mid - won't fill)"
224
+ puts "Size: #{perp_size} BTC"
225
+ puts
226
+
227
+ puts 'Placing limit SELL order (short)...'
228
+ result = sdk.exchange.order(
229
+ coin: perp_coin,
230
+ is_buy: false,
231
+ size: perp_size,
232
+ limit_px: limit_price,
233
+ order_type: { limit: { tif: 'Gtc' } },
234
+ reduce_only: false
235
+ )
236
+ oid = check_result(result, 'Limit short')
237
+
238
+ if oid.is_a?(Integer)
239
+ wait_with_countdown(WAIT_SECONDS, 'Order resting. Waiting before cancel...')
240
+
241
+ puts "Canceling order #{oid}..."
242
+ result = sdk.exchange.cancel(coin: perp_coin, oid: oid)
243
+ check_result(result, 'Cancel')
244
+ end
245
+ else
246
+ puts "SKIPPED: Could not get #{perp_coin} price"
247
+ end
248
+
249
+ # ============================================================
250
+ # Summary
251
+ # ============================================================
252
+ separator('INTEGRATION TEST COMPLETE')
253
+ puts 'All tests executed. Check your testnet wallet for trade history:'
254
+ puts 'https://app.hyperliquid-testnet.xyz'
255
+ puts
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: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - carter2099
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: eth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.5'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: faraday
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +51,20 @@ dependencies:
37
51
  - - "~>"
38
52
  - !ruby/object:Gem::Version
39
53
  version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: msgpack
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.7'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.7'
40
68
  description: A Ruby SDK for interacting with Hyperliquid's decentralized exchange
41
69
  API
42
70
  email:
@@ -49,6 +77,7 @@ files:
49
77
  - ".rubocop.yml"
50
78
  - ".ruby-version"
51
79
  - CHANGELOG.md
80
+ - CLAUDE.md
52
81
  - CODE_OF_CONDUCT.md
53
82
  - LICENSE.txt
54
83
  - README.md
@@ -56,11 +85,16 @@ files:
56
85
  - example.rb
57
86
  - lib/hyperliquid.rb
58
87
  - lib/hyperliquid/client.rb
88
+ - lib/hyperliquid/cloid.rb
59
89
  - lib/hyperliquid/constants.rb
60
90
  - lib/hyperliquid/errors.rb
91
+ - lib/hyperliquid/exchange.rb
61
92
  - lib/hyperliquid/info.rb
93
+ - lib/hyperliquid/signing/eip712.rb
94
+ - lib/hyperliquid/signing/signer.rb
62
95
  - lib/hyperliquid/version.rb
63
96
  - sig/hyperliquid.rbs
97
+ - test_integration.rb
64
98
  homepage: https://github.com/carter2099/hyperliquid
65
99
  licenses:
66
100
  - MIT