hyperliquid 0.4.1 → 0.6.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 +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +24 -2
- data/CLAUDE.md +6 -7
- data/README.md +4 -8
- data/SECURITY.md +7 -0
- data/docs/API.md +34 -1
- data/docs/DEVELOPMENT.md +31 -17
- data/docs/EXAMPLES.md +155 -0
- data/lib/hyperliquid/exchange.rb +379 -0
- data/lib/hyperliquid/signing/eip712.rb +73 -0
- data/lib/hyperliquid/signing/signer.rb +27 -2
- data/lib/hyperliquid/version.rb +1 -1
- data/scripts/test_01_spot_market_roundtrip.rb +48 -0
- data/scripts/test_02_spot_limit_order.rb +48 -0
- data/scripts/test_03_perp_market_roundtrip.rb +52 -0
- data/scripts/test_04_perp_limit_order.rb +52 -0
- data/scripts/test_05_update_leverage.rb +39 -0
- data/scripts/test_06_modify_order.rb +67 -0
- data/scripts/test_07_market_close.rb +49 -0
- data/scripts/test_08_usd_class_transfer.rb +23 -0
- data/scripts/test_09_sub_account_lifecycle.rb +51 -0
- data/scripts/test_10_vault_transfer.rb +41 -0
- data/scripts/test_all.rb +86 -0
- data/scripts/test_helpers.rb +100 -0
- data/test_integration.rb +8 -246
- metadata +14 -1
data/lib/hyperliquid/exchange.rb
CHANGED
|
@@ -213,6 +213,362 @@ module Hyperliquid
|
|
|
213
213
|
post_action(action, signature, nonce, vault_address)
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
+
# Modify a single existing order
|
|
217
|
+
# @param oid [Integer, Cloid, String] Order ID or client order ID to modify
|
|
218
|
+
# @param coin [String] Asset symbol (e.g., "BTC")
|
|
219
|
+
# @param is_buy [Boolean] True for buy, false for sell
|
|
220
|
+
# @param size [String, Numeric] New order size
|
|
221
|
+
# @param limit_px [String, Numeric] New limit price
|
|
222
|
+
# @param order_type [Hash] Order type config (default: { limit: { tif: "Gtc" } })
|
|
223
|
+
# @param reduce_only [Boolean] Reduce-only flag (default: false)
|
|
224
|
+
# @param cloid [Cloid, String, nil] Client order ID for the modified order (optional)
|
|
225
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
226
|
+
# @return [Hash] Modify response
|
|
227
|
+
def modify_order(oid:, coin:, is_buy:, size:, limit_px:,
|
|
228
|
+
order_type: { limit: { tif: 'Gtc' } },
|
|
229
|
+
reduce_only: false, cloid: nil, vault_address: nil)
|
|
230
|
+
batch_modify(
|
|
231
|
+
modifies: [{
|
|
232
|
+
oid: oid, coin: coin, is_buy: is_buy, size: size,
|
|
233
|
+
limit_px: limit_px, order_type: order_type,
|
|
234
|
+
reduce_only: reduce_only, cloid: cloid
|
|
235
|
+
}],
|
|
236
|
+
vault_address: vault_address
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Modify multiple orders at once
|
|
241
|
+
# @param modifies [Array<Hash>] Array of modify hashes with keys:
|
|
242
|
+
# :oid, :coin, :is_buy, :size, :limit_px, :order_type, :reduce_only, :cloid
|
|
243
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
244
|
+
# @return [Hash] Batch modify response
|
|
245
|
+
def batch_modify(modifies:, vault_address: nil)
|
|
246
|
+
nonce = timestamp_ms
|
|
247
|
+
|
|
248
|
+
modify_wires = modifies.map do |m|
|
|
249
|
+
order_wire = build_order_wire(
|
|
250
|
+
coin: m[:coin],
|
|
251
|
+
is_buy: m[:is_buy],
|
|
252
|
+
size: m[:size],
|
|
253
|
+
limit_px: m[:limit_px],
|
|
254
|
+
order_type: m[:order_type] || { limit: { tif: 'Gtc' } },
|
|
255
|
+
reduce_only: m[:reduce_only] || false,
|
|
256
|
+
cloid: m[:cloid]
|
|
257
|
+
)
|
|
258
|
+
{ oid: normalize_oid(m[:oid]), order: order_wire }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
action = {
|
|
262
|
+
type: 'batchModify',
|
|
263
|
+
modifies: modify_wires
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
signature = @signer.sign_l1_action(
|
|
267
|
+
action, nonce,
|
|
268
|
+
vault_address: vault_address,
|
|
269
|
+
expires_after: @expires_after
|
|
270
|
+
)
|
|
271
|
+
post_action(action, signature, nonce, vault_address)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Set cross or isolated leverage for a coin
|
|
275
|
+
# @param coin [String] Asset symbol (perps only)
|
|
276
|
+
# @param leverage [Integer] Leverage value
|
|
277
|
+
# @param is_cross [Boolean] True for cross margin, false for isolated (default: true)
|
|
278
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
279
|
+
# @return [Hash] Leverage update response
|
|
280
|
+
def update_leverage(coin:, leverage:, is_cross: true, vault_address: nil)
|
|
281
|
+
nonce = timestamp_ms
|
|
282
|
+
|
|
283
|
+
action = {
|
|
284
|
+
type: 'updateLeverage',
|
|
285
|
+
asset: asset_index(coin),
|
|
286
|
+
isCross: is_cross,
|
|
287
|
+
leverage: leverage
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
signature = @signer.sign_l1_action(
|
|
291
|
+
action, nonce,
|
|
292
|
+
vault_address: vault_address,
|
|
293
|
+
expires_after: @expires_after
|
|
294
|
+
)
|
|
295
|
+
post_action(action, signature, nonce, vault_address)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Add or remove isolated margin for a position
|
|
299
|
+
# @param coin [String] Asset symbol (perps only)
|
|
300
|
+
# @param amount [Numeric] Amount in USD (positive to add, negative to remove)
|
|
301
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
302
|
+
# @return [Hash] Margin update response
|
|
303
|
+
def update_isolated_margin(coin:, amount:, vault_address: nil)
|
|
304
|
+
nonce = timestamp_ms
|
|
305
|
+
|
|
306
|
+
action = {
|
|
307
|
+
type: 'updateIsolatedMargin',
|
|
308
|
+
asset: asset_index(coin),
|
|
309
|
+
isBuy: true,
|
|
310
|
+
ntli: float_to_usd_int(amount)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
signature = @signer.sign_l1_action(
|
|
314
|
+
action, nonce,
|
|
315
|
+
vault_address: vault_address,
|
|
316
|
+
expires_after: @expires_after
|
|
317
|
+
)
|
|
318
|
+
post_action(action, signature, nonce, vault_address)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Schedule automatic cancellation of all orders
|
|
322
|
+
# @param time [Integer, nil] UTC timestamp in milliseconds to cancel at (nil to activate with server default)
|
|
323
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
324
|
+
# @return [Hash] Schedule cancel response
|
|
325
|
+
def schedule_cancel(time: nil, vault_address: nil)
|
|
326
|
+
nonce = timestamp_ms
|
|
327
|
+
|
|
328
|
+
action = { type: 'scheduleCancel' }
|
|
329
|
+
action[:time] = time if time
|
|
330
|
+
|
|
331
|
+
signature = @signer.sign_l1_action(
|
|
332
|
+
action, nonce,
|
|
333
|
+
vault_address: vault_address,
|
|
334
|
+
expires_after: @expires_after
|
|
335
|
+
)
|
|
336
|
+
post_action(action, signature, nonce, vault_address)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Close a position at market price
|
|
340
|
+
# @param coin [String] Asset symbol (perps only)
|
|
341
|
+
# @param size [Numeric, nil] Size to close (nil = close entire position)
|
|
342
|
+
# @param slippage [Float] Slippage tolerance (default: 5%)
|
|
343
|
+
# @param cloid [Cloid, String, nil] Client order ID (optional)
|
|
344
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
345
|
+
# @return [Hash] Order response
|
|
346
|
+
def market_close(coin:, size: nil, slippage: DEFAULT_SLIPPAGE, cloid: nil, vault_address: nil)
|
|
347
|
+
address = vault_address || @signer.address
|
|
348
|
+
state = @info.user_state(address)
|
|
349
|
+
|
|
350
|
+
position = state['assetPositions']&.find do |pos|
|
|
351
|
+
pos.dig('position', 'coin') == coin
|
|
352
|
+
end
|
|
353
|
+
raise ArgumentError, "No open position found for #{coin}" unless position
|
|
354
|
+
|
|
355
|
+
szi = position.dig('position', 'szi').to_f
|
|
356
|
+
is_buy = szi.negative?
|
|
357
|
+
close_size = size || szi.abs
|
|
358
|
+
|
|
359
|
+
mids = @info.all_mids
|
|
360
|
+
mid = mids[coin]&.to_f
|
|
361
|
+
raise ArgumentError, "Unknown asset or no price available: #{coin}" unless mid&.positive?
|
|
362
|
+
|
|
363
|
+
slippage_price = calculate_slippage_price(coin, mid, is_buy, slippage)
|
|
364
|
+
|
|
365
|
+
order(
|
|
366
|
+
coin: coin,
|
|
367
|
+
is_buy: is_buy,
|
|
368
|
+
size: close_size,
|
|
369
|
+
limit_px: slippage_price,
|
|
370
|
+
order_type: { limit: { tif: 'Ioc' } },
|
|
371
|
+
reduce_only: true,
|
|
372
|
+
cloid: cloid,
|
|
373
|
+
vault_address: vault_address
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Transfer USDC to another address
|
|
378
|
+
# @param amount [String, Numeric] Amount to send
|
|
379
|
+
# @param destination [String] Destination wallet address
|
|
380
|
+
# @return [Hash] Transfer response
|
|
381
|
+
def usd_send(amount:, destination:)
|
|
382
|
+
nonce = timestamp_ms
|
|
383
|
+
action = {
|
|
384
|
+
type: 'usdSend',
|
|
385
|
+
signatureChainId: '0x66eee',
|
|
386
|
+
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @signer.instance_variable_get(:@testnet)),
|
|
387
|
+
destination: destination,
|
|
388
|
+
amount: amount.to_s,
|
|
389
|
+
time: nonce
|
|
390
|
+
}
|
|
391
|
+
signature = @signer.sign_user_signed_action(
|
|
392
|
+
{ destination: destination, amount: amount.to_s, time: nonce },
|
|
393
|
+
'HyperliquidTransaction:UsdSend',
|
|
394
|
+
Signing::EIP712::USD_SEND_TYPES
|
|
395
|
+
)
|
|
396
|
+
post_action(action, signature, nonce, nil)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Transfer a spot token to another address
|
|
400
|
+
# @param amount [String, Numeric] Amount to send
|
|
401
|
+
# @param destination [String] Destination wallet address
|
|
402
|
+
# @param token [String] Token identifier
|
|
403
|
+
# @return [Hash] Transfer response
|
|
404
|
+
def spot_send(amount:, destination:, token:)
|
|
405
|
+
nonce = timestamp_ms
|
|
406
|
+
action = {
|
|
407
|
+
type: 'spotSend',
|
|
408
|
+
signatureChainId: '0x66eee',
|
|
409
|
+
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @signer.instance_variable_get(:@testnet)),
|
|
410
|
+
destination: destination,
|
|
411
|
+
token: token,
|
|
412
|
+
amount: amount.to_s,
|
|
413
|
+
time: nonce
|
|
414
|
+
}
|
|
415
|
+
signature = @signer.sign_user_signed_action(
|
|
416
|
+
{ destination: destination, token: token, amount: amount.to_s, time: nonce },
|
|
417
|
+
'HyperliquidTransaction:SpotSend',
|
|
418
|
+
Signing::EIP712::SPOT_SEND_TYPES
|
|
419
|
+
)
|
|
420
|
+
post_action(action, signature, nonce, nil)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Move USDC between perp and spot accounts
|
|
424
|
+
# @param amount [String, Numeric] Amount to transfer
|
|
425
|
+
# @param to_perp [Boolean] True to move to perp, false to move to spot
|
|
426
|
+
# @return [Hash] Transfer response
|
|
427
|
+
def usd_class_transfer(amount:, to_perp:)
|
|
428
|
+
nonce = timestamp_ms
|
|
429
|
+
action = {
|
|
430
|
+
type: 'usdClassTransfer',
|
|
431
|
+
signatureChainId: '0x66eee',
|
|
432
|
+
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @signer.instance_variable_get(:@testnet)),
|
|
433
|
+
amount: amount.to_s,
|
|
434
|
+
toPerp: to_perp,
|
|
435
|
+
nonce: nonce
|
|
436
|
+
}
|
|
437
|
+
signature = @signer.sign_user_signed_action(
|
|
438
|
+
{ amount: amount.to_s, toPerp: to_perp, nonce: nonce },
|
|
439
|
+
'HyperliquidTransaction:UsdClassTransfer',
|
|
440
|
+
Signing::EIP712::USD_CLASS_TRANSFER_TYPES
|
|
441
|
+
)
|
|
442
|
+
post_action(action, signature, nonce, nil)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Withdraw USDC via the bridge
|
|
446
|
+
# @param amount [String, Numeric] Amount to withdraw
|
|
447
|
+
# @param destination [String] Destination wallet address
|
|
448
|
+
# @return [Hash] Withdrawal response
|
|
449
|
+
def withdraw_from_bridge(amount:, destination:)
|
|
450
|
+
nonce = timestamp_ms
|
|
451
|
+
action = {
|
|
452
|
+
type: 'withdraw3',
|
|
453
|
+
signatureChainId: '0x66eee',
|
|
454
|
+
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @signer.instance_variable_get(:@testnet)),
|
|
455
|
+
destination: destination,
|
|
456
|
+
amount: amount.to_s,
|
|
457
|
+
time: nonce
|
|
458
|
+
}
|
|
459
|
+
signature = @signer.sign_user_signed_action(
|
|
460
|
+
{ destination: destination, amount: amount.to_s, time: nonce },
|
|
461
|
+
'HyperliquidTransaction:Withdraw',
|
|
462
|
+
Signing::EIP712::WITHDRAW_TYPES
|
|
463
|
+
)
|
|
464
|
+
post_action(action, signature, nonce, nil)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Move assets between DEX instances
|
|
468
|
+
# @param destination [String] Destination wallet address
|
|
469
|
+
# @param source_dex [String] Source DEX identifier
|
|
470
|
+
# @param destination_dex [String] Destination DEX identifier
|
|
471
|
+
# @param token [String] Token identifier
|
|
472
|
+
# @param amount [String, Numeric] Amount to send
|
|
473
|
+
# @return [Hash] Transfer response
|
|
474
|
+
def send_asset(destination:, source_dex:, destination_dex:, token:, amount:)
|
|
475
|
+
nonce = timestamp_ms
|
|
476
|
+
action = {
|
|
477
|
+
type: 'sendAsset',
|
|
478
|
+
signatureChainId: '0x66eee',
|
|
479
|
+
hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @signer.instance_variable_get(:@testnet)),
|
|
480
|
+
destination: destination,
|
|
481
|
+
sourceDex: source_dex,
|
|
482
|
+
destinationDex: destination_dex,
|
|
483
|
+
token: token,
|
|
484
|
+
amount: amount.to_s,
|
|
485
|
+
fromSubAccount: '',
|
|
486
|
+
nonce: nonce
|
|
487
|
+
}
|
|
488
|
+
signature = @signer.sign_user_signed_action(
|
|
489
|
+
{
|
|
490
|
+
destination: destination, sourceDex: source_dex, destinationDex: destination_dex,
|
|
491
|
+
token: token, amount: amount.to_s, fromSubAccount: '', nonce: nonce
|
|
492
|
+
},
|
|
493
|
+
'HyperliquidTransaction:SendAsset',
|
|
494
|
+
Signing::EIP712::SEND_ASSET_TYPES
|
|
495
|
+
)
|
|
496
|
+
post_action(action, signature, nonce, nil)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Create a sub-account
|
|
500
|
+
# @param name [String] Sub-account name
|
|
501
|
+
# @return [Hash] Creation response
|
|
502
|
+
def create_sub_account(name:)
|
|
503
|
+
nonce = timestamp_ms
|
|
504
|
+
action = { type: 'createSubAccount', name: name }
|
|
505
|
+
signature = @signer.sign_l1_action(action, nonce)
|
|
506
|
+
post_action(action, signature, nonce, nil)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Transfer USDC to/from a sub-account
|
|
510
|
+
# @param sub_account_user [String] Sub-account wallet address
|
|
511
|
+
# @param is_deposit [Boolean] True to deposit into sub-account, false to withdraw
|
|
512
|
+
# @param usd [Numeric] Amount in USD
|
|
513
|
+
# @return [Hash] Transfer response
|
|
514
|
+
def sub_account_transfer(sub_account_user:, is_deposit:, usd:)
|
|
515
|
+
nonce = timestamp_ms
|
|
516
|
+
action = {
|
|
517
|
+
type: 'subAccountTransfer',
|
|
518
|
+
subAccountUser: sub_account_user,
|
|
519
|
+
isDeposit: is_deposit,
|
|
520
|
+
usd: float_to_usd_int(usd)
|
|
521
|
+
}
|
|
522
|
+
signature = @signer.sign_l1_action(action, nonce)
|
|
523
|
+
post_action(action, signature, nonce, nil)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Transfer spot tokens to/from a sub-account
|
|
527
|
+
# @param sub_account_user [String] Sub-account wallet address
|
|
528
|
+
# @param is_deposit [Boolean] True to deposit into sub-account, false to withdraw
|
|
529
|
+
# @param token [String] Token identifier
|
|
530
|
+
# @param amount [String, Numeric] Amount to transfer
|
|
531
|
+
# @return [Hash] Transfer response
|
|
532
|
+
def sub_account_spot_transfer(sub_account_user:, is_deposit:, token:, amount:)
|
|
533
|
+
nonce = timestamp_ms
|
|
534
|
+
action = {
|
|
535
|
+
type: 'subAccountSpotTransfer',
|
|
536
|
+
subAccountUser: sub_account_user,
|
|
537
|
+
isDeposit: is_deposit,
|
|
538
|
+
token: token,
|
|
539
|
+
amount: amount.to_s
|
|
540
|
+
}
|
|
541
|
+
signature = @signer.sign_l1_action(action, nonce)
|
|
542
|
+
post_action(action, signature, nonce, nil)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Deposit or withdraw USDC to/from a vault
|
|
546
|
+
# @param vault_address [String] Vault wallet address
|
|
547
|
+
# @param is_deposit [Boolean] True to deposit, false to withdraw
|
|
548
|
+
# @param usd [Numeric] Amount in USD
|
|
549
|
+
# @return [Hash] Vault transfer response
|
|
550
|
+
def vault_transfer(vault_address:, is_deposit:, usd:)
|
|
551
|
+
nonce = timestamp_ms
|
|
552
|
+
action = {
|
|
553
|
+
type: 'vaultTransfer',
|
|
554
|
+
vaultAddress: vault_address,
|
|
555
|
+
isDeposit: is_deposit,
|
|
556
|
+
usd: float_to_usd_int(usd)
|
|
557
|
+
}
|
|
558
|
+
signature = @signer.sign_l1_action(action, nonce)
|
|
559
|
+
post_action(action, signature, nonce, nil)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Set referral code
|
|
563
|
+
# @param code [String] Referral code
|
|
564
|
+
# @return [Hash] Set referrer response
|
|
565
|
+
def set_referrer(code:)
|
|
566
|
+
nonce = timestamp_ms
|
|
567
|
+
action = { type: 'setReferrer', code: code }
|
|
568
|
+
signature = @signer.sign_l1_action(action, nonce)
|
|
569
|
+
post_action(action, signature, nonce, nil)
|
|
570
|
+
end
|
|
571
|
+
|
|
216
572
|
# Clear the asset metadata cache
|
|
217
573
|
# Call this if metadata has been updated
|
|
218
574
|
def reload_metadata!
|
|
@@ -345,6 +701,29 @@ module Hyperliquid
|
|
|
345
701
|
format("%.#{decimal_places}f", rounded)
|
|
346
702
|
end
|
|
347
703
|
|
|
704
|
+
# Normalize an order ID to the correct wire format
|
|
705
|
+
# @param oid [Integer, Cloid, String] Order ID or client order ID
|
|
706
|
+
# @return [Integer, String] Normalized order ID
|
|
707
|
+
def normalize_oid(oid)
|
|
708
|
+
case oid
|
|
709
|
+
when Integer then oid
|
|
710
|
+
when Cloid then oid.to_raw
|
|
711
|
+
when String then normalize_cloid(oid)
|
|
712
|
+
else raise ArgumentError, "oid must be Integer, Cloid, or String. Got: #{oid.class}"
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Convert a float USD amount to an integer (scaled by 10^6)
|
|
717
|
+
# @param value [Numeric] USD amount
|
|
718
|
+
# @return [Integer] Scaled integer value
|
|
719
|
+
def float_to_usd_int(value)
|
|
720
|
+
scaled = value.to_f * 1_000_000
|
|
721
|
+
rounded = scaled.round
|
|
722
|
+
raise ArgumentError, "float_to_usd_int causes rounding: #{value}" if (rounded - scaled).abs >= 1e-3
|
|
723
|
+
|
|
724
|
+
rounded
|
|
725
|
+
end
|
|
726
|
+
|
|
348
727
|
# Convert cloid to raw string format
|
|
349
728
|
# @param cloid [Cloid, String, nil] Client order ID
|
|
350
729
|
# @return [String, nil] Raw cloid string
|
|
@@ -12,6 +12,61 @@ module Hyperliquid
|
|
|
12
12
|
MAINNET_SOURCE = 'a'
|
|
13
13
|
TESTNET_SOURCE = 'b'
|
|
14
14
|
|
|
15
|
+
# Chain ID for user-signed actions (Arbitrum Sepolia: 0x66eee = 421614)
|
|
16
|
+
USER_SIGNED_CHAIN_ID = 421_614
|
|
17
|
+
|
|
18
|
+
# EIP-712 type definitions for user-signed actions
|
|
19
|
+
|
|
20
|
+
USD_SEND_TYPES = {
|
|
21
|
+
'HyperliquidTransaction:UsdSend': [
|
|
22
|
+
{ name: :hyperliquidChain, type: 'string' },
|
|
23
|
+
{ name: :destination, type: 'string' },
|
|
24
|
+
{ name: :amount, type: 'string' },
|
|
25
|
+
{ name: :time, type: 'uint64' }
|
|
26
|
+
]
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
SPOT_SEND_TYPES = {
|
|
30
|
+
'HyperliquidTransaction:SpotSend': [
|
|
31
|
+
{ name: :hyperliquidChain, type: 'string' },
|
|
32
|
+
{ name: :destination, type: 'string' },
|
|
33
|
+
{ name: :token, type: 'string' },
|
|
34
|
+
{ name: :amount, type: 'string' },
|
|
35
|
+
{ name: :time, type: 'uint64' }
|
|
36
|
+
]
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
USD_CLASS_TRANSFER_TYPES = {
|
|
40
|
+
'HyperliquidTransaction:UsdClassTransfer': [
|
|
41
|
+
{ name: :hyperliquidChain, type: 'string' },
|
|
42
|
+
{ name: :amount, type: 'string' },
|
|
43
|
+
{ name: :toPerp, type: 'bool' },
|
|
44
|
+
{ name: :nonce, type: 'uint64' }
|
|
45
|
+
]
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
WITHDRAW_TYPES = {
|
|
49
|
+
'HyperliquidTransaction:Withdraw': [
|
|
50
|
+
{ name: :hyperliquidChain, type: 'string' },
|
|
51
|
+
{ name: :destination, type: 'string' },
|
|
52
|
+
{ name: :amount, type: 'string' },
|
|
53
|
+
{ name: :time, type: 'uint64' }
|
|
54
|
+
]
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
SEND_ASSET_TYPES = {
|
|
58
|
+
'HyperliquidTransaction:SendAsset': [
|
|
59
|
+
{ name: :hyperliquidChain, type: 'string' },
|
|
60
|
+
{ name: :destination, type: 'string' },
|
|
61
|
+
{ name: :sourceDex, type: 'string' },
|
|
62
|
+
{ name: :destinationDex, type: 'string' },
|
|
63
|
+
{ name: :token, type: 'string' },
|
|
64
|
+
{ name: :amount, type: 'string' },
|
|
65
|
+
{ name: :fromSubAccount, type: 'string' },
|
|
66
|
+
{ name: :nonce, type: 'uint64' }
|
|
67
|
+
]
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
15
70
|
class << self
|
|
16
71
|
# Domain for L1 actions (orders, cancels, leverage, etc.)
|
|
17
72
|
# @return [Hash] EIP-712 domain configuration
|
|
@@ -24,6 +79,17 @@ module Hyperliquid
|
|
|
24
79
|
}
|
|
25
80
|
end
|
|
26
81
|
|
|
82
|
+
# Domain for user-signed actions (transfers, withdrawals, etc.)
|
|
83
|
+
# @return [Hash] EIP-712 domain configuration
|
|
84
|
+
def user_signed_domain
|
|
85
|
+
{
|
|
86
|
+
name: 'HyperliquidSignTransaction',
|
|
87
|
+
version: '1',
|
|
88
|
+
chainId: USER_SIGNED_CHAIN_ID,
|
|
89
|
+
verifyingContract: '0x0000000000000000000000000000000000000000'
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
27
93
|
# EIP-712 domain type definition
|
|
28
94
|
# @return [Array<Hash>] Domain type fields
|
|
29
95
|
def domain_type
|
|
@@ -50,6 +116,13 @@ module Hyperliquid
|
|
|
50
116
|
def source(testnet:)
|
|
51
117
|
testnet ? TESTNET_SOURCE : MAINNET_SOURCE
|
|
52
118
|
end
|
|
119
|
+
|
|
120
|
+
# Get hyperliquid chain name for user-signed actions
|
|
121
|
+
# @param testnet [Boolean] Whether testnet
|
|
122
|
+
# @return [String] "Mainnet" or "Testnet"
|
|
123
|
+
def hyperliquid_chain(testnet:)
|
|
124
|
+
testnet ? 'Testnet' : 'Mainnet'
|
|
125
|
+
end
|
|
53
126
|
end
|
|
54
127
|
end
|
|
55
128
|
end
|
|
@@ -44,6 +44,31 @@ module Hyperliquid
|
|
|
44
44
|
sign_typed_data(typed_data)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Sign a user-signed action (transfers, withdrawals, etc.)
|
|
48
|
+
# Uses direct EIP-712 typed data signing with HyperliquidSignTransaction domain
|
|
49
|
+
# @param action [Hash] The action message to sign (will have chain fields injected)
|
|
50
|
+
# @param primary_type [String] EIP-712 primary type (e.g., "HyperliquidTransaction:UsdSend")
|
|
51
|
+
# @param sign_types [Hash] EIP-712 type definitions for the action
|
|
52
|
+
# @return [Hash] Signature with :r, :s, :v components
|
|
53
|
+
def sign_user_signed_action(action, primary_type, sign_types)
|
|
54
|
+
# Inject chain fields into a copy of the action
|
|
55
|
+
message = action.merge(
|
|
56
|
+
hyperliquidChain: EIP712.hyperliquid_chain(testnet: @testnet),
|
|
57
|
+
signatureChainId: '0x66eee'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
typed_data = {
|
|
61
|
+
types: {
|
|
62
|
+
EIP712Domain: EIP712.domain_type
|
|
63
|
+
}.merge(sign_types),
|
|
64
|
+
primaryType: primary_type,
|
|
65
|
+
domain: EIP712.user_signed_domain,
|
|
66
|
+
message: message
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
sign_typed_data(typed_data)
|
|
70
|
+
end
|
|
71
|
+
|
|
47
72
|
private
|
|
48
73
|
|
|
49
74
|
# Normalize private key format
|
|
@@ -54,7 +79,7 @@ module Hyperliquid
|
|
|
54
79
|
end
|
|
55
80
|
|
|
56
81
|
# Construct the phantom agent for signing
|
|
57
|
-
# Maintains parity with Python SDK
|
|
82
|
+
# Maintains parity with official Python SDK
|
|
58
83
|
# @param action [Hash] Action payload
|
|
59
84
|
# @param nonce [Integer] Nonce timestamp
|
|
60
85
|
# @param vault_address [String, nil] Optional vault address
|
|
@@ -62,7 +87,7 @@ module Hyperliquid
|
|
|
62
87
|
# @return [Hash] Phantom agent with source and connectionId
|
|
63
88
|
def construct_phantom_agent(action, nonce, vault_address, expires_after)
|
|
64
89
|
# Compute action hash
|
|
65
|
-
# Maintains parity with Python SDK
|
|
90
|
+
# Maintains parity with official Python SDK
|
|
66
91
|
# data = msgpack(action) + nonce(8 bytes BE) + vault_flag + [vault_addr] + [expires_flag + expires_after]
|
|
67
92
|
# - Note: expires_flag is only included if expires_after exists. A bit odd but that's what the
|
|
68
93
|
# Python SDK does.
|
data/lib/hyperliquid/version.rb
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Test 1: Spot Market Roundtrip (PURR/USDC)
|
|
5
|
+
# Buy and sell PURR at market price.
|
|
6
|
+
|
|
7
|
+
require_relative 'test_helpers'
|
|
8
|
+
|
|
9
|
+
sdk = build_sdk
|
|
10
|
+
separator('TEST 1: Spot Market Roundtrip (PURR/USDC)')
|
|
11
|
+
|
|
12
|
+
spot_coin = 'PURR/USDC'
|
|
13
|
+
spot_size = 5
|
|
14
|
+
|
|
15
|
+
mids = sdk.info.all_mids
|
|
16
|
+
spot_price = mids[spot_coin]&.to_f
|
|
17
|
+
|
|
18
|
+
if spot_price&.positive?
|
|
19
|
+
puts "#{spot_coin} mid: $#{spot_price}"
|
|
20
|
+
puts "Size: #{spot_size} PURR (~$#{(spot_size * spot_price).round(2)})"
|
|
21
|
+
puts "Slippage: #{(SPOT_SLIPPAGE * 100).to_i}%"
|
|
22
|
+
puts
|
|
23
|
+
|
|
24
|
+
puts 'Placing market BUY...'
|
|
25
|
+
result = sdk.exchange.market_order(
|
|
26
|
+
coin: spot_coin,
|
|
27
|
+
is_buy: true,
|
|
28
|
+
size: spot_size,
|
|
29
|
+
slippage: SPOT_SLIPPAGE
|
|
30
|
+
)
|
|
31
|
+
check_result(result, 'Buy')
|
|
32
|
+
|
|
33
|
+
wait_with_countdown(WAIT_SECONDS, 'Waiting before sell...')
|
|
34
|
+
|
|
35
|
+
puts 'Placing market SELL...'
|
|
36
|
+
result = sdk.exchange.market_order(
|
|
37
|
+
coin: spot_coin,
|
|
38
|
+
is_buy: false,
|
|
39
|
+
size: spot_size,
|
|
40
|
+
slippage: SPOT_SLIPPAGE
|
|
41
|
+
)
|
|
42
|
+
check_result(result, 'Sell')
|
|
43
|
+
else
|
|
44
|
+
puts red("SKIPPED: Could not get #{spot_coin} price")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
test_passed('Test 1 Spot Market Roundtrip')
|
|
48
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Test 2: Spot Limit Order (Place and Cancel)
|
|
5
|
+
# Place a limit buy well below market, then cancel it.
|
|
6
|
+
|
|
7
|
+
require_relative 'test_helpers'
|
|
8
|
+
|
|
9
|
+
sdk = build_sdk
|
|
10
|
+
separator('TEST 2: Spot Limit Order (Place and Cancel)')
|
|
11
|
+
|
|
12
|
+
spot_coin = 'PURR/USDC'
|
|
13
|
+
spot_size = 5
|
|
14
|
+
|
|
15
|
+
mids = sdk.info.all_mids
|
|
16
|
+
spot_price = mids[spot_coin]&.to_f
|
|
17
|
+
|
|
18
|
+
if spot_price&.positive?
|
|
19
|
+
limit_price = (spot_price * 0.50).round(2)
|
|
20
|
+
puts "#{spot_coin} mid: $#{spot_price}"
|
|
21
|
+
puts "Limit price: $#{limit_price} (50% below mid - won't fill)"
|
|
22
|
+
puts "Size: #{spot_size} PURR"
|
|
23
|
+
puts
|
|
24
|
+
|
|
25
|
+
puts 'Placing limit BUY order...'
|
|
26
|
+
result = sdk.exchange.order(
|
|
27
|
+
coin: spot_coin,
|
|
28
|
+
is_buy: true,
|
|
29
|
+
size: spot_size,
|
|
30
|
+
limit_px: limit_price,
|
|
31
|
+
order_type: { limit: { tif: 'Gtc' } },
|
|
32
|
+
reduce_only: false
|
|
33
|
+
)
|
|
34
|
+
oid = check_result(result, 'Limit order')
|
|
35
|
+
|
|
36
|
+
if oid.is_a?(Integer)
|
|
37
|
+
wait_with_countdown(WAIT_SECONDS, 'Order resting. Waiting before cancel...')
|
|
38
|
+
|
|
39
|
+
puts "Canceling order #{oid}..."
|
|
40
|
+
result = sdk.exchange.cancel(coin: spot_coin, oid: oid)
|
|
41
|
+
check_result(result, 'Cancel')
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
puts red("SKIPPED: Could not get #{spot_coin} price")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
test_passed('Test 2 Spot Limit Order')
|
|
48
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Test 3: Perp Market Roundtrip (BTC Long)
|
|
5
|
+
# Open a long BTC position, then close it.
|
|
6
|
+
|
|
7
|
+
require_relative 'test_helpers'
|
|
8
|
+
|
|
9
|
+
sdk = build_sdk
|
|
10
|
+
separator('TEST 3: Perp Market Roundtrip (BTC Long)')
|
|
11
|
+
|
|
12
|
+
perp_coin = 'BTC'
|
|
13
|
+
mids = sdk.info.all_mids
|
|
14
|
+
btc_price = mids[perp_coin]&.to_f
|
|
15
|
+
|
|
16
|
+
if btc_price&.positive?
|
|
17
|
+
meta = sdk.info.meta
|
|
18
|
+
btc_meta = meta['universe'].find { |a| a['name'] == perp_coin }
|
|
19
|
+
sz_decimals = btc_meta['szDecimals']
|
|
20
|
+
|
|
21
|
+
perp_size = (20.0 / btc_price).ceil(sz_decimals)
|
|
22
|
+
|
|
23
|
+
puts "#{perp_coin} mid: $#{btc_price.round(2)}"
|
|
24
|
+
puts "Size: #{perp_size} BTC (~$#{(perp_size * btc_price).round(2)})"
|
|
25
|
+
puts "Slippage: #{(PERP_SLIPPAGE * 100).to_i}%"
|
|
26
|
+
puts
|
|
27
|
+
|
|
28
|
+
puts 'Opening LONG position (market buy)...'
|
|
29
|
+
result = sdk.exchange.market_order(
|
|
30
|
+
coin: perp_coin,
|
|
31
|
+
is_buy: true,
|
|
32
|
+
size: perp_size,
|
|
33
|
+
slippage: PERP_SLIPPAGE
|
|
34
|
+
)
|
|
35
|
+
check_result(result, 'Long open')
|
|
36
|
+
|
|
37
|
+
wait_with_countdown(WAIT_SECONDS, 'Position open. Waiting before close...')
|
|
38
|
+
|
|
39
|
+
puts 'Closing LONG position (market sell)...'
|
|
40
|
+
result = sdk.exchange.market_order(
|
|
41
|
+
coin: perp_coin,
|
|
42
|
+
is_buy: false,
|
|
43
|
+
size: perp_size,
|
|
44
|
+
slippage: PERP_SLIPPAGE
|
|
45
|
+
)
|
|
46
|
+
check_result(result, 'Long close')
|
|
47
|
+
else
|
|
48
|
+
puts red("SKIPPED: Could not get #{perp_coin} price")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
test_passed('Test 3 Perp Market Roundtrip')
|
|
52
|
+
|