hyperliquid 1.9.2 → 2.0.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +26 -0
  4. data/docs/API.md +374 -152
  5. data/docs/CONFIGURATION.md +25 -11
  6. data/docs/DEVELOPMENT.md +93 -15
  7. data/docs/ERRORS.md +2 -0
  8. data/docs/EXAMPLES.md +253 -2
  9. data/docs/WS.md +78 -20
  10. data/lib/hyperliquid/client.rb +19 -10
  11. data/lib/hyperliquid/exchange.rb +844 -22
  12. data/lib/hyperliquid/info.rb +23 -11
  13. data/lib/hyperliquid/signing/signer.rb +18 -1
  14. data/lib/hyperliquid/version.rb +1 -1
  15. data/lib/hyperliquid/ws/client.rb +192 -78
  16. data/lib/hyperliquid.rb +6 -0
  17. metadata +16 -34
  18. data/.rspec +0 -3
  19. data/.rubocop.yml +0 -49
  20. data/.ruby-version +0 -1
  21. data/CLAUDE.md +0 -108
  22. data/CODE_OF_CONDUCT.md +0 -132
  23. data/Rakefile +0 -12
  24. data/example.rb +0 -161
  25. data/scripts/test_01_spot_market_roundtrip.rb +0 -48
  26. data/scripts/test_02_spot_limit_order.rb +0 -48
  27. data/scripts/test_03_perp_market_roundtrip.rb +0 -58
  28. data/scripts/test_04_perp_limit_order.rb +0 -52
  29. data/scripts/test_05_update_leverage.rb +0 -50
  30. data/scripts/test_06_modify_order.rb +0 -67
  31. data/scripts/test_07_market_close.rb +0 -55
  32. data/scripts/test_08_usd_class_transfer.rb +0 -36
  33. data/scripts/test_09_sub_account_lifecycle.rb +0 -51
  34. data/scripts/test_10_vault.rb +0 -68
  35. data/scripts/test_11_builder_fee.rb +0 -81
  36. data/scripts/test_12_staking.rb +0 -70
  37. data/scripts/test_13_ws_l2_book.rb +0 -83
  38. data/scripts/test_14_ws_candle.rb +0 -81
  39. data/scripts/test_15_explorer.rb +0 -71
  40. data/scripts/test_16_send_to_evm_with_data.rb +0 -79
  41. data/scripts/test_17_create_vault.rb +0 -73
  42. data/scripts/test_18_user_portfolio_margin.rb +0 -72
  43. data/scripts/test_19_spot_user.rb +0 -55
  44. data/scripts/test_20_explorer_ws.rb +0 -92
  45. data/scripts/test_all.rb +0 -96
  46. data/scripts/test_automated.rb +0 -82
  47. data/scripts/test_helpers.rb +0 -238
  48. data/sig/hyperliquid.rbs +0 -4
  49. data/test_integration.rb +0 -17
@@ -34,6 +34,12 @@ module Hyperliquid
34
34
  @signer.address
35
35
  end
36
36
 
37
+ # Redacted representation: never exposes the signer's private key.
38
+ # @return [String]
39
+ def inspect
40
+ "#<#{self.class.name} address=#{address} testnet=#{@testnet} expires_after=#{@expires_after.inspect}>"
41
+ end
42
+
37
43
  # Place a single order
38
44
  # @param coin [String] Asset symbol (e.g., "BTC")
39
45
  # @param is_buy [Boolean] True for buy, false for sell
@@ -341,7 +347,7 @@ module Hyperliquid
341
347
  end
342
348
 
343
349
  # Schedule automatic cancellation of all orders
344
- # @param time [Integer, nil] UTC timestamp in milliseconds to cancel at (nil to activate with server default)
350
+ # @param time [Integer, nil] UTC timestamp in milliseconds to cancel at (nil removes the scheduled cancel)
345
351
  # @param vault_address [String, nil] Vault address for vault trading (optional)
346
352
  # @return [Hash] Schedule cancel response
347
353
  def schedule_cancel(time: nil, vault_address: nil)
@@ -517,7 +523,7 @@ module Hyperliquid
517
523
  def create_sub_account(name:)
518
524
  nonce = timestamp_ms
519
525
  action = { type: 'createSubAccount', name: name }
520
- signature = @signer.sign_l1_action(action, nonce)
526
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
521
527
  post_action(action, signature, nonce, nil)
522
528
  end
523
529
 
@@ -534,7 +540,7 @@ module Hyperliquid
534
540
  isDeposit: is_deposit,
535
541
  usd: float_to_usd_int(usd)
536
542
  }
537
- signature = @signer.sign_l1_action(action, nonce)
543
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
538
544
  post_action(action, signature, nonce, nil)
539
545
  end
540
546
 
@@ -553,7 +559,7 @@ module Hyperliquid
553
559
  token: token,
554
560
  amount: amount.to_s
555
561
  }
556
- signature = @signer.sign_l1_action(action, nonce)
562
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
557
563
  post_action(action, signature, nonce, nil)
558
564
  end
559
565
 
@@ -570,7 +576,7 @@ module Hyperliquid
570
576
  isDeposit: is_deposit,
571
577
  usd: float_to_usd_int(usd)
572
578
  }
573
- signature = @signer.sign_l1_action(action, nonce)
579
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
574
580
  post_action(action, signature, nonce, nil)
575
581
  end
576
582
 
@@ -580,7 +586,7 @@ module Hyperliquid
580
586
  def set_referrer(code:)
581
587
  nonce = timestamp_ms
582
588
  action = { type: 'setReferrer', code: code }
583
- signature = @signer.sign_l1_action(action, nonce)
589
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
584
590
  post_action(action, signature, nonce, nil)
585
591
  end
586
592
 
@@ -712,7 +718,8 @@ module Hyperliquid
712
718
  # Set the abstraction mode for a user (`userSetAbstraction` user-signed action).
713
719
  # The `user` address is lowercased to match the Python SDK and protocol expectations.
714
720
  # @param user [String] Wallet address whose abstraction is being set
715
- # @param abstraction [String] One of 'u' (unified), 'p' (portfolio margin), 'i' (isolated/disabled)
721
+ # @param abstraction [String] One of 'unifiedAccount', 'portfolioMargin', 'disabled'
722
+ # (long form; the L1 agentSetAbstraction uses 'u'/'p'/'i')
716
723
  # @return [Hash] Exchange response
717
724
  def user_set_abstraction(user:, abstraction:)
718
725
  nonce = timestamp_ms
@@ -1269,6 +1276,35 @@ module Hyperliquid
1269
1276
  post_action(action, signature, nonce, vault_address)
1270
1277
  end
1271
1278
 
1279
+ # Place a trailing stop order (`trailingStop` L1 action).
1280
+ # @param coin [String] Asset symbol
1281
+ # @param is_buy [Boolean] True for buy/long, false for sell/short
1282
+ # @param size [String, Numeric] Order size (base currency units)
1283
+ # @param reduce_only [Boolean] Reduce-only flag
1284
+ # @param retracement [Hash] Trailing retracement config, passed through verbatim:
1285
+ # { pct: "<percent String, e.g. '1.234%'>" } or { px: "<price String>" }
1286
+ # @param activation_px [String, nil] Activation price; nil sends null (no activation threshold)
1287
+ # @param vault_address [String, nil] Vault address if acting on behalf of a vault
1288
+ # @return [Hash] Exchange response — on success `response.data.oid`
1289
+ def trailing_stop(coin:, is_buy:, size:, reduce_only:, retracement:, activation_px: nil, vault_address: nil)
1290
+ nonce = timestamp_ms
1291
+ action = {
1292
+ type: 'trailingStop',
1293
+ asset: asset_index(coin),
1294
+ isBuy: is_buy,
1295
+ sz: float_to_wire(size),
1296
+ reduceOnly: reduce_only,
1297
+ retracement: retracement,
1298
+ activationPx: activation_px
1299
+ }
1300
+ signature = @signer.sign_l1_action(
1301
+ action, nonce,
1302
+ vault_address: vault_address,
1303
+ expires_after: @expires_after
1304
+ )
1305
+ post_action(action, signature, nonce, vault_address)
1306
+ end
1307
+
1272
1308
  # Reserve additional rate-limited actions for a fee (`reserveRequestWeight` L1 action).
