hyperliquid 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Hyperliquid
6
+ # Exchange API client for write operations (orders, cancels, etc.)
7
+ # Requires a private key for signing transactions
8
+ class Exchange
9
+ # Default slippage for market orders (5%)
10
+ DEFAULT_SLIPPAGE = 0.05
11
+
12
+ # Spot assets have indices >= 10000
13
+ SPOT_ASSET_THRESHOLD = 10_000
14
+
15
+ # Initialize the exchange client
16
+ # @param client [Hyperliquid::Client] HTTP client
17
+ # @param signer [Hyperliquid::Signing::Signer] EIP-712 signer
18
+ # @param info [Hyperliquid::Info] Info API client for metadata
19
+ # @param expires_after [Integer, nil] Optional global expiration timestamp
20
+ def initialize(client:, signer:, info:, expires_after: nil)
21
+ @client = client
22
+ @signer = signer
23
+ @info = info
24
+ @expires_after = expires_after
25
+ @asset_cache = nil
26
+ end
27
+
28
+ # Get the wallet address
29
+ # @return [String] Checksummed Ethereum address
30
+ def address
31
+ @signer.address
32
+ end
33
+
34
+ # Place a single order
35
+ # @param coin [String] Asset symbol (e.g., "BTC")
36
+ # @param is_buy [Boolean] True for buy, false for sell
37
+ # @param size [String, Numeric] Order size
38
+ # @param limit_px [String, Numeric] Limit price
39
+ # @param order_type [Hash] Order type config (default: { limit: { tif: "Gtc" } })
40
+ # @param reduce_only [Boolean] Reduce-only flag (default: false)
41
+ # @param cloid [Cloid, String, nil] Client order ID (optional)
42
+ # @param vault_address [String, nil] Vault address for vault trading (optional)
43
+ # @return [Hash] Order response
44
+ def order(coin:, is_buy:, size:, limit_px:, order_type: { limit: { tif: 'Gtc' } },
45
+ reduce_only: false, cloid: nil, vault_address: nil)
46
+ nonce = timestamp_ms
47
+
48
+ order_wire = build_order_wire(
49
+ coin: coin,
50
+ is_buy: is_buy,
51
+ size: size,
52
+ limit_px: limit_px,
53
+ order_type: order_type,
54
+ reduce_only: reduce_only,
55
+ cloid: cloid
56
+ )
57
+
58
+ action = {
59
+ type: 'order',
60
+ orders: [order_wire],
61
+ grouping: 'na'
62
+ }
63
+
64
+ signature = @signer.sign_l1_action(
65
+ action, nonce,
66
+ vault_address: vault_address,
67
+ expires_after: @expires_after
68
+ )
69
+ post_action(action, signature, nonce, vault_address)
70
+ end
71
+
72
+ # Place multiple orders in a batch
73
+ # @param orders [Array<Hash>] Array of order hashes with keys:
74
+ # :coin, :is_buy, :size, :limit_px, :order_type, :reduce_only, :cloid
75
+ # @param grouping [String] Order grouping ("na", "normalTpsl", "positionTpsl")
76
+ # @param vault_address [String, nil] Vault address for vault trading (optional)
77
+ # @return [Hash] Bulk order response
78
+ def bulk_orders(orders:, grouping: 'na', vault_address: nil)
79
+ nonce = timestamp_ms
80
+
81
+ order_wires = orders.map do |o|
82
+ build_order_wire(
83
+ coin: o[:coin],
84
+ is_buy: o[:is_buy],
85
+ size: o[:size],
86
+ limit_px: o[:limit_px],
87
+ order_type: o[:order_type] || { limit: { tif: 'Gtc' } },
88
+ reduce_only: o[:reduce_only] || false,
89
+ cloid: o[:cloid]
90
+ )
91
+ end
92
+
93
+ action = {
94
+ type: 'order',
95
+ orders: order_wires,
96
+ grouping: grouping
97
+ }
98
+
99
+ signature = @signer.sign_l1_action(
100
+ action, nonce,
101
+ vault_address: vault_address,
102
+ expires_after: @expires_after
103
+ )
104
+ post_action(action, signature, nonce, vault_address)
105
+ end
106
+
107
+ # Place a market order (aggressive limit IoC with slippage)
108
+ # @param coin [String] Asset symbol
109
+ # @param is_buy [Boolean] True for buy, false for sell
110
+ # @param size [String, Numeric] Order size
111
+ # @param slippage [Float] Slippage tolerance (default: 0.05 = 5%)
112
+ # @param vault_address [String, nil] Vault address for vault trading (optional)
113
+ # @return [Hash] Order response
114
+ def market_order(coin:, is_buy:, size:, slippage: DEFAULT_SLIPPAGE, vault_address: nil)
115
+ # Get current mid price
116
+ mids = @info.all_mids
117
+ mid = mids[coin]&.to_f
118
+ raise ArgumentError, "Unknown asset or no price available: #{coin}" unless mid&.positive?
119
+
120
+ # Apply slippage and round to appropriate precision
121
+ slippage_price = calculate_slippage_price(coin, mid, is_buy, slippage)
122
+
123
+ order(
124
+ coin: coin,
125
+ is_buy: is_buy,
126
+ size: size,
127
+ limit_px: slippage_price,
128
+ order_type: { limit: { tif: 'Ioc' } },
129
+ vault_address: vault_address
130
+ )
131
+ end
132
+
133
+ # Cancel a single order by order ID
134
+ # @param coin [String] Asset symbol
135
+ # @param oid [Integer] Order ID
136
+ # @param vault_address [String, nil] Vault address for vault trading (optional)
137
+ # @return [Hash] Cancel response
138
+ def cancel(coin:, oid:, vault_address: nil)
139
+ nonce = timestamp_ms
140
+
141
+ action = {
142
+ type: 'cancel',
143
+ cancels: [{ a: asset_index(coin), o: oid }]
144
+ }
145
+
146
+ signature = @signer.sign_l1_action(
147
+ action, nonce,
148
+ vault_address: vault_address,
149
+ expires_after: @expires_after
150
+ )
151
+ post_action(action, signature, nonce, vault_address)
152
+ end
153
+
154
+ # Cancel a single order by client order ID
155
+ # @param coin [String] Asset symbol
156
+ # @param cloid [Cloid, String] Client order ID
157
+ # @param vault_address [String, nil] Vault address for vault trading (optional)
158
+ # @return [Hash] Cancel response
159
+ def cancel_by_cloid(coin:, cloid:, vault_address: nil)
160
+ nonce = timestamp_ms
161
+ cloid_raw = normalize_cloid(cloid)
162
+
163
+ action = {
164
+ type: 'cancelByCloid',
165
+ cancels: [{ asset: asset_index(coin), cloid: cloid_raw }]
166
+ }
167
+
168
+ signature = @signer.sign_l1_action(
169
+ action, nonce,
170
+ vault_address: vault_address,
171
+ expires_after: @expires_after
172
+ )
173
+ post_action(action, signature, nonce, vault_address)
174
+ end
175
+
176
+ # Cancel multiple orders by order ID
177
+ # @param cancels [Array<Hash>] Array of cancel hashes with :coin and :oid
178
+ # @param vault_address [String, nil] Vault address for vault trading (optional)
179
+ # @return [Hash] Bulk cancel response
180
+ def bulk_cancel(cancels:, vault_address: nil)
181
+ nonce = timestamp_ms
182
+
183
+ cancel_wires = cancels.map do |c|
184
+ { a: asset_index(c[:coin]), o: c[:oid] }
185
+ end
186
+ action = { type: 'cancel', cancels: cancel_wires }
187
+
188
+ signature = @signer.sign_l1_action(
189
+ action, nonce,
190
+ vault_address: vault_address,
191
+ expires_after: @expires_after
192
+ )
193
+ post_action(action, signature, nonce, vault_address)
194
+ end
195
+
196
+ # Cancel multiple orders by client order ID
197
+ # @param cancels [Array<Hash>] Array of cancel hashes with :coin and :cloid
198
+ # @param vault_address [String, nil] Vault address for vault trading (optional)
199
+ # @return [Hash] Bulk cancel by cloid response
200
+ def bulk_cancel_by_cloid(cancels:, vault_address: nil)
201
+ nonce = timestamp_ms
202
+
203
+ cancel_wires = cancels.map do |c|
204
+ { asset: asset_index(c[:coin]), cloid: normalize_cloid(c[:cloid]) }
205
+ end
206
+ action = { type: 'cancelByCloid', cancels: cancel_wires }
207
+
208
+ signature = @signer.sign_l1_action(
209
+ action, nonce,
210
+ vault_address: vault_address,
211
+ expires_after: @expires_after
212
+ )
213
+ post_action(action, signature, nonce, vault_address)
214
+ end
215
+
216
+ # Clear the asset metadata cache
217
+ # Call this if metadata has been updated
218
+ def reload_metadata!
219
+ @asset_cache = nil
220
+ end
221
+
222
+ private
223
+
224
+ # Build order wire format
225
+ def build_order_wire(coin:, is_buy:, size:, limit_px:, order_type:, reduce_only:, cloid:)
226
+ wire = {
227
+ a: asset_index(coin),
228
+ b: is_buy,
229
+ p: float_to_wire(limit_px),
230
+ s: float_to_wire(size),
231
+ r: reduce_only,
232
+ t: order_type_to_wire(order_type)
233
+ }
234
+ wire[:c] = normalize_cloid(cloid) if cloid
235
+ wire
236
+ end
237
+
238
+ # Get current timestamp in milliseconds
239
+ def timestamp_ms
240
+ (Time.now.to_f * 1000).to_i
241
+ end
242
+
243
+ # Get asset index for a coin symbol
244
+ # @param coin [String] Asset symbol
245
+ # @return [Integer] Asset index
246
+ def asset_index(coin)
247
+ load_asset_cache unless @asset_cache
248
+ @asset_cache[:indices][coin] || raise(ArgumentError, "Unknown asset: #{coin}")
249
+ end
250
+
251
+ # Get asset metadata for a coin symbol
252
+ # @param coin [String] Asset symbol
253
+ # @return [Hash] Asset metadata with :sz_decimals and :is_spot
254
+ def asset_metadata(coin)
255
+ load_asset_cache unless @asset_cache
256
+ @asset_cache[:metadata][coin] || raise(ArgumentError, "Unknown asset: #{coin}")
257
+ end
258
+
259
+ # Load asset metadata from Info API (perps and spot)
260
+ def load_asset_cache
261
+ @asset_cache = { indices: {}, metadata: {} }
262
+
263
+ # Load perpetual assets
264
+ meta = @info.meta
265
+ meta['universe'].each_with_index do |asset, index|
266
+ name = asset['name']
267
+ @asset_cache[:indices][name] = index
268
+ @asset_cache[:metadata][name] = {
269
+ sz_decimals: asset['szDecimals'],
270
+ is_spot: false
271
+ }
272
+ end
273
+
274
+ # Load spot assets (index starts at SPOT_ASSET_THRESHOLD)
275
+ spot_meta = @info.spot_meta
276
+ spot_meta['universe'].each_with_index do |pair, index|
277
+ name = pair['name']
278
+ spot_index = index + SPOT_ASSET_THRESHOLD
279
+ @asset_cache[:indices][name] = spot_index
280
+ @asset_cache[:metadata][name] = {
281
+ sz_decimals: pair['szDecimals'] || 0,
282
+ is_spot: true
283
+ }
284
+ end
285
+ end
286
+
287
+ # Convert float to wire format (string representation)
288
+ # Maintains parity with official Python SDK
289
+ # - 8 decimal precision
290
+ # - Rounding tolerance validation (1e-12)
291
+ # - Decimal normalization (remove trailing zeros)
292
+ # @param value [String, Numeric] Value to convert
293
+ # @return [String] Wire format string
294
+ def float_to_wire(value)
295
+ decimal = BigDecimal(value.to_s)
296
+
297
+ # Format to 8 decimal places
298
+ rounded_str = format('%.8f', decimal)
299
+ rounded = BigDecimal(rounded_str)
300
+
301
+ # Validate rounding tolerance
302
+ raise ArgumentError, "float_to_wire causes rounding: #{value}" if (rounded - decimal).abs >= BigDecimal('1e-12')
303
+
304
+ # Negative zero edge case
305
+ rounded_str = '0.00000000' if rounded_str == '-0.00000000'
306
+
307
+ # Normalize: remove trailing zeros and unnecessary decimal point
308
+ # BigDecimal#to_s('F') gives fixed-point notation
309
+ normalized = BigDecimal(rounded_str).to_s('F')
310
+ # Remove trailing zeros after decimal point, and trailing decimal point
311
+ normalized.sub(/(\.\d*?)0+\z/, '\1').sub(/\.\z/, '')
312
+ end
313
+
314
+ # Calculate slippage price for market orders
315
+ # Maintains parity with official Python SDK
316
+ # 1. Apply slippage to mid price
317
+ # 2. Round to 5 significant figures
318
+ # 3. Round to asset-specific decimal places
319
+ # @param coin [String] Asset symbol
320
+ # @param mid [Float] Current mid price
321
+ # @param is_buy [Boolean] True for buy
322
+ # @param slippage [Float] Slippage tolerance
323
+ # @return [String] Formatted price string
324
+ def calculate_slippage_price(coin, mid, is_buy, slippage)
325
+ # Apply slippage
326
+ px = is_buy ? mid * (1 + slippage) : mid * (1 - slippage)
327
+
328
+ # Get asset metadata
329
+ metadata = asset_metadata(coin)
330
+ sz_decimals = metadata[:sz_decimals]
331
+ is_spot = metadata[:is_spot]
332
+
333
+ # Round to 5 significant figures first
334
+ sig_figs_str = format('%.5g', px)
335
+ sig_figs_price = sig_figs_str.to_f
336
+
337
+ # Calculate decimal places: (6 for perp, 8 for spot) - szDecimals
338
+ base_decimals = is_spot ? 8 : 6
339
+ decimal_places = [base_decimals - sz_decimals, 0].max
340
+
341
+ # Round to decimal places
342
+ rounded = sig_figs_price.round(decimal_places)
343
+
344
+ # Format with fixed decimal places
345
+ format("%.#{decimal_places}f", rounded)
346
+ end
347
+
348
+ # Convert cloid to raw string format
349
+ # @param cloid [Cloid, String, nil] Client order ID
350
+ # @return [String, nil] Raw cloid string
351
+ def normalize_cloid(cloid)
352
+ case cloid
353
+ when nil
354
+ nil
355
+ when Cloid
356
+ cloid.to_raw
357
+ when String
358
+ # Validate format
359
+ unless cloid.match?(/\A0x[0-9a-fA-F]{32}\z/i)
360
+ raise ArgumentError,
361
+ "cloid must be '0x' followed by 32 hex characters (16 bytes). Got: #{cloid.inspect}"
362
+ end
363
+ cloid.downcase
364
+ else
365
+ raise ArgumentError, "cloid must be Cloid, String, or nil. Got: #{cloid.class}"
366
+ end
367
+ end
368
+
369
+ # Convert order type to wire format
370
+ # @param order_type [Hash] Order type configuration
371
+ # @return [Hash] Wire format order type
372
+ def order_type_to_wire(order_type)
373
+ if order_type[:limit]
374
+ { limit: { tif: order_type[:limit][:tif] || 'Gtc' } }
375
+ elsif order_type[:trigger]
376
+ trigger = order_type[:trigger]
377
+
378
+ # Validate required fields
379
+ raise ArgumentError, 'Trigger orders require :trigger_px' unless trigger[:trigger_px]
380
+ raise ArgumentError, 'Trigger orders require :tpsl' unless trigger[:tpsl]
381
+ unless %w[tp sl].include?(trigger[:tpsl])
382
+ raise ArgumentError, "tpsl must be 'tp' or 'sl', got: #{trigger[:tpsl]}"
383
+ end
384
+
385
+ {
386
+ trigger: {
387
+ isMarket: trigger[:is_market] || false,
388
+ triggerPx: float_to_wire(trigger[:trigger_px]),
389
+ tpsl: trigger[:tpsl]
390
+ }
391
+ }
392
+ else
393
+ raise ArgumentError, 'order_type must specify :limit or :trigger'
394
+ end
395
+ end
396
+
397
+ # Post an action to the exchange endpoint
398
+ def post_action(action, signature, nonce, vault_address)
399
+ payload = {
400
+ action: action,
401
+ nonce: nonce,
402
+ signature: signature
403
+ }
404
+ payload[:vaultAddress] = vault_address if vault_address
405
+ payload[:expiresAfter] = @expires_after if @expires_after
406
+
407
+ @client.post(Constants::EXCHANGE_ENDPOINT, payload)
408
+ end
409
+ end
410
+ end
@@ -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.1'
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