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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -2
- data/CHANGELOG.md +34 -4
- data/CLAUDE.md +202 -0
- data/README.md +19 -343
- data/docs/API.md +95 -0
- data/docs/CONFIGURATION.md +53 -0
- data/docs/DEVELOPMENT.md +54 -0
- data/docs/ERRORS.md +38 -0
- data/docs/EXAMPLES.md +342 -0
- data/example.rb +131 -28
- data/lib/hyperliquid/cloid.rb +102 -0
- data/lib/hyperliquid/constants.rb +1 -0
- data/lib/hyperliquid/exchange.rb +410 -0
- data/lib/hyperliquid/signing/eip712.rb +56 -0
- data/lib/hyperliquid/signing/signer.rb +122 -0
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid.rb +33 -5
- data/test_integration.rb +255 -0
- metadata +40 -1
|
@@ -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
|
data/lib/hyperliquid/version.rb
CHANGED
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|