1273
1309
  # @param weight [Integer] Amount of request weight to reserve
1274
1310
  # @param destination [String, nil] Address of an existing user to reserve the weight for; nil omits the field
@@ -1360,18 +1396,130 @@ module Hyperliquid
1360
1396
  post_action(action, signature, nonce, nil)
1361
1397
  end
1362
1398
 
1363
- # Activate or deactivate the signer as an outcome deployer
1364
- # (`activateOutcomeDeployer` L1 action, HIP-4).
1365
- # @param is_deactivate [Boolean] True to deactivate, false to activate
1399
+ # Activate the signer as a HIP-4 outcome deployer and claim a venue name
1400
+ # (`activateOutcomeDeployer` L1 action, activate variant). Irreversible in practice:
1401
+ # the stake is locked for the minimum staking duration and the venue name stays
1402
+ # reserved forever. Requires Standard account abstraction ("disabled").
1403
+ # @param venue_name [String] Venue to claim (2-4 lowercase ASCII letters; server-validated)
1366
1404
  # @return [Hash] Exchange response
1367
- def activate_outcome_deployer(is_deactivate:)
1368
- nonce = timestamp_ms
1369
- action = { type: 'activateOutcomeDeployer', isDeactivate: is_deactivate }
1370
- signature = @signer.sign_l1_action(
1371
- action, nonce,
1372
- expires_after: @expires_after
1405
+ def activate_outcome_deployer(venue_name:)
1406
+ outcome_deployer_activation({ activate: { venueName: venue_name } })
1407
+ end
1408
+
1409
+ # Permanently deactivate the signer as a HIP-4 outcome deployer
1410
+ # (`activateOutcomeDeployer` L1 action, deactivate variant). Requires the minimum
1411
+ # staking duration to have elapsed and no active outcomes; the account can never
1412
+ # activate again and its venue name stays reserved.
1413
+ # @return [Hash] Exchange response
1414
+ def deactivate_outcome_deployer
1415
+ outcome_deployer_activation({ deactivate: nil })
1416
+ end
1417
+
1418
+ # HIP-4: deploy a standalone YES/NO outcome from a standalone-outcome template
1419
+ # (`outcomeDeploy` L1 action, registerStandaloneOutcomeFromTemplate operation).
1420
+ # The response is passed through unmodified; callers must check its `status`.
1421
+ # @param venue [String] Deployer venue (sub-deployers pass the venue they act for)
1422
+ # @param template_id [String] Template id (see Info#outcome_templates)
1423
+ # @param keyword_to_value [Hash, Array<Array(String, String)>] One value per template
1424
+ # keyword; keys/values are stringified and sorted by keyword before signing
1425
+ # @param deployer_fee_scale [String, Numeric] Decimal in [0, 10]; Strings are sent verbatim
1426
+ # @return [Hash] Exchange response
1427
+ def register_standalone_outcome_from_template(venue:, template_id:, keyword_to_value:, deployer_fee_scale:)
1428
+ instance = outcome_template_instance(template_id, keyword_to_value, deployer_fee_scale)
1429
+ outcome_deploy_action(venue, { registerStandaloneOutcomeFromTemplate: instance })
1430
+ end
1431
+
1432
+ # HIP-4: deploy a question plus its named outcomes in one action
1433
+ # (`outcomeDeploy` L1 action, registerQuestionFromTemplate operation). The protocol
1434
+ # also creates the question's fallback outcome.
1435
+ # @param venue [String] Deployer venue
1436
+ # @param template_id [String] Question template id
1437
+ # @param keyword_to_value [Hash, Array] Question template keyword values (sorted by the SDK)
1438
+ # @param deployer_fee_scale [String, Numeric] Decimal in [0, 10], applies to every outcome of the question
1439
+ # @param named_outcomes [Array<Hash>] Each `{ template_id:, keyword_to_value: }`; order preserved
1440
+ # @return [Hash] Exchange response
1441
+ def register_question_from_template(venue:, template_id:, keyword_to_value:, deployer_fee_scale:, named_outcomes:)
1442
+ outcome_deploy_action(
1443
+ venue,
1444
+ { registerQuestionFromTemplate: {
1445
+ questionTemplateInstance: outcome_template_instance(template_id, keyword_to_value, deployer_fee_scale),
1446
+ namedOutcomeTemplateInstances: named_outcomes.map do |named|
1447
+ outcome_template_instance(named.fetch(:template_id), named.fetch(:keyword_to_value))
1448
+ end
1449
+ } }
1450
+ )
1451
+ end
1452
+
1453
+ # HIP-4: add one named outcome to a live template-deployed question
1454
+ # (`outcomeDeploy` L1 action, registerAndAssociateNamedOutcomeFromTemplate operation).
1455
+ # @param venue [String] Deployer venue
1456
+ # @param question [Integer] Question identifier
1457
+ # @param template_id [String] Question-outcome template id (parent must be the question's template)
1458
+ # @param keyword_to_value [Hash, Array] Template keyword values (sorted by the SDK)
1459
+ # @return [Hash] Exchange response
1460
+ def register_and_associate_named_outcome_from_template(venue:, question:, template_id:, keyword_to_value:)
1461
+ outcome_deploy_action(
1462
+ venue,
1463
+ { registerAndAssociateNamedOutcomeFromTemplate: {
1464
+ question: question.to_i,
1465
+ namedOutcomeTemplateInstance: outcome_template_instance(template_id, keyword_to_value)
1466
+ } }
1467
+ )
1468
+ end
1469
+
1470
+ # HIP-4: settle one outcome of the venue (`outcomeDeploy` L1 action, settleOutcome operation).
1471
+ # `name`, `description` and `side_names` must exactly match the outcome (copy them from
1472
+ # Info#outcome_meta: name, description, sideSpecs[].name).
1473
+ # @param venue [String] Deployer venue
1474
+ # @param outcome [Integer] Outcome identifier
1475
+ # @param settle_fraction [String, Numeric] Payout fraction of the first side in [0, 1]
1476
+ # @param name [String] Outcome name
1477
+ # @param description [String] Outcome description
1478
+ # @param side_names [Array<String>] The two side names
1479
+ # @param details [String] Settlement details (protocol currently requires '')
1480
+ # @return [Hash] Exchange response
1481
+ def settle_outcome(venue:, outcome:, settle_fraction:, name:, description:, side_names:, details: '')
1482
+ settlement = outcome_settlement(outcome: outcome, settle_fraction: settle_fraction, name: name,
1483
+ description: description, side_names: side_names, details: details)
1484
+ outcome_deploy_action(venue, { settleOutcome: settlement })
1485
+ end
1486
+
1487
+ # HIP-4: settle all remaining named outcomes of a question in one action
1488
+ # (`outcomeDeploy` L1 action, settleQuestion2 operation; the original settleQuestion
1489
+ # is discontinued). Exactly one settlement must use fraction "1", the rest "0".
1490
+ # @param venue [String] Deployer venue
1491
+ # @param question [Integer] Question identifier
1492
+ # @param name [String] Question name (must match outcome_meta)
1493
+ # @param description [String] Question description (must match outcome_meta)
1494
+ # @param settlements [Array<Hash>] Each `{ outcome:, settle_fraction:, name:, description:,
1495
+ # side_names:, details: '' }` with Symbol keys; order preserved
1496
+ # @return [Hash] Exchange response
1497
+ def settle_question(venue:, question:, name:, description:, settlements:)
1498
+ outcome_deploy_action(
1499
+ venue,
1500
+ { settleQuestion2: {
1501
+ question: question.to_i,
1502
+ outcomeSettlements: settlements.map { |s| outcome_settlement(**s) },
1503
+ nameAndDescription: [name, description]
1504
+ } }
1505
+ )
1506
+ end
1507
+
1508
+ # HIP-4: grant or revoke sub-deployer permissions per operation
1509
+ # (`outcomeDeploy` L1 action, setSubDeployers operation).
1510
+ # @param venue [String] Deployer venue
1511
+ # @param changes [Array<Hash>] Each `{ variant:, user:, allowed: }` where variant is the camelCase
1512
+ # wire name (registerStandaloneOutcomeFromTemplate, registerQuestionFromTemplate,
1513
+ # registerAndAssociateNamedOutcomeFromTemplate, settleOutcome, or settleQuestion — which
1514
+ # authorizes settle_question); user is lowercased; order preserved
1515
+ # @return [Hash] Exchange response
1516
+ def set_outcome_sub_deployers(venue:, changes:)
1517
+ outcome_deploy_action(
1518
+ venue,
1519
+ { setSubDeployers: changes.map do |change|
1520
+ { variant: change.fetch(:variant).to_s, user: change.fetch(:user).downcase, allowed: change.fetch(:allowed) }
1521
+ end }
1373
1522
  )
1374
- post_action(action, signature, nonce, nil)
1375
1523
  end
1376
1524
 
1377
1525
  # Finalize the link between a HyperCore spot token and an ERC-20 contract on
@@ -1425,6 +1573,520 @@ module Hyperliquid
1425
1573
  post_action(action, signature, nonce, nil)
1426
1574
  end
1427
1575
 
1576
+ # Validator operators: jail the signer's own validator (`CSignerAction` L1 action, jailSelf variant).
1577
+ # Must be signed by the validator's registered signer key.
1578
+ # @return [Hash] Exchange response
1579
+ def c_signer_jail_self
1580
+ c_signer_action(:jailSelf)
1581
+ end
1582
+
1583
+ # Validator operators: unjail the signer's own validator (`CSignerAction` L1 action, unjailSelf variant).
1584
+ # Must be signed by the validator's registered signer key.
1585
+ # @return [Hash] Exchange response
1586
+ def c_signer_unjail_self
1587
+ c_signer_action(:unjailSelf)
1588
+ end
1589
+
1590
+ # Validator operators: vote on the risk-free rate for the aligned quote asset
1591
+ # (`validatorL1Stream` L1 action).
1592
+ # @param risk_free_rate [String, Numeric] Rate as a decimal (e.g. "0.04" for 4%); normalised via float_to_wire
1593
+ # @return [Hash] Exchange response
1594
+ def validator_l1_stream(risk_free_rate:)
1595
+ nonce = timestamp_ms
1596
+ action = { type: 'validatorL1Stream', riskFreeRate: float_to_wire(risk_free_rate) }
1597
+ signature = @signer.sign_l1_action(
1598
+ action, nonce,
1599
+ expires_after: @expires_after
1600
+ )
1601
+ post_action(action, signature, nonce, nil)
1602
+ end
1603
+
1604
+ # Validator operators: register a new validator (`CValidatorAction` L1 action, register variant).
1605
+ # Signed by the validator (owner) key. Wire keys are protocol-literal snake_case.
1606
+ # @param node_ip [String] Validator node IP address (sent as { Ip: node_ip })
1607
+ # @param name [String] Validator name
1608
+ # @param description [String] Validator description
1609
+ # @param delegations_disabled [Boolean] Whether delegations are disabled
1610
+ # @param commission_bps [Integer] Commission in basis points
1611
+ # @param signer [String] Signer address (lowercased)
1612
+ # @param unjailed [Boolean] Initial jail status (true = unjailed)
1613
+ # @param initial_wei [Integer] Initial self-stake in wei
1614
+ # @return [Hash] Exchange response
1615
+ def c_validator_register(node_ip:, name:, description:, delegations_disabled:, commission_bps:,
1616
+ signer:, unjailed:, initial_wei:)
1617
+ c_validator_action(
1618
+ register: {
1619
+ profile: {
1620
+ node_ip: { Ip: node_ip },
1621
+ name: name,
1622
+ description: description,
1623
+ delegations_disabled: delegations_disabled,
1624
+ commission_bps: Integer(commission_bps),
1625
+ signer: signer.downcase
1626
+ },
1627
+ unjailed: unjailed,
1628
+ initial_wei: Integer(initial_wei)
1629
+ }
1630
+ )
1631
+ end
1632
+
1633
+ # Validator operators: change the validator profile (`CValidatorAction` L1 action, changeProfile variant).
1634
+ # Every field is always sent; nil means "leave unchanged" (sent as JSON null).
1635
+ # @param unjailed [Boolean] Desired jail status (required)
1636
+ # @param node_ip [String, nil] New node IP (sent as { Ip: node_ip }) or nil
1637
+ # @param name [String, nil] New name or nil
1638
+ # @param description [String, nil] New description or nil
1639
+ # @param disable_delegations [Boolean, nil] Enable/disable delegations, or nil
1640
+ # @param commission_bps [Integer, nil] New commission in basis points, or nil
1641
+ # @param signer [String, nil] New signer address (lowercased), or nil
1642
+ # @return [Hash] Exchange response
1643
+ def c_validator_change_profile(unjailed:, node_ip: nil, name: nil, description: nil,
1644
+ disable_delegations: nil, commission_bps: nil, signer: nil)
1645
+ c_validator_action(
1646
+ changeProfile: {
1647
+ node_ip: node_ip.nil? ? nil : { Ip: node_ip },
1648
+ name: name,
1649
+ description: description,
1650
+ unjailed: unjailed,
1651
+ disable_delegations: disable_delegations,
1652
+ commission_bps: commission_bps.nil? ? nil : Integer(commission_bps),
1653
+ signer: signer&.downcase
1654
+ }
1655
+ )
1656
+ end
1657
+
1658
+ # Validator operators: unregister the validator (`CValidatorAction` L1 action, unregister variant).
1659
+ # @return [Hash] Exchange response
1660
+ def c_validator_unregister
1661
+ c_validator_action(unregister: nil)
1662
+ end
1663
+
1664
+ # --- HIP-3 perp deployer actions (perpDeploy L1 action) ---
1665
+ # All are L1-signed `perpDeploy` actions; none accept vault_address; expires_after propagates.
1666
+ # Coins are full HIP-3 names ("<dex>:<COIN>"). Decimal args accept String (sent verbatim)
1667
+ # or Numeric (normalized via float_to_wire). Integer args are Integer()-coerced and never scaled.
1668
+
1669
+ # Register a new asset on a HIP-3 perp dex (legacy `registerAsset` variant, Python SDK parity).
1670
+ # Passing `schema:` also creates the dex (first registration). `max_gas: nil` bids the current
1671
+ # deploy-auction price; `max_gas: 0` uses a reserve deployment (check perp_deploy_auction_status first).
1672
+ # @param dex [String] Perp dex name (2-4 lowercase chars)
1673
+ # @param coin [String] Full asset name, e.g. "test:TEST0"
1674
+ # @param sz_decimals [Integer] Size decimals
1675
+ # @param oracle_px [String, Numeric] Initial oracle price
1676
+ # @param margin_table_id [Integer] Margin table id
1677
+ # @param only_isolated [Boolean] Whether the asset is isolated-margin only
1678
+ # @param max_gas [Integer, nil] Max gas in native-token wei; nil = current auction price
1679
+ # @param schema [Hash, nil] New-dex schema:
1680
+ # { full_name:, collateral_token:, oracle_updater: nil, is_star: (optional) }
1681
+ # @return [Hash] Exchange response
1682
+ def perp_deploy_register_asset(dex:, coin:, sz_decimals:, oracle_px:, margin_table_id:, only_isolated:,
1683
+ max_gas: nil, schema: nil)
1684
+ asset_request = perp_deploy_asset_request(coin, sz_decimals, oracle_px, margin_table_id)
1685
+ asset_request[:onlyIsolated] = only_isolated
1686
+ perp_deploy_action(:registerAsset, perp_deploy_register_payload(asset_request, dex, max_gas, schema))
1687
+ end
1688
+
1689
+ # Register a new asset with an explicit margin mode (`registerAsset2` variant).
1690
+ # @param margin_mode [String] "strictIsolated", "noCross", or "normal"
1691
+ # (other params as perp_deploy_register_asset)
1692
+ # @return [Hash] Exchange response
1693
+ def perp_deploy_register_asset2(dex:, coin:, sz_decimals:, oracle_px:, margin_table_id:, margin_mode:,
1694
+ max_gas: nil, schema: nil)
1695
+ asset_request = perp_deploy_asset_request(coin, sz_decimals, oracle_px, margin_table_id)
1696
+ asset_request[:marginMode] = margin_mode
1697
+ perp_deploy_action(:registerAsset2, perp_deploy_register_payload(asset_request, dex, max_gas, schema))
1698
+ end
1699
+
1700
+ # Push oracle, mark, and external perp prices (`setOracle` variant; ≥2.5s between calls).
1701
+ # @param dex [String] Perp dex name
1702
+ # @param oracle_pxs [Hash{String=>String,Numeric}] coin => oracle price
1703
+ # @param all_mark_pxs [Array<Hash{String=>String,Numeric}>] 0-2 hashes of coin => mark price
1704
+ # @param external_perp_pxs [Hash{String=>String,Numeric}] coin => external perp price (must include all assets)
1705
+ # @return [Hash] Exchange response
1706
+ def perp_deploy_set_oracle(dex:, oracle_pxs:, all_mark_pxs:, external_perp_pxs:)
1707
+ perp_deploy_action(:setOracle, {
1708
+ dex: dex,
1709
+ oraclePxs: sorted_coin_pairs(oracle_pxs) { |px| perp_deploy_decimal(px) },
1710
+ markPxs: all_mark_pxs.map { |pxs| sorted_coin_pairs(pxs) { |px| perp_deploy_decimal(px) } },
1711
+ externalPerpPxs: sorted_coin_pairs(external_perp_pxs) { |px| perp_deploy_decimal(px) }
1712
+ })
1713
+ end
1714
+
1715
+ # Set per-asset funding multipliers (`setFundingMultipliers` variant).
1716
+ # @param multipliers [Hash{String=>String,Numeric}] coin => funding multiplier (0-10)
1717
+ # @return [Hash] Exchange response
1718
+ def perp_deploy_set_funding_multipliers(multipliers:)
1719
+ perp_deploy_action(:setFundingMultipliers, sorted_coin_pairs(multipliers) { |v| perp_deploy_decimal(v) })
1720
+ end
1721
+
1722
+ # Set per-asset funding interest rates (`setFundingInterestRates` variant).
1723
+ # @param rates [Hash{String=>String,Numeric}] coin => 8h interest rate (-0.01..0.01)
1724
+ # @return [Hash] Exchange response
1725
+ def perp_deploy_set_funding_interest_rates(rates:)
1726
+ perp_deploy_action(:setFundingInterestRates, sorted_coin_pairs(rates) { |v| perp_deploy_decimal(v) })
1727
+ end
1728
+
1729
+ # Set per-asset funding clamps (`setFundingClamps` variant).
1730
+ # @param clamps [Hash{String=>String,Numeric}] coin => 8h funding clamp (0..0.01; default 0.0003)
1731
+ # @return [Hash] Exchange response
1732
+ def perp_deploy_set_funding_clamps(clamps:)
1733
+ perp_deploy_action(:setFundingClamps, sorted_coin_pairs(clamps) { |v| perp_deploy_decimal(v) })
1734
+ end
1735
+
1736
+ # Halt or resume trading on a HIP-3 asset (`haltTrading` variant).
1737
+ # @param coin [String] Full asset name
1738
+ # @param is_halted [Boolean] true halts trading, false resumes
1739
+ # @return [Hash] Exchange response
1740
+ def perp_deploy_halt_trading(coin:, is_halted:)
1741
+ perp_deploy_action(:haltTrading, { coin: coin, isHalted: is_halted })
1742
+ end
1743
+
1744
+ # Insert a margin table on a HIP-3 perp dex (`insertMarginTable` variant).
1745
+ # Integer() coercion: Floats truncate; Strings use Ruby literal prefixes ('0x10' == 16).
1746
+ # @param dex [String] Perp dex name
1747
+ # @param description [String] Margin table description
1748
+ # @param margin_tiers [Array<Hash>] ≤3 tiers { lower_bound: Integer (1e-6 collateral units), max_leverage: 1..50 },
1749
+ # increasing lower_bound / decreasing max_leverage; order preserved
1750
+ # @return [Hash] Exchange response
1751
+ def perp_deploy_insert_margin_table(dex:, description:, margin_tiers:)
1752
+ tiers = margin_tiers.map do |tier|
1753
+ { lowerBound: Integer(tier.fetch(:lower_bound)), maxLeverage: Integer(tier.fetch(:max_leverage)) }
1754
+ end
1755
+ perp_deploy_action(:insertMarginTable,
1756
+ { dex: dex, marginTable: { description: description, marginTiers: tiers } })
1757
+ end
1758
+
1759
+ # Assign margin tables to assets (`setMarginTableIds` variant).
1760
+ # @param margin_table_ids [Hash{String=>Integer}] coin => non-zero margin table id
1761
+ # @return [Hash] Exchange response
1762
+ def perp_deploy_set_margin_table_ids(margin_table_ids:)
1763
+ perp_deploy_action(:setMarginTableIds, sorted_coin_pairs(margin_table_ids) { |id| Integer(id) })
1764
+ end
1765
+
1766
+ # Set per-asset margin modes (`setMarginModes` variant); modes are passed through verbatim.
1767
+ # @param margin_modes [Hash{String=>String}] coin => "strictIsolated" | "noCross" | "normal"
1768
+ # @return [Hash] Exchange response
1769
+ def perp_deploy_set_margin_modes(margin_modes:)
1770
+ perp_deploy_action(:setMarginModes, sorted_coin_pairs(margin_modes) { |mode| mode })
1771
+ end
1772
+
1773
+ # Set per-asset open interest caps (`setOpenInterestCaps` variant; caps ≥ 1_000_000).
1774
+ # @param caps [Hash{String=>Integer,nil}] coin => OI cap notional in 1e-6 collateral units;
1775
+ # nil removes the custom cap
1776
+ # @return [Hash] Exchange response
1777
+ def perp_deploy_set_open_interest_caps(caps:)
1778
+ perp_deploy_action(:setOpenInterestCaps, sorted_coin_pairs(caps) { |cap| cap.nil? ? nil : Integer(cap) })
1779
+ end
1780
+
1781
+ # Set the fee recipient of a HIP-3 perp dex (`setFeeRecipient` variant).
1782
+ # @param dex [String] Perp dex name
1783
+ # @param fee_recipient [String] Fee recipient address (lowercased)
1784
+ # @return [Hash] Exchange response
1785
+ def perp_deploy_set_fee_recipient(dex:, fee_recipient:)
1786
+ perp_deploy_action(:setFeeRecipient, { dex: dex, feeRecipient: fee_recipient.downcase })
1787
+ end
1788
+
1789
+ # Per-asset deployer fee share + growth mode (`setDeployerFees`; mainnet: one change per 30 days).
1790
+ # @param fees [Hash{String=>Hash}] coin => { scale: String|Numeric, growth_mode: Boolean };
1791
+ # scale 0..3, or <10 with growth mode
1792
+ # @return [Hash] Exchange response
1793
+ def perp_deploy_set_deployer_fees(fees:)
1794
+ perp_deploy_action(:setDeployerFees, sorted_coin_pairs(fees) do |fee|
1795
+ { scale: perp_deploy_decimal(fee.fetch(:scale)), growthMode: fee.fetch(:growth_mode) }
1796
+ end)
1797
+ end
1798
+
1799
+ # Grant or revoke sub-deployer permissions (`setSubDeployers` variant); entry order is preserved.
1800
+ # @param dex [String] Perp dex name
1801
+ # @param sub_deployers [Array<Hash>] { variant:, user:, allowed: Boolean }; variant is a String
1802
+ # (e.g. "setOracle") or a Hash (e.g. { hip3Star: "order" }) passed through verbatim; user is lowercased
1803
+ # @return [Hash] Exchange response
1804
+ def perp_deploy_set_sub_deployers(dex:, sub_deployers:)
1805
+ entries = sub_deployers.map do |entry|
1806
+ { variant: entry.fetch(:variant), user: entry.fetch(:user).downcase, allowed: entry.fetch(:allowed) }
1807
+ end
1808
+ perp_deploy_action(:setSubDeployers, { dex: dex, subDeployers: entries })
1809
+ end
1810
+
1811
+ # Set an asset's annotation (`setPerpAnnotation` variant).
1812
+ # @param coin [String] Full asset name
1813
+ # @param category [String] ≤15 chars
1814
+ # @param description [String] ≤400 chars
1815
+ # @param display_name [String, nil] ≤9 chars; nil sends null
1816
+ # @param keywords [Array<String>] ≤2 keywords, each ≤10 chars
1817
+ # @return [Hash] Exchange response
1818
+ def perp_deploy_set_perp_annotation(coin:, category:, description:, display_name: nil, keywords: [])
1819
+ perp_deploy_action(:setPerpAnnotation, {
1820
+ coin: coin, category: category, description: description,
1821
+ displayName: display_name, keywords: keywords
1822
+ })
1823
+ end
1824
+
1825
+ # Disable (shut down) a HIP-3 perp dex (`disableDex` variant).
1826
+ # @param dex [String] Name of the perp dex to disable
1827
+ # @return [Hash] Exchange response
1828
+ def perp_deploy_disable_dex(dex:)
1829
+ perp_deploy_action(:disableDex, dex)
1830
+ end
1831
+
1832
+ # ---- Spot deploy (HIP-1 / HIP-2): one `spotDeploy` L1 action, one method per variant ----
1833
+
1834
+ # Register a new spot token via the deploy gas auction (`spotDeploy.registerToken2`, L1).
1835
+ # On success `response.data` is the new token index.
1836
+ # @param token_name [String] Token ticker
1837
+ # @param sz_decimals [Integer] Size decimals
1838
+ # @param wei_decimals [Integer] Wei decimals
1839
+ # @param max_gas [Integer] Maximum auction gas the deployer will pay (HYPE wei units)
1840
+ # @param full_name [String, nil] Optional full name (omitted when nil)
1841
+ # Integer params accept an Integer or an integer String (coerced with Integer(); Floats are truncated).
1842
+ # @return [Hash] Exchange response
1843
+ def spot_deploy_register_token(token_name:, sz_decimals:, wei_decimals:, max_gas:, full_name: nil)
1844
+ register_token = {
1845
+ spec: { name: token_name, szDecimals: Integer(sz_decimals), weiDecimals: Integer(wei_decimals) },
1846
+ maxGas: Integer(max_gas)
1847
+ }
1848
+ register_token[:fullName] = full_name unless full_name.nil?
1849
+ spot_deploy_action(:registerToken2, register_token)
1850
+ end
1851
+
1852
+ # Assign genesis balances for a deployed token (`spotDeploy.userGenesis`, L1). Repeatable.
1853
+ # @param token [Integer] Token index
1854
+ # @param user_and_wei [Array<Array(String, Integer|String)>] [[address, wei], ...]
1855
+ # @param existing_token_and_wei [Array<Array(Integer, Integer|String)>] [[token, wei], ...]
1856
+ # @param blacklist_users [Array<Array(String, Boolean)>, nil] Omitted when nil. The server rejects it
1857
+ # (even []) unless both other lists are empty.
1858
+ # @return [Hash] Exchange response
1859
+ def spot_deploy_user_genesis(token:, user_and_wei:, existing_token_and_wei:, blacklist_users: nil)
1860
+ user_genesis = {
1861
+ token: Integer(token),
1862
+ userAndWei: user_and_wei.map { |user, wei| [user.downcase, wei_to_wire(wei)] },
1863
+ existingTokenAndWei: existing_token_and_wei.map { |existing, wei| [Integer(existing), wei_to_wire(wei)] }
1864
+ }
1865
+ unless blacklist_users.nil?
1866
+ user_genesis[:blacklistUsers] = blacklist_users.map { |user, blacklist| [user.downcase, blacklist] }
1867
+ end
1868
+ spot_deploy_action(:userGenesis, user_genesis)
1869
+ end
1870
+
1871
+ # Finalize genesis for a deployed token (`spotDeploy.genesis`, L1).
1872
+ # @param token [Integer] Token index
1873
+ # @param max_supply [Integer, String] Maximum supply in wei (Float raises ArgumentError)
1874
+ # @param no_hyperliquidity [Boolean] Send `noHyperliquidity: true` (omitted when false)
1875
+ # @return [Hash] Exchange response
1876
+ def spot_deploy_genesis(token:, max_supply:, no_hyperliquidity: false)
1877
+ genesis = { token: Integer(token), maxSupply: wei_to_wire(max_supply) }
1878
+ genesis[:noHyperliquidity] = true if no_hyperliquidity
1879
+ spot_deploy_action(:genesis, genesis)
1880
+ end
1881
+
1882
+ # Register a spot pair (`spotDeploy.registerSpot`, L1).
1883
+ # On success `response.data` is the new spot (pair) index.
1884
+ # @param base_token [Integer] Base token index
1885
+ # @param quote_token [Integer] Quote token index
1886
+ # @return [Hash] Exchange response
1887
+ def spot_deploy_register_spot(base_token:, quote_token:)
1888
+ spot_deploy_action(:registerSpot, { tokens: [Integer(base_token), Integer(quote_token)] })
1889
+ end
1890
+
1891
+ # Seed Hyperliquidity for a spot pair (`spotDeploy.registerHyperliquidity`, L1).
1892
+ # @param spot [Integer] Spot (pair) index
1893
+ # @param start_px [Float, String] Starting price
1894
+ # @param order_sz [Float, String] Size of each order
1895
+ # @param n_orders [Integer] Number of orders
1896
+ # @param n_seeded_levels [Integer, nil] Number of seeded levels (omitted when nil)
1897
+ # @return [Hash] Exchange response
1898
+ def spot_deploy_register_hyperliquidity(spot:, start_px:, order_sz:, n_orders:, n_seeded_levels: nil)
1899
+ register = {
1900
+ spot: Integer(spot),
1901
+ startPx: float_to_wire(start_px),
1902
+ orderSz: float_to_wire(order_sz),
1903
+ nOrders: Integer(n_orders)
1904
+ }
1905
+ register[:nSeededLevels] = Integer(n_seeded_levels) unless n_seeded_levels.nil?
1906
+ spot_deploy_action(:registerHyperliquidity, register)
1907
+ end
1908
+
1909
+ # Set the deployer trading fee share (`spotDeploy.setDeployerTradingFeeShare`, L1).
1910
+ # @param token [Integer] Token index
1911
+ # @param share [String] Percent string, e.g. "0.012%" or "100%" (may only decrease)
1912
+ # @return [Hash] Exchange response
1913
+ def spot_deploy_set_deployer_trading_fee_share(token:, share:)
1914
+ spot_deploy_action(:setDeployerTradingFeeShare, { token: Integer(token), share: share })
1915
+ end
1916
+
1917
+ # Enable the freeze privilege (`spotDeploy.enableFreezePrivilege`, L1).
1918
+ # Must be sent before genesis (server: "Genesis error: genesis already happened").
1919
+ # @param token [Integer] Token index
1920
+ # @return [Hash] Exchange response
1921
+ def spot_deploy_enable_freeze_privilege(token:)
1922
+ spot_deploy_action(:enableFreezePrivilege, { token: Integer(token) })
1923
+ end
1924
+
1925
+ # Freeze or unfreeze a user's balance of the token (`spotDeploy.freezeUser`, L1).
1926
+ # @param token [Integer] Token index
1927
+ # @param user [String] User address (lowercased)
1928
+ # @param freeze [Boolean] true to freeze, false to unfreeze
1929
+ # @return [Hash] Exchange response
1930
+ def spot_deploy_freeze_user(token:, user:, freeze:)
1931
+ spot_deploy_action(:freezeUser, { token: Integer(token), user: user.downcase, freeze: freeze })
1932
+ end
1933
+
1934
+ # Permanently give up the freeze privilege (`spotDeploy.revokeFreezePrivilege`, L1).
1935
+ # @param token [Integer] Token index
1936
+ # @return [Hash] Exchange response
1937
+ def spot_deploy_revoke_freeze_privilege(token:)
1938
+ spot_deploy_action(:revokeFreezePrivilege, { token: Integer(token) })
1939
+ end
1940
+
1941
+ # Make the token a permissionless quote token (`spotDeploy.enableQuoteToken`, L1).
1942
+ # Irreversible per protocol docs; requires zero deployer fee share and quote-token staking.
1943
+ # @param token [Integer] Token index
1944
+ # @return [Hash] Exchange response
1945
+ def spot_deploy_enable_quote_token(token:)
1946
+ spot_deploy_action(:enableQuoteToken, { token: Integer(token) })
1947
+ end
1948
+
1949
+ # Disable the token as a quote token (`spotDeploy.disableQuoteToken`, L1).
1950
+ # @param token [Integer] Token index
1951
+ # @return [Hash] Exchange response
1952
+ def spot_deploy_disable_quote_token(token:)
1953
+ spot_deploy_action(:disableQuoteToken, { token: Integer(token) })
1954
+ end
1955
+
1956
+ # Request linking a Core spot token to an ERC-20 on HyperEVM (`spotDeploy.requestEvmContract`, L1);
1957
+ # finalize with #finalize_evm_contract.
1958
+ # @param token [Integer] Token index
1959
+ # @param address [String] ERC-20 contract address (lowercased)
1960
+ # @param evm_extra_wei_decimals [Integer] EVM wei decimals minus Core wei decimals, in [-2, 18]
1961
+ # @return [Hash] Exchange response
1962
+ def spot_deploy_request_evm_contract(token:, address:, evm_extra_wei_decimals:)
1963
+ request = {
1964
+ token: Integer(token),
1965
+ address: address.downcase,
1966
+ evmExtraWeiDecimals: Integer(evm_extra_wei_decimals)
1967
+ }
1968
+ spot_deploy_action(:requestEvmContract, request)
1969
+ end
1970
+
1971
+ # Set the token annotation (`spotDeploy.setTokenAnnotation`, L1). Changeable at most once per day.
1972
+ # @param token [Integer] Token index
1973
+ # @param category [String] Category
1974
+ # @param description [String] Description
1975
+ # @param keywords [Array<String>] Keywords
1976
+ # @param display_name [String, nil] Display name; nil is sent as JSON null
1977
+ # @return [Hash] Exchange response
1978
+ def spot_deploy_set_token_annotation(token:, category:, description:, keywords:, display_name: nil)
1979
+ annotation = { category: category, description: description, displayName: display_name, keywords: keywords }
1980
+ spot_deploy_action(:setTokenAnnotation, { token: Integer(token), annotation: annotation })
1981
+ end
1982
+
1983
+ # Set the deployer label (`spotDeploy.setDeployerLabel`, L1).
1984
+ # Settable once per deployer; 2-4 lowercase chars, unique across deployers and perp dexs.
1985
+ # @param label [String] Label
1986
+ # @return [Hash] Exchange response
1987
+ def spot_deploy_set_deployer_label(label:)
1988
+ spot_deploy_action(:setDeployerLabel, { label: label })
1989
+ end
1990
+
1991
+ # HIP-3* (testnet-only) star operations — perpDeploy action, star variant
1992
+
1993
+ # Add (true) or remove (false) a user on a HIP-3* venue's allow-list
1994
+ # (`perpDeploy` L1 action, star proxy modifyApproval). Testnet-only.
1995
+ # @param dex [String] HIP-3* perp dex name
1996
+ # @param user [String] Proxied user address (lowercased)
1997
+ # @param approved [Boolean] true adds the user, false removes them and clears their flags
1998
+ # @return [Hash] Exchange response
1999
+ def star_modify_approval(dex:, user:, approved:)
2000
+ star_proxy_action(dex, user, { modifyApproval: approved })
2001
+ end
2002
+
2003
+ # Allow or disallow a user to deposit to/withdraw from a HIP-3* venue's backstop liquidator
2004
+ # (`perpDeploy` L1 action, star proxy modifyBackstopLiquidatorApproval). Testnet-only.
2005
+ # @param dex [String] HIP-3* perp dex name
2006
+ # @param user [String] Proxied user address (lowercased)
2007
+ # @param allowed [Boolean] Allow the user to deposit to/withdraw from the venue's backstop liquidator
2008
+ # @return [Hash] Exchange response
2009
+ def star_modify_backstop_liquidator_approval(dex:, user:, allowed:)
2010
+ star_proxy_action(dex, user, { modifyBackstopLiquidatorApproval: allowed })
2011
+ end
2012
+
2013
+ # Set or clear reduce-only mode for an approved user on a HIP-3* venue
2014
+ # (`perpDeploy` L1 action, star proxy setReduceOnly). Testnet-only.
2015
+ # @param dex [String] HIP-3* perp dex name
2016
+ # @param user [String] Proxied user address (lowercased)
2017
+ # @param reduce_only [Boolean] Restrict an approved user to reducing positions on the venue
2018
+ # @return [Hash] Exchange response
2019
+ def star_set_reduce_only(dex:, user:, reduce_only:)
2020
+ star_proxy_action(dex, user, { setReduceOnly: reduce_only })
2021
+ end
2022
+
2023
+ # Cancel a user's resting orders on a HIP-3* venue
2024
+ # (`perpDeploy` L1 action, star proxy cancel). Testnet-only.
2025
+ # @param dex [String] HIP-3* perp dex name
2026
+ # @param user [String] Proxied user address (lowercased)
2027
+ # @param cancels [Array<Hash>] Array of { coin:, oid: } (the user's resting orders)
2028
+ # @return [Hash] Exchange response
2029
+ def star_cancel(dex:, user:, cancels:)
2030
+ cancel_wires = cancels.map { |c| { a: asset_index(c[:coin]), o: c[:oid] } }
2031
+ star_proxy_action(dex, user, { cancel: { cancels: cancel_wires } })
2032
+ end
2033
+
2034
+ # Cancel all of a user's orders and TWAPs on a HIP-3* venue, optionally per coin
2035
+ # (`perpDeploy` L1 action, star proxy cancelAll). Testnet-only.
2036
+ # @param dex [String] HIP-3* perp dex name
2037
+ # @param user [String] Proxied user address (lowercased)
2038
+ # @param coins [Array<String>, nil] Coins to cancel orders/TWAPs for (1-10); nil cancels all (sends null)
2039
+ # @return [Hash] Exchange response
2040
+ def star_cancel_all(dex:, user:, coins: nil)
2041
+ assets = coins&.map { |coin| asset_index(coin) }
2042
+ star_proxy_action(dex, user, { cancelAll: { assets: assets } })
2043
+ end
2044
+
2045
+ # Place orders on behalf of a user on a HIP-3* venue
2046
+ # (`perpDeploy` L1 action, star proxy order). Testnet-only.
2047
+ # @param dex [String] HIP-3* perp dex name
2048
+ # @param user [String] Proxied user address (lowercased)
2049
+ # @param orders [Array<Hash>] bulk_orders-shaped hashes (:coin, :is_buy, :size, :limit_px,
2050
+ # :order_type, :reduce_only, :cloid); :reduce_only defaults to true (server requires reduce-only)
2051
+ # @param grouping [String] Order grouping (default "na")
2052
+ # @return [Hash] Exchange response
2053
+ def star_order(dex:, user:, orders:, grouping: 'na')
2054
+ order_wires = orders.map do |o|
2055
+ build_order_wire(
2056
+ coin: o[:coin],
2057
+ is_buy: o[:is_buy],
2058
+ size: o[:size],
2059
+ limit_px: o[:limit_px],
2060
+ order_type: o[:order_type] || { limit: { tif: 'Gtc' } },
2061
+ reduce_only: o.fetch(:reduce_only, true),
2062
+ cloid: o[:cloid]
2063
+ )
2064
+ end
2065
+ star_proxy_action(dex, user, { order: { orders: order_wires, grouping: grouping } })
2066
+ end
2067
+
2068
+ # Send collateral from a user to another address on the same HIP-3* venue
2069
+ # (`perpDeploy` L1 action, star proxy sendAsset). Testnet-only.
2070
+ # @param dex [String] HIP-3* perp dex name
2071
+ # @param user [String] Proxied (sending) user address (lowercased)
2072
+ # @param destination [String] Recipient address on the same venue (lowercased)
2073
+ # @param amount [String, Numeric] Collateral amount; Strings sent verbatim, Numerics via float_to_wire
2074
+ # (perp_deploy_decimal)
2075
+ # @return [Hash] Exchange response
2076
+ def star_send_asset(dex:, user:, destination:, amount:)
2077
+ send_asset = { destination: destination.downcase, amount: perp_deploy_decimal(amount) }
2078
+ star_proxy_action(dex, user, { sendAsset: send_asset })
2079
+ end
2080
+
2081
+ # Set HIP-3* spot oracle prices (`perpDeploy` L1 action, star setOracle). Testnet-only.
2082
+ # @param dex [String] HIP-3* perp dex name
2083
+ # @param oracle_pxs [Hash{String=>String,Numeric}] Dex-prefixed coin => price; sorted by coin.
2084
+ # Strings are sent verbatim, Numerics normalized via float_to_wire (perp_deploy_decimal)
2085
+ # @return [Hash] Exchange response
2086
+ def star_set_oracle(dex:, oracle_pxs:)
2087
+ star_action(dex, { setOracle: { oraclePxs: sorted_coin_pairs(oracle_pxs) { |px| perp_deploy_decimal(px) } } })
2088
+ end
2089
+
1428
2090
  # Clear the asset metadata cache
1429
2091
  # Call this if metadata has been updated
1430
2092
  def reload_metadata!
@@ -1449,7 +2111,7 @@ module Hyperliquid
1449
2111
 
1450
2112
  # Get current timestamp in milliseconds
1451
2113
  def timestamp_ms
1452
- (Time.now.to_f * 1000).to_i
2114
+ Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
1453
2115
  end
1454
2116
 
1455
2117
  # Find a position for a coin
@@ -1527,9 +2189,17 @@ module Hyperliquid
1527
2189
  end
1528
2190
  end
1529
2191
 
2192
+ # spotMeta carries szDecimals on tokens[], not on universe[] pairs; a pair's
2193
+ # size decimals are its base token's (pair['tokens'][0], matched by token index).
1530
2194
  def load_spot_assets
1531
- @info.spot_meta['universe'].each_with_index do |pair, index|
1532
- cache_asset(pair['name'], index + SPOT_ASSET_THRESHOLD, pair['szDecimals'] || 0, is_spot: true)
2195
+ spot_meta = @info.spot_meta
2196
+ tokens_by_index = spot_meta['tokens'].to_h { |token| [token['index'], token] }
2197
+ spot_meta['universe'].each_with_index do |pair, index|
2198
+ base_index = pair['tokens'][0]
2199
+ base_token = tokens_by_index[base_index]
2200
+ raise Error, "spotMeta pair #{pair['name']} references unknown base token index #{base_index}" unless base_token
2201
+
2202
+ cache_asset(pair['name'], index + SPOT_ASSET_THRESHOLD, base_token['szDecimals'], is_spot: true)
1533
2203
  end
1534
2204
  end
1535
2205
 
@@ -1584,6 +2254,64 @@ module Hyperliquid
1584
2254
  normalized.sub(/(\.\d*?)0+\z/, '\1').sub(/\.\z/, '')
1585
2255
  end
1586
2256
 
2257
+ # Sign and post a HIP-4 `outcomeDeploy` L1 action. Key order type, venue, operation is load-bearing.
2258
+ def outcome_deploy_action(venue, operation)
2259
+ nonce = timestamp_ms
2260
+ action = { type: 'outcomeDeploy', venue: venue, operation: operation }
2261
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
2262
+ post_action(action, signature, nonce, nil)
2263
+ end
2264
+
2265
+ # Sign and post an `activateOutcomeDeployer` L1 action with the given enum variant.
2266
+ def outcome_deployer_activation(variant)
2267
+ nonce = timestamp_ms
2268
+ action = { type: 'activateOutcomeDeployer' }.merge(variant)
2269
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
2270
+ post_action(action, signature, nonce, nil)
2271
+ end
2272
+
2273
+ # Template instance { id, keywordToValue[, deployerFeeScale] } (fee scale only on
2274
+ # standalone/question instances, never on named-outcome instances).
2275
+ def outcome_template_instance(template_id, keyword_to_value, deployer_fee_scale = nil)
2276
+ instance = { id: template_id, keywordToValue: outcome_keyword_pairs(keyword_to_value) }
2277
+ instance[:deployerFeeScale] = outcome_decimal_wire(deployer_fee_scale) unless deployer_fee_scale.nil?
2278
+ instance
2279
+ end
2280
+
2281
+ # Hash or [[k, v], ...] -> [[String, String], ...] sorted by keyword (signed list must be sorted).
2282
+ def outcome_keyword_pairs(keyword_to_value)
2283
+ keyword_to_value.to_a.map { |keyword, value| [keyword.to_s, value.to_s] }.sort_by(&:first)
2284
+ end
2285
+
2286
+ # Decimal string for HIP-4 fee scales / settle fractions: Strings verbatim, Numerics via float_to_wire.
2287
+ def outcome_decimal_wire(value)
2288
+ value.is_a?(String) ? value : float_to_wire(value)
2289
+ end
2290
+
2291
+ # One settlement entry; key order outcome, settleFraction, details, nameAndDescription, sideNames.
2292
+ def outcome_settlement(outcome:, settle_fraction:, name:, description:, side_names:, details: '')
2293
+ {
2294
+ outcome: outcome.to_i,
2295
+ settleFraction: outcome_decimal_wire(settle_fraction),
2296
+ details: details,
2297
+ nameAndDescription: [name, description],
2298
+ sideNames: side_names.map(&:to_s)
2299
+ }
2300
+ end
2301
+
2302
+ # Convert an integer wei amount to its wire string. Integers are stringified exactly and
2303
+ # Strings pass through verbatim. Floats are rejected (Float#to_s gives "1.0e+16" / "100.0" forms).
2304
+ # @param value [Integer, String] Amount in wei
2305
+ # @return [String]
2306
+ # @raise [ArgumentError] For any other type
2307
+ def wei_to_wire(value)
2308
+ case value
2309
+ when Integer then value.to_s
2310
+ when String then value
2311
+ else raise ArgumentError, "wei amount must be an Integer or String, got #{value.inspect}"
2312
+ end
2313
+ end
2314
+
1587
2315
  # Calculate slippage price for market orders
1588
2316
  # Maintains parity with official Python SDK
1589
2317
  # 1. Apply slippage to mid price
@@ -1611,8 +2339,9 @@ module Hyperliquid
1611
2339
  base_decimals = is_spot ? 8 : 6
1612
2340
  decimal_places = [base_decimals - sz_decimals, 0].max
1613
2341
 
1614
- # Round to decimal places
1615
- rounded = sig_figs_price.round(decimal_places)
2342
+ # Round to decimal places like Python's round(): correctly rounded on the exact binary value, ties to
2343
+ # even (Float#round rounds ties half-up and misrounds e.g. 2.675 -> 2.68)
2344
+ rounded = sig_figs_price.to_r.round(decimal_places, half: :even).to_f
1616
2345
 
1617
2346
  # Format with fixed decimal places
1618
2347
  format("%.#{decimal_places}f", rounded)
@@ -1669,6 +2398,62 @@ module Hyperliquid
1669
2398
  { b: builder[:b].downcase, f: builder[:f] }
1670
2399
  end
1671
2400
 
2401
+ # Sign and post a `perpDeploy` L1 action: { type: 'perpDeploy', <variant_key> => payload }.
2402
+ def perp_deploy_action(variant_key, payload)
2403
+ nonce = timestamp_ms
2404
+ action = { type: 'perpDeploy', variant_key => payload }
2405
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
2406
+ post_action(action, signature, nonce, nil)
2407
+ end
2408
+
2409
+ def perp_deploy_asset_request(coin, sz_decimals, oracle_px, margin_table_id)
2410
+ { coin: coin, szDecimals: Integer(sz_decimals), oraclePx: perp_deploy_decimal(oracle_px),
2411
+ marginTableId: Integer(margin_table_id) }
2412
+ end
2413
+
2414
+ # Key order maxGas, assetRequest, dex, schema; nil maxGas/schema are sent as null.
2415
+ def perp_deploy_register_payload(asset_request, dex, max_gas, schema)
2416
+ { maxGas: max_gas.nil? ? nil : Integer(max_gas), assetRequest: asset_request, dex: dex,
2417
+ schema: perp_dex_schema_wire(schema) }
2418
+ end
2419
+
2420
+ def perp_dex_schema_wire(schema)
2421
+ return nil if schema.nil?
2422
+
2423
+ wire = {
2424
+ fullName: schema.fetch(:full_name),
2425
+ collateralToken: Integer(schema.fetch(:collateral_token)),
2426
+ oracleUpdater: schema[:oracle_updater]&.downcase
2427
+ }
2428
+ wire[:isStar] = schema[:is_star] if schema.key?(:is_star)
2429
+ wire
2430
+ end
2431
+
2432
+ # Hash (or [coin, value] pairs) -> [[coin, converted], ...] sorted by coin.
2433
+ def sorted_coin_pairs(pairs)
2434
+ pairs.to_h.map { |coin, value| [coin.to_s, yield(value)] }.sort_by(&:first)
2435
+ end
2436
+
2437
+ # String sent verbatim (Python parity); Numeric normalized via float_to_wire.
2438
+ def perp_deploy_decimal(value)
2439
+ case value
2440
+ when String then value
2441
+ when Numeric then float_to_wire(value)
2442
+ else raise ArgumentError, "decimal must be String or Numeric. Got: #{value.class}"
2443
+ end
2444
+ end
2445
+
2446
+ # HIP-3* star operation envelope: { type: 'perpDeploy', star: { dex:, operation: } }
2447
+ def star_action(dex, operation)
2448
+ perp_deploy_action(:star, { dex: dex, operation: operation })
2449
+ end
2450
+
2451
+ # HIP-3* proxied user operation: operation { proxy: [user, proxy_operation] }
2452
+ def star_proxy_action(dex, user, proxy_operation)
2453
+ star_action(dex, { proxy: [user.downcase, proxy_operation] })
2454
+ end
2455
+
2456
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1672
2457
  # Convert order type to wire format
1673
2458
  # @param order_type [Hash] Order type configuration
1674
2459
  # @return [Hash] Wire format order type
@@ -1696,6 +2481,43 @@ module Hyperliquid
1696
2481
  raise ArgumentError, 'order_type must specify :limit or :trigger'
1697
2482
  end
1698
2483
  end
2484
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
2485
+
2486
+ # Build, sign, and post a `CSignerAction` L1 action whose variant key carries null.
2487
+ # @param variant [Symbol] :jailSelf or :unjailSelf
2488
+ def c_signer_action(variant)
2489
+ nonce = timestamp_ms
2490
+ action = { type: 'CSignerAction', variant => nil }
2491
+ signature = @signer.sign_l1_action(
2492
+ action, nonce,
2493
+ expires_after: @expires_after
2494
+ )
2495
+ post_action(action, signature, nonce, nil)
2496
+ end
2497
+
2498
+ # Build, sign, and post a `CValidatorAction` L1 action. `type` is always the first key.
2499
+ # @param variant_body [Hash] Exactly one of { register: {...} }, { changeProfile: {...} }, { unregister: nil }
2500
+ def c_validator_action(variant_body)
2501
+ nonce = timestamp_ms
2502
+ action = { type: 'CValidatorAction' }.merge(variant_body)
2503
+ signature = @signer.sign_l1_action(
2504
+ action, nonce,
2505
+ expires_after: @expires_after
2506
+ )
2507
+ post_action(action, signature, nonce, nil)
2508
+ end
2509
+
2510
+ # Build, sign (L1), and post a `spotDeploy` action with a single variant key.
2511
+ # `type` must be inserted first: msgpack preserves Hash order and the server hashes it.
2512
+ # @param variant [Symbol] Variant key, e.g. :registerToken2
2513
+ # @param payload [Hash, Array] Variant payload
2514
+ # @return [Hash] Exchange response
2515
+ def spot_deploy_action(variant, payload)
2516
+ nonce = timestamp_ms
2517
+ action = { type: 'spotDeploy', variant => payload }
2518
+ signature = @signer.sign_l1_action(action, nonce, expires_after: @expires_after)
2519
+ post_action(action, signature, nonce, nil)
2520
+ end
1699
2521
 
1700
2522
  # Post an action to the exchange endpoint
1701
2523
  def post_action(action, signature, nonce, vault_address)