hyperliquid-rb 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +460 -0
- data/lib/hyperliquid/cloid.rb +48 -0
- data/lib/hyperliquid/constants.rb +107 -0
- data/lib/hyperliquid/error.rb +27 -0
- data/lib/hyperliquid/exchange.rb +840 -0
- data/lib/hyperliquid/info.rb +486 -0
- data/lib/hyperliquid/signer.rb +147 -0
- data/lib/hyperliquid/transport.rb +69 -0
- data/lib/hyperliquid/utils.rb +78 -0
- data/lib/hyperliquid/version.rb +5 -0
- data/lib/hyperliquid/websocket_manager.rb +197 -0
- data/lib/hyperliquid.rb +15 -0
- metadata +114 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hyperliquid
|
|
4
|
+
class Info
|
|
5
|
+
attr_reader :transport
|
|
6
|
+
|
|
7
|
+
def initialize(base_url: MAINNET_URL, skip_ws: false)
|
|
8
|
+
@transport = Transport.new(base_url: base_url)
|
|
9
|
+
@coin_to_asset = nil
|
|
10
|
+
@spot_coin_to_asset = nil
|
|
11
|
+
@name_to_coin = nil
|
|
12
|
+
@asset_to_sz_decimals = nil
|
|
13
|
+
@ws_manager = nil
|
|
14
|
+
return if skip_ws
|
|
15
|
+
|
|
16
|
+
@ws_manager = WebsocketManager.new(base_url)
|
|
17
|
+
@ws_manager.start
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# ---- Market Data ----
|
|
21
|
+
|
|
22
|
+
# Get perpetual metadata (universe, asset info).
|
|
23
|
+
# @param dex [String, nil] optional dex identifier for builder perp dexs
|
|
24
|
+
def meta(dex: nil)
|
|
25
|
+
if dex && !dex.empty?
|
|
26
|
+
post("meta", dex: dex)
|
|
27
|
+
else
|
|
28
|
+
post("meta")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get spot metadata.
|
|
33
|
+
def spot_meta
|
|
34
|
+
post("spotMeta")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get perpetual metadata with live asset contexts (funding, open interest, etc).
|
|
38
|
+
def meta_and_asset_ctxs
|
|
39
|
+
post("metaAndAssetCtxs")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get spot metadata with live asset contexts.
|
|
43
|
+
def spot_meta_and_asset_ctxs
|
|
44
|
+
post("spotMetaAndAssetCtxs")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get all mid prices. Returns Hash of coin => mid price string.
|
|
48
|
+
# @param dex [String, nil] optional dex identifier for builder perp dexs
|
|
49
|
+
def all_mids(dex: nil)
|
|
50
|
+
if dex && !dex.empty?
|
|
51
|
+
post("allMids", dex: dex)
|
|
52
|
+
else
|
|
53
|
+
post("allMids")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get L2 order book snapshot.
|
|
58
|
+
# @param coin [String] e.g. "ETH"
|
|
59
|
+
# @param n_levels [Integer] number of price levels (default: 10)
|
|
60
|
+
def l2_snapshot(coin, n_levels: 10)
|
|
61
|
+
post("l2Book", coin: coin, nSigFigs: n_levels)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get candle data.
|
|
65
|
+
# @param coin [String] e.g. "ETH"
|
|
66
|
+
# @param interval [String] e.g. "1h", "1d", "15m"
|
|
67
|
+
# @param start_time [Integer] start timestamp in ms
|
|
68
|
+
# @param end_time [Integer] end timestamp in ms
|
|
69
|
+
def candles_snapshot(coin, interval:, start_time:, end_time:)
|
|
70
|
+
post("candleSnapshot", req: { coin: coin, interval: interval, startTime: start_time, endTime: end_time })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get list of perp dexes.
|
|
74
|
+
def perp_dexs
|
|
75
|
+
post("perpDexs")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ---- User State ----
|
|
79
|
+
|
|
80
|
+
# Get user's perpetual account state (positions, margin, etc).
|
|
81
|
+
# @param address [String] user address
|
|
82
|
+
# @param dex [String, nil] optional dex identifier
|
|
83
|
+
def user_state(address, dex: nil)
|
|
84
|
+
if dex && !dex.empty?
|
|
85
|
+
post("clearinghouseState", user: address, dex: dex)
|
|
86
|
+
else
|
|
87
|
+
post("clearinghouseState", user: address)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get user's spot account state.
|
|
92
|
+
# @param address [String] user address
|
|
93
|
+
def spot_user_state(address)
|
|
94
|
+
post("spotClearinghouseState", user: address)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Get user's open orders.
|
|
98
|
+
# @param address [String] user address
|
|
99
|
+
# @param dex [String, nil] optional dex identifier
|
|
100
|
+
def open_orders(address, dex: nil)
|
|
101
|
+
if dex && !dex.empty?
|
|
102
|
+
post("openOrders", user: address, dex: dex)
|
|
103
|
+
else
|
|
104
|
+
post("openOrders", user: address)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get user's open orders with additional frontend info.
|
|
109
|
+
# @param address [String] user address
|
|
110
|
+
# @param dex [String, nil] optional dex identifier
|
|
111
|
+
def frontend_open_orders(address, dex: nil)
|
|
112
|
+
if dex && !dex.empty?
|
|
113
|
+
post("frontendOpenOrders", user: address, dex: dex)
|
|
114
|
+
else
|
|
115
|
+
post("frontendOpenOrders", user: address)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Get user's trade fills.
|
|
120
|
+
# @param address [String] user address
|
|
121
|
+
def user_fills(address)
|
|
122
|
+
post("userFills", user: address)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Get user's trade fills in a time range.
|
|
126
|
+
# @param address [String] user address
|
|
127
|
+
# @param start_time [Integer] start timestamp in ms
|
|
128
|
+
# @param end_time [Integer] end timestamp in ms (optional)
|
|
129
|
+
# @param aggregate_by_time [Boolean] aggregate fills by time (optional)
|
|
130
|
+
def user_fills_by_time(address, start_time:, end_time: nil, aggregate_by_time: nil)
|
|
131
|
+
req = { user: address, startTime: start_time }
|
|
132
|
+
req[:endTime] = end_time if end_time
|
|
133
|
+
req[:aggregateByTime] = aggregate_by_time unless aggregate_by_time.nil?
|
|
134
|
+
post("userFillsByTime", **req)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get user's fee rates.
|
|
138
|
+
# @param address [String] user address
|
|
139
|
+
def user_fees(address)
|
|
140
|
+
post("userFees", user: address)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get user's funding history.
|
|
144
|
+
# @param address [String] user address
|
|
145
|
+
# @param start_time [Integer] start timestamp in ms
|
|
146
|
+
# @param end_time [Integer] end timestamp in ms (optional)
|
|
147
|
+
def user_funding(address, start_time:, end_time: nil)
|
|
148
|
+
req = { user: address, startTime: start_time }
|
|
149
|
+
req[:endTime] = end_time if end_time
|
|
150
|
+
post("userFunding", **req)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Get user's non-funding ledger updates.
|
|
154
|
+
# @param address [String] user address
|
|
155
|
+
# @param start_time [Integer] start timestamp in ms
|
|
156
|
+
# @param end_time [Integer] end timestamp in ms (optional)
|
|
157
|
+
def user_non_funding_ledger_updates(address, start_time:, end_time: nil)
|
|
158
|
+
req = { user: address, startTime: start_time }
|
|
159
|
+
req[:endTime] = end_time if end_time
|
|
160
|
+
post("userNonFundingLedgerUpdates", **req)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get user's rate limits.
|
|
164
|
+
# @param address [String] user address
|
|
165
|
+
def user_rate_limit(address)
|
|
166
|
+
post("userRateLimit", user: address)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# ---- Orders ----
|
|
170
|
+
|
|
171
|
+
# Query status of an order by oid or cloid.
|
|
172
|
+
# @param address [String] user address
|
|
173
|
+
# @param oid [Integer, String] order id (Integer) or cloid (String)
|
|
174
|
+
def order_status(address, oid)
|
|
175
|
+
post("orderStatus", user: address, oid: oid)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Get user's historical orders (up to 2000 recent).
|
|
179
|
+
# @param address [String] user address
|
|
180
|
+
def historical_orders(address)
|
|
181
|
+
post("historicalOrders", user: address)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ---- Funding ----
|
|
185
|
+
|
|
186
|
+
# Get funding history for a coin.
|
|
187
|
+
# @param coin [String] e.g. "ETH"
|
|
188
|
+
# @param start_time [Integer] start timestamp in ms
|
|
189
|
+
# @param end_time [Integer] end timestamp in ms (optional)
|
|
190
|
+
def funding_history(coin, start_time:, end_time: nil)
|
|
191
|
+
req = { coin: coin, startTime: start_time }
|
|
192
|
+
req[:endTime] = end_time if end_time
|
|
193
|
+
post("fundingHistory", **req)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Get predicted funding rates.
|
|
197
|
+
def predicted_fundings
|
|
198
|
+
post("predictedFundings")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# ---- Sub-accounts ----
|
|
202
|
+
|
|
203
|
+
# Get sub-accounts for an address.
|
|
204
|
+
# @param address [String] user address
|
|
205
|
+
def sub_accounts(address)
|
|
206
|
+
post("subAccounts", user: address)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ---- Staking ----
|
|
210
|
+
|
|
211
|
+
# Get user's staking summary (delegated, undelegated, pending).
|
|
212
|
+
# @param address [String] user address
|
|
213
|
+
def user_staking_summary(address)
|
|
214
|
+
post("delegatorSummary", user: address)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Get user's staking delegations per validator.
|
|
218
|
+
# @param address [String] user address
|
|
219
|
+
def user_staking_delegations(address)
|
|
220
|
+
post("delegations", user: address)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Get user's historic staking rewards.
|
|
224
|
+
# @param address [String] user address
|
|
225
|
+
def user_staking_rewards(address)
|
|
226
|
+
post("delegatorRewards", user: address)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Get comprehensive delegator history.
|
|
230
|
+
# @param address [String] user address
|
|
231
|
+
def delegator_history(address)
|
|
232
|
+
post("delegatorHistory", user: address)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ---- Referrals ----
|
|
236
|
+
|
|
237
|
+
# Get referral state for an address.
|
|
238
|
+
# @param address [String] user address
|
|
239
|
+
def referral(address)
|
|
240
|
+
post("referral", user: address)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# ---- Multi-sig ----
|
|
244
|
+
|
|
245
|
+
# Get multi-sig signers for a user.
|
|
246
|
+
# @param address [String] multi-sig user address
|
|
247
|
+
def query_user_to_multi_sig_signers(address)
|
|
248
|
+
post("userToMultiSigSigners", user: address)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ---- Deploy Auctions ----
|
|
252
|
+
|
|
253
|
+
# Get perp deploy auction status.
|
|
254
|
+
def query_perp_deploy_auction_status
|
|
255
|
+
post("perpDeployAuctionStatus")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get spot deploy state for a user.
|
|
259
|
+
# @param address [String] user address
|
|
260
|
+
def query_spot_deploy_auction_status(address)
|
|
261
|
+
post("spotDeployState", user: address)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# ---- Dex Abstraction ----
|
|
265
|
+
|
|
266
|
+
# Get dex abstraction state for a user.
|
|
267
|
+
# @param address [String] user address
|
|
268
|
+
def query_user_dex_abstraction_state(address)
|
|
269
|
+
post("userDexAbstraction", user: address)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Get user abstraction state.
|
|
273
|
+
# @param address [String] user address
|
|
274
|
+
def query_user_abstraction_state(address)
|
|
275
|
+
post("userAbstraction", user: address)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# ---- Portfolio / TWAP / Vault ----
|
|
279
|
+
|
|
280
|
+
# Get portfolio performance data.
|
|
281
|
+
# @param address [String] user address
|
|
282
|
+
def portfolio(address)
|
|
283
|
+
post("portfolio", user: address)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Get user's TWAP slice fills.
|
|
287
|
+
# @param address [String] user address
|
|
288
|
+
def user_twap_slice_fills(address)
|
|
289
|
+
post("userTwapSliceFills", user: address)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Get user's vault equity positions.
|
|
293
|
+
# @param address [String] user address
|
|
294
|
+
def user_vault_equities(address)
|
|
295
|
+
post("userVaultEquities", user: address)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Get user's role and account type.
|
|
299
|
+
# @param address [String] user address
|
|
300
|
+
def user_role(address)
|
|
301
|
+
post("userRole", user: address)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Get extra agents for a user.
|
|
305
|
+
# @param address [String] user address
|
|
306
|
+
def extra_agents(address)
|
|
307
|
+
post("extraAgents", user: address)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# ---- WebSocket ----
|
|
311
|
+
|
|
312
|
+
# Subscribe to a WebSocket channel.
|
|
313
|
+
# @param subscription [Hash] e.g. { "type" => "allMids" } or { "type" => "l2Book", "coin" => "ETH" }
|
|
314
|
+
# @param callback [Proc] called with each message hash
|
|
315
|
+
# @return [Integer] subscription_id for unsubscribing
|
|
316
|
+
def subscribe(subscription, callback)
|
|
317
|
+
remap_coin_subscription(subscription)
|
|
318
|
+
raise "Cannot call subscribe since skip_ws was used" if @ws_manager.nil?
|
|
319
|
+
|
|
320
|
+
@ws_manager.subscribe(subscription, callback)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Unsubscribe from a WebSocket channel.
|
|
324
|
+
# @param subscription [Hash] same hash used to subscribe
|
|
325
|
+
# @param subscription_id [Integer] returned by subscribe
|
|
326
|
+
# @return [Boolean] true if subscription was found and removed
|
|
327
|
+
def unsubscribe(subscription, subscription_id)
|
|
328
|
+
remap_coin_subscription(subscription)
|
|
329
|
+
raise "Cannot call unsubscribe since skip_ws was used" if @ws_manager.nil?
|
|
330
|
+
|
|
331
|
+
@ws_manager.unsubscribe(subscription, subscription_id)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Disconnect the WebSocket connection and stop the ping thread.
|
|
335
|
+
def disconnect_websocket
|
|
336
|
+
raise "Cannot call disconnect_websocket since skip_ws was used" if @ws_manager.nil?
|
|
337
|
+
|
|
338
|
+
@ws_manager.stop
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# ---- Coin-to-asset mapping (lazy loaded) ----
|
|
342
|
+
|
|
343
|
+
# Map a coin name to its asset index (handles both perp and spot).
|
|
344
|
+
# This is the primary method used by Exchange for resolving coin names.
|
|
345
|
+
# @param name [String] e.g. "ETH", "BTC", "PURR/USDC"
|
|
346
|
+
# @return [Integer] asset index
|
|
347
|
+
def name_to_asset(name)
|
|
348
|
+
load_all_mappings! unless @name_to_coin
|
|
349
|
+
coin = @name_to_coin[name]
|
|
350
|
+
raise Error, "Unknown coin name: #{name}" unless coin
|
|
351
|
+
|
|
352
|
+
asset = @coin_to_asset[coin]
|
|
353
|
+
raise Error, "Unknown coin: #{coin}" unless asset
|
|
354
|
+
|
|
355
|
+
asset
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Map a coin name to its perpetual asset index.
|
|
359
|
+
# @param coin [String] e.g. "ETH"
|
|
360
|
+
# @return [Integer] asset index
|
|
361
|
+
def coin_to_asset(coin)
|
|
362
|
+
load_coin_mapping! unless @coin_to_asset
|
|
363
|
+
@coin_to_asset[coin] || raise(Error, "Unknown perpetual coin: #{coin}")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Map a coin name to its spot asset index (10000 + index).
|
|
367
|
+
# @param coin [String] e.g. "PURR/USDC"
|
|
368
|
+
# @return [Integer] asset index
|
|
369
|
+
def spot_coin_to_asset(coin)
|
|
370
|
+
load_spot_coin_mapping! unless @spot_coin_to_asset
|
|
371
|
+
@spot_coin_to_asset[coin] || raise(Error, "Unknown spot coin: #{coin}")
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Get sz decimals for an asset index.
|
|
375
|
+
# @param asset [Integer] asset index
|
|
376
|
+
# @return [Integer] sz decimals
|
|
377
|
+
def asset_to_sz_decimals(asset)
|
|
378
|
+
load_all_mappings! unless @asset_to_sz_decimals
|
|
379
|
+
@asset_to_sz_decimals[asset]
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Get the full coin name (with dex prefix if applicable) for a display name.
|
|
383
|
+
# @param name [String] e.g. "ETH"
|
|
384
|
+
# @return [String] coin name
|
|
385
|
+
def name_to_coin(name)
|
|
386
|
+
load_all_mappings! unless @name_to_coin
|
|
387
|
+
@name_to_coin[name] || raise(Error, "Unknown name: #{name}")
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Reset cached coin mappings (e.g. after new listings).
|
|
391
|
+
def refresh_coin_mappings!
|
|
392
|
+
@coin_to_asset = nil
|
|
393
|
+
@spot_coin_to_asset = nil
|
|
394
|
+
@name_to_coin = nil
|
|
395
|
+
@asset_to_sz_decimals = nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private
|
|
399
|
+
|
|
400
|
+
def remap_coin_subscription(subscription)
|
|
401
|
+
type = subscription["type"]
|
|
402
|
+
return unless %w[l2Book trades candle bbo activeAssetCtx].include?(type)
|
|
403
|
+
|
|
404
|
+
load_all_mappings! unless @name_to_coin
|
|
405
|
+
coin = @name_to_coin[subscription["coin"]]
|
|
406
|
+
subscription["coin"] = coin if coin
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def post(type, **params)
|
|
410
|
+
body = { type: type }.merge(params)
|
|
411
|
+
@transport.post_info(body)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def load_coin_mapping!
|
|
415
|
+
m = meta
|
|
416
|
+
@coin_to_asset = {}
|
|
417
|
+
m["universe"].each_with_index do |asset_info, index|
|
|
418
|
+
@coin_to_asset[asset_info["name"]] = index
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def load_spot_coin_mapping!
|
|
423
|
+
m = spot_meta
|
|
424
|
+
@spot_coin_to_asset = {}
|
|
425
|
+
m["universe"].each_with_index do |token_info, index|
|
|
426
|
+
name = token_info["name"]
|
|
427
|
+
@spot_coin_to_asset[name] = 10_000 + index
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def load_all_mappings!
|
|
432
|
+
@coin_to_asset = {}
|
|
433
|
+
@name_to_coin = {}
|
|
434
|
+
@asset_to_sz_decimals = {}
|
|
435
|
+
|
|
436
|
+
# Load perp metadata
|
|
437
|
+
m = meta
|
|
438
|
+
set_perp_meta(m, 0)
|
|
439
|
+
|
|
440
|
+
# Load spot metadata
|
|
441
|
+
begin
|
|
442
|
+
sm = spot_meta
|
|
443
|
+
sm["universe"].each_with_index do |token_info, index|
|
|
444
|
+
name = token_info["name"]
|
|
445
|
+
asset = 10_000 + index
|
|
446
|
+
@coin_to_asset[name] = asset
|
|
447
|
+
@name_to_coin[name] = name
|
|
448
|
+
end
|
|
449
|
+
sm["tokens"]&.each do |token|
|
|
450
|
+
@asset_to_sz_decimals[token["index"]] = token["szDecimals"] if token["index"]
|
|
451
|
+
end
|
|
452
|
+
rescue Error
|
|
453
|
+
# spot_meta may not be available
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Load builder perp dexes
|
|
457
|
+
begin
|
|
458
|
+
dexes = perp_dexs
|
|
459
|
+
dexes&.each_with_index do |dex, i|
|
|
460
|
+
offset = 110_000 + (i * 10_000)
|
|
461
|
+
begin
|
|
462
|
+
dex_meta = meta(dex: dex)
|
|
463
|
+
set_perp_meta(dex_meta, offset, dex: dex)
|
|
464
|
+
rescue Error
|
|
465
|
+
# dex meta may not be available
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
rescue Error
|
|
469
|
+
# perp_dexs may not be available
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
@spot_coin_to_asset = @coin_to_asset.select { |_k, v| v >= 10_000 && v < 110_000 }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def set_perp_meta(m, offset, dex: nil)
|
|
476
|
+
m["universe"].each_with_index do |asset_info, index|
|
|
477
|
+
name = asset_info["name"]
|
|
478
|
+
coin = dex && !dex.empty? ? "#{dex}:#{name}" : name
|
|
479
|
+
asset = offset + index
|
|
480
|
+
@coin_to_asset[coin] = asset
|
|
481
|
+
@name_to_coin[name] = coin
|
|
482
|
+
@asset_to_sz_decimals[asset] = asset_info["szDecimals"]
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "eth"
|
|
4
|
+
require "msgpack"
|
|
5
|
+
|
|
6
|
+
module Hyperliquid
|
|
7
|
+
class Signer
|
|
8
|
+
attr_reader :key, :is_mainnet
|
|
9
|
+
|
|
10
|
+
# @param private_key [String] hex private key (with or without 0x prefix)
|
|
11
|
+
# @param base_url [String] API URL to determine mainnet vs testnet
|
|
12
|
+
def initialize(private_key:, base_url: MAINNET_URL)
|
|
13
|
+
hex = private_key.delete_prefix("0x")
|
|
14
|
+
@key = Eth::Key.new(priv: hex)
|
|
15
|
+
@is_mainnet = base_url == MAINNET_URL
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def address
|
|
19
|
+
@key.address.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Sign an L1 action (orders, cancels, leverage, etc.)
|
|
23
|
+
# Returns { r: "0x...", s: "0x...", v: Integer }
|
|
24
|
+
def sign_l1_action(action, nonce:, vault_address: nil, expires_after: nil)
|
|
25
|
+
hash = action_hash(action, nonce: nonce, vault_address: vault_address, expires_after: expires_after)
|
|
26
|
+
phantom = { source: (@is_mainnet ? "a" : "b"), connectionId: hash }
|
|
27
|
+
typed_data = build_agent_typed_data(phantom)
|
|
28
|
+
sign_typed_data(typed_data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Sign a user-signed action (transfers, withdrawals, approvals, etc.)
|
|
32
|
+
# @param action [Hash] the action payload (string keys, will be modified)
|
|
33
|
+
# @param primary_type [String] e.g. "UsdSend", "Withdraw"
|
|
34
|
+
# @param payload_types [Array] EIP-712 field definitions for the primary type
|
|
35
|
+
def sign_user_signed_action(action, primary_type:, payload_types:)
|
|
36
|
+
action["signatureChainId"] = "0x66eee"
|
|
37
|
+
action["hyperliquidChain"] = @is_mainnet ? "Mainnet" : "Testnet"
|
|
38
|
+
|
|
39
|
+
typed_data = build_user_signed_typed_data(action, primary_type: primary_type, payload_types: payload_types)
|
|
40
|
+
sign_typed_data(typed_data)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Sign a multi-sig action envelope.
|
|
44
|
+
# The inner action hash is used with the SendMultiSig type.
|
|
45
|
+
def sign_multi_sig_action(action, nonce:, vault_address: nil, expires_after: nil)
|
|
46
|
+
inner_action = action["payload"]["action"]
|
|
47
|
+
inner_hash = action_hash(inner_action, nonce: nonce, vault_address: vault_address, expires_after: expires_after)
|
|
48
|
+
|
|
49
|
+
phantom = { source: (@is_mainnet ? "a" : "b"), connectionId: inner_hash }
|
|
50
|
+
typed_data = build_agent_typed_data(phantom)
|
|
51
|
+
sign_typed_data(typed_data)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Compute the action hash for L1 actions.
|
|
55
|
+
# msgpack(action) + nonce(8B) + vault_flag(1B) + [vault_addr(20B)] + [0x00 + expires(8B)]
|
|
56
|
+
def action_hash(action, nonce:, vault_address: nil, expires_after: nil)
|
|
57
|
+
data = MessagePack.pack(action)
|
|
58
|
+
data += [nonce].pack("Q>")
|
|
59
|
+
|
|
60
|
+
if vault_address.nil?
|
|
61
|
+
data += "\x00".b
|
|
62
|
+
else
|
|
63
|
+
data += "\x01".b
|
|
64
|
+
data += Utils.address_to_bytes(vault_address)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if expires_after
|
|
68
|
+
data += "\x00".b
|
|
69
|
+
data += [expires_after].pack("Q>")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Eth::Util.keccak256(data)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def build_agent_typed_data(phantom_agent)
|
|
78
|
+
{
|
|
79
|
+
types: {
|
|
80
|
+
EIP712Domain: [
|
|
81
|
+
{ name: "name", type: "string" },
|
|
82
|
+
{ name: "version", type: "string" },
|
|
83
|
+
{ name: "chainId", type: "uint256" },
|
|
84
|
+
{ name: "verifyingContract", type: "address" }
|
|
85
|
+
],
|
|
86
|
+
Agent: [
|
|
87
|
+
{ name: "source", type: "string" },
|
|
88
|
+
{ name: "connectionId", type: "bytes32" }
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
primaryType: "Agent",
|
|
92
|
+
domain: {
|
|
93
|
+
name: "Exchange",
|
|
94
|
+
version: "1",
|
|
95
|
+
chainId: 1337,
|
|
96
|
+
verifyingContract: "0x0000000000000000000000000000000000000000"
|
|
97
|
+
},
|
|
98
|
+
message: phantom_agent
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_user_signed_typed_data(action, primary_type:, payload_types:)
|
|
103
|
+
chain_id = action["signatureChainId"].to_i(16)
|
|
104
|
+
|
|
105
|
+
# Build message from action, using only the fields defined in payload_types
|
|
106
|
+
message = {}
|
|
107
|
+
payload_types.each do |field|
|
|
108
|
+
name = field[:name]
|
|
109
|
+
message[name.to_sym] = action[name]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
type_name = "HyperliquidTransaction:#{primary_type}"
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
types: {
|
|
116
|
+
EIP712Domain: [
|
|
117
|
+
{ name: "name", type: "string" },
|
|
118
|
+
{ name: "version", type: "string" },
|
|
119
|
+
{ name: "chainId", type: "uint256" },
|
|
120
|
+
{ name: "verifyingContract", type: "address" }
|
|
121
|
+
],
|
|
122
|
+
type_name.to_sym => payload_types
|
|
123
|
+
},
|
|
124
|
+
primaryType: type_name,
|
|
125
|
+
domain: {
|
|
126
|
+
name: "HyperliquidSignTransaction",
|
|
127
|
+
version: "1",
|
|
128
|
+
chainId: chain_id,
|
|
129
|
+
verifyingContract: "0x0000000000000000000000000000000000000000"
|
|
130
|
+
},
|
|
131
|
+
message: message
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def sign_typed_data(typed_data)
|
|
136
|
+
hash = Eth::Eip712.hash(typed_data)
|
|
137
|
+
sig_hex = @key.sign(hash)
|
|
138
|
+
sig_bytes = [sig_hex].pack("H*")
|
|
139
|
+
|
|
140
|
+
r_int = sig_bytes[0, 32].unpack1("H*").to_i(16)
|
|
141
|
+
s_int = sig_bytes[32, 32].unpack1("H*").to_i(16)
|
|
142
|
+
v = sig_bytes[64].unpack1("C")
|
|
143
|
+
|
|
144
|
+
{ r: "0x#{r_int.to_s(16)}", s: "0x#{s_int.to_s(16)}", v: v }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Hyperliquid
|
|
7
|
+
class Transport
|
|
8
|
+
attr_reader :base_url
|
|
9
|
+
|
|
10
|
+
def initialize(base_url: MAINNET_URL)
|
|
11
|
+
@base_url = base_url
|
|
12
|
+
@connection = Faraday.new(url: base_url) do |f|
|
|
13
|
+
f.request :json
|
|
14
|
+
f.response :json
|
|
15
|
+
f.adapter Faraday.default_adapter
|
|
16
|
+
f.options.timeout = 30
|
|
17
|
+
f.options.open_timeout = 10
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def post_info(body)
|
|
22
|
+
post("/info", body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def post_exchange(body)
|
|
26
|
+
post("/exchange", body)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def post(path, body)
|
|
32
|
+
response = @connection.post(path) do |req|
|
|
33
|
+
req.body = body
|
|
34
|
+
end
|
|
35
|
+
handle_response(response)
|
|
36
|
+
rescue Faraday::Error => e
|
|
37
|
+
raise Error, "Network error: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handle_response(response)
|
|
41
|
+
case response.status
|
|
42
|
+
when 200
|
|
43
|
+
response.body
|
|
44
|
+
when 400..499
|
|
45
|
+
raise ClientError.new(
|
|
46
|
+
"HTTP #{response.status}: #{error_message(response.body)}",
|
|
47
|
+
status: response.status,
|
|
48
|
+
body: response.body
|
|
49
|
+
)
|
|
50
|
+
when 500..599
|
|
51
|
+
raise ServerError.new(
|
|
52
|
+
"HTTP #{response.status}: #{error_message(response.body)}",
|
|
53
|
+
status: response.status,
|
|
54
|
+
body: response.body
|
|
55
|
+
)
|
|
56
|
+
else
|
|
57
|
+
raise Error, "Unexpected HTTP #{response.status}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def error_message(body)
|
|
62
|
+
case body
|
|
63
|
+
when String then body
|
|
64
|
+
when Hash then body["error"] || body.to_s
|
|
65
|
+
else body.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|