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.
@@ -0,0 +1,840 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Hyperliquid
7
+ class Exchange
8
+ DEFAULT_SLIPPAGE = 0.05
9
+
10
+ attr_reader :info, :signer
11
+ attr_accessor :expires_after
12
+
13
+ # @param private_key [String] hex private key
14
+ # @param base_url [String] API URL (defaults to mainnet)
15
+ # @param vault_address [String, nil] default vault address for all operations
16
+ # @param account_address [String, nil] address to use for queries (e.g. when trading as agent)
17
+ def initialize(private_key:, base_url: MAINNET_URL, vault_address: nil, account_address: nil, skip_ws: false)
18
+ @signer = Signer.new(private_key: private_key, base_url: base_url)
19
+ @info = Info.new(base_url: base_url, skip_ws: skip_ws)
20
+ @transport = Transport.new(base_url: base_url)
21
+ @vault_address = vault_address
22
+ @account_address = account_address
23
+ @expires_after = nil
24
+ end
25
+
26
+ def address
27
+ @signer.address
28
+ end
29
+
30
+ # Set expiration for subsequent actions (millisecond timestamp).
31
+ # Not supported on user-signed actions.
32
+ # @param expires_after [Integer, nil] timestamp in ms, or nil to clear
33
+ def set_expires_after(expires_after)
34
+ @expires_after = expires_after
35
+ end
36
+
37
+ # ---- Orders ----
38
+
39
+ # Place a single order.
40
+ # @param coin [String] e.g. "ETH"
41
+ # @param is_buy [Boolean]
42
+ # @param sz [Float] order size
43
+ # @param limit_px [Float] limit price
44
+ # @param order_type [Hash] e.g. { limit: { "tif" => "Gtc" } } or { trigger: { ... } }
45
+ # @param reduce_only [Boolean]
46
+ # @param cloid [Cloid, nil] client order ID
47
+ # @param builder [Hash, nil] e.g. { "b" => "0x...", "f" => 10 }
48
+ # @param vault_address [String, nil] override default vault
49
+ def order(coin, is_buy:, sz:, limit_px:, order_type:, reduce_only: false, cloid: nil, builder: nil,
50
+ vault_address: nil)
51
+ order_request = {
52
+ coin: coin, is_buy: is_buy, sz: sz, limit_px: limit_px,
53
+ order_type: order_type, reduce_only: reduce_only, cloid: cloid
54
+ }
55
+ bulk_orders([order_request], builder: builder, vault_address: vault_address)
56
+ end
57
+
58
+ # Place multiple orders at once.
59
+ # @param order_requests [Array<Hash>] array of order request hashes
60
+ # @param grouping [String] "na", "normalTpsl", or "positionTpsl"
61
+ # @param builder [Hash, nil]
62
+ # @param vault_address [String, nil]
63
+ def bulk_orders(order_requests, grouping: "na", builder: nil, vault_address: nil)
64
+ wires = order_requests.map do |req|
65
+ asset = @info.name_to_asset(req[:coin])
66
+ Utils.order_request_to_order_wire(req, asset)
67
+ end
68
+
69
+ action = { "type" => "order", "orders" => wires, "grouping" => grouping }
70
+ action["builder"] = { "b" => builder["b"].downcase, "f" => builder["f"] } if builder
71
+
72
+ vault = vault_address || @vault_address
73
+ post_action(action, vault_address: vault)
74
+ end
75
+
76
+ # Market open: buy/sell at market with slippage tolerance.
77
+ # @param coin [String]
78
+ # @param is_buy [Boolean]
79
+ # @param sz [Float] size
80
+ # @param px [Float, nil] reference price (default: mid price)
81
+ # @param slippage [Float] slippage as decimal (default 0.05 = 5%)
82
+ # @param cloid [Cloid, nil]
83
+ # @param builder [Hash, nil]
84
+ def market_open(coin, is_buy:, sz:, px: nil, slippage: DEFAULT_SLIPPAGE, cloid: nil, builder: nil)
85
+ px = slippage_price(coin, is_buy, slippage, px)
86
+ order(coin, is_buy: is_buy, sz: sz, limit_px: px,
87
+ order_type: { limit: { "tif" => "Ioc" } }, cloid: cloid, builder: builder)
88
+ end
89
+
90
+ # Market close: close entire position (or partial) at market.
91
+ # @param coin [String]
92
+ # @param sz [Float, nil] size to close (default: entire position)
93
+ # @param px [Float, nil] reference price (default: mid price)
94
+ # @param slippage [Float]
95
+ # @param cloid [Cloid, nil]
96
+ # @param builder [Hash, nil]
97
+ def market_close(coin, sz: nil, px: nil, slippage: DEFAULT_SLIPPAGE, cloid: nil, builder: nil)
98
+ addr = @account_address || @vault_address || address
99
+ state = @info.user_state(addr)
100
+ position = state["assetPositions"]&.find { |p| p["position"]["coin"] == coin }
101
+ raise Error, "No open position for #{coin}" unless position
102
+
103
+ szi = position["position"]["szi"].to_f
104
+ is_buy = szi < 0 # close short = buy, close long = sell
105
+ sz ||= szi.abs
106
+
107
+ px = slippage_price(coin, is_buy, slippage, px)
108
+ order(coin, is_buy: is_buy, sz: sz, limit_px: px,
109
+ order_type: { limit: { "tif" => "Ioc" } }, reduce_only: true, cloid: cloid, builder: builder)
110
+ end
111
+
112
+ # ---- Modify Orders ----
113
+
114
+ # Modify a single order.
115
+ # @param oid [Integer, Cloid] order ID or client order ID to modify
116
+ # @param coin [String]
117
+ # @param is_buy [Boolean]
118
+ # @param sz [Float]
119
+ # @param limit_px [Float]
120
+ # @param order_type [Hash]
121
+ # @param reduce_only [Boolean]
122
+ # @param cloid [Cloid, nil]
123
+ def modify_order(oid, coin:, is_buy:, sz:, limit_px:, order_type:, reduce_only: false, cloid: nil)
124
+ order_request = {
125
+ coin: coin, is_buy: is_buy, sz: sz, limit_px: limit_px,
126
+ order_type: order_type, reduce_only: reduce_only, cloid: cloid
127
+ }
128
+ bulk_modify_orders([{ oid: oid, order: order_request }])
129
+ end
130
+
131
+ # Modify multiple orders at once.
132
+ # @param modifications [Array<Hash>] each with :oid and :order keys
133
+ def bulk_modify_orders(modifications)
134
+ wires = modifications.map do |mod|
135
+ asset = @info.name_to_asset(mod[:order][:coin])
136
+ oid_val = mod[:oid].is_a?(Cloid) ? mod[:oid].to_raw : mod[:oid]
137
+ {
138
+ "oid" => oid_val,
139
+ "order" => Utils.order_request_to_order_wire(mod[:order], asset)
140
+ }
141
+ end
142
+
143
+ action = { "type" => "batchModify", "modifies" => wires }
144
+ post_action(action)
145
+ end
146
+
147
+ # ---- Cancel Orders ----
148
+
149
+ # Cancel a single order by order ID.
150
+ # @param coin [String]
151
+ # @param oid [Integer]
152
+ def cancel(coin, oid)
153
+ bulk_cancel([{ coin: coin, oid: oid }])
154
+ end
155
+
156
+ # Cancel a single order by client order ID.
157
+ # @param coin [String]
158
+ # @param cloid [Cloid]
159
+ def cancel_by_cloid(coin, cloid)
160
+ bulk_cancel_by_cloid([{ coin: coin, cloid: cloid }])
161
+ end
162
+
163
+ # Cancel multiple orders by order ID.
164
+ # @param cancels [Array<Hash>] each with :coin and :oid keys
165
+ def bulk_cancel(cancels)
166
+ cancel_wires = cancels.map do |c|
167
+ asset = @info.name_to_asset(c[:coin])
168
+ { "a" => asset, "o" => c[:oid] }
169
+ end
170
+
171
+ action = { "type" => "cancel", "cancels" => cancel_wires }
172
+ post_action(action)
173
+ end
174
+
175
+ # Cancel multiple orders by client order ID.
176
+ # @param cancels [Array<Hash>] each with :coin and :cloid keys
177
+ def bulk_cancel_by_cloid(cancels)
178
+ cancel_wires = cancels.map do |c|
179
+ asset = @info.name_to_asset(c[:coin])
180
+ { "asset" => asset, "cloid" => c[:cloid].to_s }
181
+ end
182
+
183
+ action = { "type" => "cancelByCloid", "cancels" => cancel_wires }
184
+ post_action(action)
185
+ end
186
+
187
+ # Schedule cancel-all after a delay.
188
+ # @param time [Integer, nil] timestamp in ms when to cancel. nil to clear.
189
+ def schedule_cancel(time: nil)
190
+ action = { "type" => "scheduleCancel" }
191
+ action["time"] = time if time
192
+ post_action(action)
193
+ end
194
+
195
+ # ---- TWAP ----
196
+
197
+ # Place a TWAP order.
198
+ # @param coin [String]
199
+ # @param is_buy [Boolean]
200
+ # @param sz [Float]
201
+ # @param reduce_only [Boolean]
202
+ # @param minutes [Integer] duration
203
+ # @param randomize [Boolean] randomize execution
204
+ def twap_order(coin, is_buy:, sz:, minutes:, reduce_only: false, randomize: true)
205
+ asset = @info.name_to_asset(coin)
206
+ action = {
207
+ "type" => "twapOrder",
208
+ "twap" => {
209
+ "a" => asset,
210
+ "b" => is_buy,
211
+ "s" => Utils.float_to_wire(sz),
212
+ "r" => reduce_only,
213
+ "m" => minutes,
214
+ "t" => randomize
215
+ }
216
+ }
217
+ post_action(action)
218
+ end
219
+
220
+ # Cancel a TWAP order.
221
+ # @param coin [String]
222
+ # @param twap_id [Integer]
223
+ def twap_cancel(coin, twap_id:)
224
+ asset = @info.name_to_asset(coin)
225
+ action = { "type" => "twapCancel", "a" => asset, "t" => twap_id }
226
+ post_action(action)
227
+ end
228
+
229
+ # ---- Account ----
230
+
231
+ # Update leverage for a coin.
232
+ # @param coin [String]
233
+ # @param leverage [Integer]
234
+ # @param is_cross [Boolean]
235
+ def update_leverage(coin, leverage:, is_cross: true)
236
+ asset = @info.name_to_asset(coin)
237
+ action = {
238
+ "type" => "updateLeverage",
239
+ "asset" => asset,
240
+ "isCross" => is_cross,
241
+ "leverage" => leverage
242
+ }
243
+ post_action(action)
244
+ end
245
+
246
+ # Update isolated margin for a coin.
247
+ # @param coin [String]
248
+ # @param is_buy [Boolean]
249
+ # @param amount [Float] USD amount (positive = add, negative = remove)
250
+ def update_isolated_margin(coin, is_buy:, amount:)
251
+ asset = @info.name_to_asset(coin)
252
+ action = {
253
+ "type" => "updateIsolatedMargin",
254
+ "asset" => asset,
255
+ "isBuy" => is_buy,
256
+ "ntli" => Utils.float_to_usd_int(amount)
257
+ }
258
+ post_action(action)
259
+ end
260
+
261
+ # Set referrer code.
262
+ # @param code [String] referral code
263
+ def set_referrer(code)
264
+ action = { "type" => "setReferrer", "code" => code }
265
+ post_action(action, vault_address: nil)
266
+ end
267
+
268
+ # ---- Transfers (user-signed actions) ----
269
+
270
+ # Transfer USD to another address.
271
+ # @param destination [String] recipient address
272
+ # @param amount [Float] USD amount
273
+ def usd_transfer(destination, amount:)
274
+ action = {
275
+ "type" => "usdSend",
276
+ "destination" => destination,
277
+ "amount" => amount.to_s,
278
+ "time" => timestamp_ms
279
+ }
280
+ post_user_signed_action(action, primary_type: "UsdSend")
281
+ end
282
+
283
+ # Transfer spot tokens to another address.
284
+ # @param destination [String] recipient address
285
+ # @param token [String] token identifier
286
+ # @param amount [Float]
287
+ def spot_transfer(destination, token:, amount:)
288
+ action = {
289
+ "type" => "spotSend",
290
+ "destination" => destination,
291
+ "token" => token,
292
+ "amount" => amount.to_s,
293
+ "time" => timestamp_ms
294
+ }
295
+ post_user_signed_action(action, primary_type: "SpotSend")
296
+ end
297
+
298
+ # Withdraw USDC from bridge to L1.
299
+ # @param destination [String] recipient address
300
+ # @param amount [Float] USDC amount
301
+ def withdraw_from_bridge(destination, amount:)
302
+ action = {
303
+ "type" => "withdraw3",
304
+ "destination" => destination,
305
+ "amount" => amount.to_s,
306
+ "time" => timestamp_ms
307
+ }
308
+ post_user_signed_action(action, primary_type: "Withdraw")
309
+ end
310
+
311
+ # Transfer between perp and spot.
312
+ # @param amount [Float] USD amount
313
+ # @param to_perp [Boolean] true = spot->perp, false = perp->spot
314
+ def usd_class_transfer(amount:, to_perp:)
315
+ ts = timestamp_ms
316
+ str_amount = amount.to_s
317
+ str_amount += " subaccount:#{@vault_address}" if @vault_address
318
+
319
+ action = {
320
+ "type" => "usdClassTransfer",
321
+ "amount" => str_amount,
322
+ "toPerp" => to_perp,
323
+ "nonce" => ts
324
+ }
325
+ post_user_signed_action(action, primary_type: "UsdClassTransfer")
326
+ end
327
+
328
+ # Send asset between dexes.
329
+ # @param destination [String] recipient address
330
+ # @param source_dex [String] source dex identifier
331
+ # @param destination_dex [String] destination dex identifier
332
+ # @param token [String] token identifier
333
+ # @param amount [Float] amount to send
334
+ def send_asset(destination, source_dex:, destination_dex:, token:, amount:)
335
+ ts = timestamp_ms
336
+ action = {
337
+ "type" => "sendAsset",
338
+ "destination" => destination,
339
+ "sourceDex" => source_dex,
340
+ "destinationDex" => destination_dex,
341
+ "token" => token,
342
+ "amount" => amount.to_s,
343
+ "fromSubAccount" => @vault_address || "",
344
+ "nonce" => ts
345
+ }
346
+ post_user_signed_action(action, primary_type: "SendAsset")
347
+ end
348
+
349
+ # ---- Agent / Builder ----
350
+
351
+ # Approve an agent to trade on your behalf.
352
+ # Generates a new agent key and returns [response, agent_private_key].
353
+ # @param name [String, nil] agent name
354
+ # @return [Array] [response, agent_private_key_hex]
355
+ def approve_agent(name: nil)
356
+ agent_key = "0x#{SecureRandom.hex(32)}"
357
+ agent_account = Eth::Key.new(priv: agent_key.delete_prefix("0x"))
358
+ ts = timestamp_ms
359
+
360
+ action = {
361
+ "type" => "approveAgent",
362
+ "agentAddress" => agent_account.address.to_s,
363
+ "agentName" => name || "",
364
+ "nonce" => ts
365
+ }
366
+ sig = @signer.sign_user_signed_action(
367
+ action,
368
+ primary_type: "ApproveAgent",
369
+ payload_types: USER_SIGNED_TYPES["ApproveAgent"]
370
+ )
371
+
372
+ # Remove agentName from action if no name provided (matches Python SDK behavior)
373
+ action.delete("agentName") if name.nil?
374
+
375
+ payload = { action: action, nonce: ts, signature: sig }
376
+ result = @transport.post_exchange(payload)
377
+
378
+ [result, agent_key]
379
+ end
380
+
381
+ # Approve a builder fee.
382
+ # @param builder [String] builder address
383
+ # @param max_fee_rate [String] max fee rate
384
+ def approve_builder_fee(builder:, max_fee_rate:)
385
+ action = {
386
+ "type" => "approveBuilderFee",
387
+ "maxFeeRate" => max_fee_rate,
388
+ "builder" => builder,
389
+ "nonce" => timestamp_ms
390
+ }
391
+ post_user_signed_action(action, primary_type: "ApproveBuilderFee")
392
+ end
393
+
394
+ # ---- Sub-accounts ----
395
+
396
+ # Create a sub-account.
397
+ # @param name [String] sub-account name
398
+ def create_sub_account(name:)
399
+ action = { "type" => "createSubAccount", "name" => name }
400
+ post_action(action, vault_address: nil)
401
+ end
402
+
403
+ # Transfer USD to/from a sub-account.
404
+ # @param sub_account_user [String] sub-account address
405
+ # @param is_deposit [Boolean] true = deposit into sub, false = withdraw
406
+ # @param usd [Integer] USD amount (raw integer)
407
+ def sub_account_transfer(sub_account_user:, is_deposit:, usd:)
408
+ action = {
409
+ "type" => "subAccountTransfer",
410
+ "subAccountUser" => sub_account_user,
411
+ "isDeposit" => is_deposit,
412
+ "usd" => usd
413
+ }
414
+ post_action(action, vault_address: nil)
415
+ end
416
+
417
+ # Transfer spot tokens to/from a sub-account.
418
+ # @param sub_account_user [String]
419
+ # @param is_deposit [Boolean]
420
+ # @param token [String]
421
+ # @param amount [Float]
422
+ def sub_account_spot_transfer(sub_account_user:, is_deposit:, token:, amount:)
423
+ action = {
424
+ "type" => "subAccountSpotTransfer",
425
+ "subAccountUser" => sub_account_user,
426
+ "isDeposit" => is_deposit,
427
+ "token" => token,
428
+ "amount" => amount.to_s
429
+ }
430
+ post_action(action, vault_address: nil)
431
+ end
432
+
433
+ # ---- Vault ----
434
+
435
+ # Transfer USD to/from a vault.
436
+ # @param vault_address [String] vault address
437
+ # @param is_deposit [Boolean]
438
+ # @param usd [Integer] amount
439
+ def vault_usd_transfer(vault_address:, is_deposit:, usd:)
440
+ action = {
441
+ "type" => "vaultTransfer",
442
+ "vaultAddress" => vault_address,
443
+ "isDeposit" => is_deposit,
444
+ "usd" => usd
445
+ }
446
+ post_action(action, vault_address: nil)
447
+ end
448
+
449
+ # ---- Staking ----
450
+
451
+ # Delegate or undelegate tokens.
452
+ # @param validator [String] validator address
453
+ # @param wei [Integer] amount in wei
454
+ # @param is_undelegate [Boolean]
455
+ def token_delegate(validator:, wei:, is_undelegate: false)
456
+ action = {
457
+ "type" => "tokenDelegate",
458
+ "validator" => validator,
459
+ "wei" => wei,
460
+ "isUndelegate" => is_undelegate,
461
+ "nonce" => timestamp_ms
462
+ }
463
+ post_user_signed_action(action, primary_type: "TokenDelegate")
464
+ end
465
+
466
+ # ---- Multi-sig ----
467
+
468
+ # Convert account to multi-sig.
469
+ # @param authorized_users [Array<String>] list of authorized signer addresses
470
+ # @param threshold [Integer] number of signatures required
471
+ def convert_to_multi_sig_user(authorized_users:, threshold:)
472
+ sorted_users = authorized_users.sort
473
+ signers = { "authorizedUsers" => sorted_users, "threshold" => threshold }
474
+ action = {
475
+ "type" => "convertToMultiSigUser",
476
+ "signers" => JSON.generate(signers),
477
+ "nonce" => timestamp_ms
478
+ }
479
+ post_user_signed_action(action, primary_type: "ConvertToMultiSigUser")
480
+ end
481
+
482
+ # Execute a multi-sig action.
483
+ # @param multi_sig_user [String] multi-sig user address
484
+ # @param inner_action [Hash] the action to execute
485
+ # @param signatures [Array] existing signatures
486
+ # @param nonce [Integer] nonce
487
+ # @param vault_address [String, nil]
488
+ def multi_sig(multi_sig_user, inner_action, signatures, nonce, vault_address: nil)
489
+ multi_sig_action = {
490
+ "type" => "multiSig",
491
+ "signatureChainId" => "0x66eee",
492
+ "signatures" => signatures,
493
+ "payload" => {
494
+ "multiSigUser" => multi_sig_user.downcase,
495
+ "outerSigner" => @signer.address.downcase,
496
+ "action" => inner_action
497
+ }
498
+ }
499
+
500
+ sig = @signer.sign_multi_sig_action(multi_sig_action, vault_address: vault_address, nonce: nonce,
501
+ expires_after: @expires_after)
502
+
503
+ payload = {
504
+ action: multi_sig_action,
505
+ nonce: nonce,
506
+ signature: sig,
507
+ vaultAddress: vault_address,
508
+ expiresAfter: @expires_after
509
+ }
510
+ @transport.post_exchange(payload)
511
+ end
512
+
513
+ # ---- Spot Deploy ----
514
+
515
+ # Register a new spot token.
516
+ def spot_deploy_register_token(token_name:, sz_decimals:, wei_decimals:, max_gas:, full_name:)
517
+ action = {
518
+ "type" => "spotDeploy",
519
+ "registerToken2" => {
520
+ "spec" => { "name" => token_name, "szDecimals" => sz_decimals, "weiDecimals" => wei_decimals },
521
+ "maxGas" => max_gas,
522
+ "fullName" => full_name
523
+ }
524
+ }
525
+ post_action(action, vault_address: nil)
526
+ end
527
+
528
+ # User genesis for a spot token.
529
+ def spot_deploy_user_genesis(token:, user_and_wei:, existing_token_and_wei:)
530
+ action = {
531
+ "type" => "spotDeploy",
532
+ "userGenesis" => {
533
+ "token" => token,
534
+ "userAndWei" => user_and_wei.map { |user, wei| [user.downcase, wei] },
535
+ "existingTokenAndWei" => existing_token_and_wei
536
+ }
537
+ }
538
+ post_action(action, vault_address: nil)
539
+ end
540
+
541
+ # Enable freeze privilege for a spot token.
542
+ def spot_deploy_enable_freeze_privilege(token)
543
+ spot_deploy_token_action("enableFreezePrivilege", token)
544
+ end
545
+
546
+ # Freeze/unfreeze a user for a spot token.
547
+ def spot_deploy_freeze_user(token, user:, freeze:)
548
+ action = {
549
+ "type" => "spotDeploy",
550
+ "freezeUser" => { "token" => token, "user" => user.downcase, "freeze" => freeze }
551
+ }
552
+ post_action(action, vault_address: nil)
553
+ end
554
+
555
+ # Revoke freeze privilege for a spot token.
556
+ def spot_deploy_revoke_freeze_privilege(token)
557
+ spot_deploy_token_action("revokeFreezePrivilege", token)
558
+ end
559
+
560
+ # Enable quote token for a spot token.
561
+ def spot_deploy_enable_quote_token(token)
562
+ spot_deploy_token_action("enableQuoteToken", token)
563
+ end
564
+
565
+ # Run genesis for a spot token.
566
+ def spot_deploy_genesis(token, max_supply:, no_hyperliquidity: false)
567
+ genesis = { "token" => token, "maxSupply" => max_supply }
568
+ genesis["noHyperliquidity"] = true if no_hyperliquidity
569
+ action = { "type" => "spotDeploy", "genesis" => genesis }
570
+ post_action(action, vault_address: nil)
571
+ end
572
+
573
+ # Register a spot trading pair.
574
+ def spot_deploy_register_spot(base_token:, quote_token:)
575
+ action = {
576
+ "type" => "spotDeploy",
577
+ "registerSpot" => { "tokens" => [base_token, quote_token] }
578
+ }
579
+ post_action(action, vault_address: nil)
580
+ end
581
+
582
+ # Register hyperliquidity for a spot pair.
583
+ def spot_deploy_register_hyperliquidity(spot, start_px:, order_sz:, n_orders:, n_seeded_levels: nil)
584
+ register = {
585
+ "spot" => spot,
586
+ "startPx" => start_px.to_s,
587
+ "orderSz" => order_sz.to_s,
588
+ "nOrders" => n_orders
589
+ }
590
+ register["nSeededLevels"] = n_seeded_levels if n_seeded_levels
591
+ action = { "type" => "spotDeploy", "registerHyperliquidity" => register }
592
+ post_action(action, vault_address: nil)
593
+ end
594
+
595
+ # Set deployer trading fee share for a spot token.
596
+ def spot_deploy_set_deployer_trading_fee_share(token, share:)
597
+ action = {
598
+ "type" => "spotDeploy",
599
+ "setDeployerTradingFeeShare" => { "token" => token, "share" => share }
600
+ }
601
+ post_action(action, vault_address: nil)
602
+ end
603
+
604
+ # ---- Perp Deploy ----
605
+
606
+ # Register a new perp asset.
607
+ def perp_deploy_register_asset(dex:, coin:, sz_decimals:, oracle_px:, margin_table_id:,
608
+ only_isolated:, max_gas: nil, schema: nil)
609
+ schema_wire = nil
610
+ if schema
611
+ schema_wire = {
612
+ "fullName" => schema[:full_name],
613
+ "collateralToken" => schema[:collateral_token],
614
+ "oracleUpdater" => schema[:oracle_updater]&.downcase
615
+ }
616
+ end
617
+ action = {
618
+ "type" => "perpDeploy",
619
+ "registerAsset" => {
620
+ "maxGas" => max_gas,
621
+ "assetRequest" => {
622
+ "coin" => coin,
623
+ "szDecimals" => sz_decimals,
624
+ "oraclePx" => oracle_px,
625
+ "marginTableId" => margin_table_id,
626
+ "onlyIsolated" => only_isolated
627
+ },
628
+ "dex" => dex,
629
+ "schema" => schema_wire
630
+ }
631
+ }
632
+ post_action(action, vault_address: nil)
633
+ end
634
+
635
+ # Set oracle prices for a perp dex.
636
+ def perp_deploy_set_oracle(dex:, oracle_pxs:, all_mark_pxs:, external_perp_pxs:)
637
+ action = {
638
+ "type" => "perpDeploy",
639
+ "setOracle" => {
640
+ "dex" => dex,
641
+ "oraclePxs" => oracle_pxs.sort.to_a,
642
+ "markPxs" => all_mark_pxs.map { |m| m.sort.to_a },
643
+ "externalPerpPxs" => external_perp_pxs.sort.to_a
644
+ }
645
+ }
646
+ post_action(action, vault_address: nil)
647
+ end
648
+
649
+ # ---- C-Signer ----
650
+
651
+ # Unjail self as c-signer.
652
+ def c_signer_unjail_self
653
+ c_signer_action("unjailSelf")
654
+ end
655
+
656
+ # Jail self as c-signer.
657
+ def c_signer_jail_self
658
+ c_signer_action("jailSelf")
659
+ end
660
+
661
+ # ---- C-Validator ----
662
+
663
+ # Register as a validator.
664
+ def c_validator_register(node_ip:, name:, description:, delegations_disabled:, commission_bps:,
665
+ signer:, unjailed:, initial_wei:)
666
+ action = {
667
+ "type" => "CValidatorAction",
668
+ "register" => {
669
+ "profile" => {
670
+ "node_ip" => { "Ip" => node_ip },
671
+ "name" => name,
672
+ "description" => description,
673
+ "delegations_disabled" => delegations_disabled,
674
+ "commission_bps" => commission_bps,
675
+ "signer" => signer
676
+ },
677
+ "unjailed" => unjailed,
678
+ "initial_wei" => initial_wei
679
+ }
680
+ }
681
+ post_action(action, vault_address: nil)
682
+ end
683
+
684
+ # Change validator profile.
685
+ def c_validator_change_profile(unjailed:, node_ip: nil, name: nil, description: nil,
686
+ disable_delegations: nil, commission_bps: nil, signer: nil)
687
+ action = {
688
+ "type" => "CValidatorAction",
689
+ "changeProfile" => {
690
+ "node_ip" => node_ip ? { "Ip" => node_ip } : nil,
691
+ "name" => name,
692
+ "description" => description,
693
+ "unjailed" => unjailed,
694
+ "disable_delegations" => disable_delegations,
695
+ "commission_bps" => commission_bps,
696
+ "signer" => signer
697
+ }
698
+ }
699
+ post_action(action, vault_address: nil)
700
+ end
701
+
702
+ # Unregister as a validator.
703
+ def c_validator_unregister
704
+ action = { "type" => "CValidatorAction", "unregister" => nil }
705
+ post_action(action, vault_address: nil)
706
+ end
707
+
708
+ # ---- EVM / Blocks ----
709
+
710
+ # Enable or disable big blocks.
711
+ # @param enable [Boolean]
712
+ def use_big_blocks(enable)
713
+ action = { "type" => "evmUserModify", "usingBigBlocks" => enable }
714
+ post_action(action, vault_address: nil)
715
+ end
716
+
717
+ # ---- Dex Abstraction ----
718
+
719
+ # Enable dex abstraction for an agent.
720
+ def agent_enable_dex_abstraction
721
+ action = { "type" => "agentEnableDexAbstraction" }
722
+ post_action(action)
723
+ end
724
+
725
+ # Set abstraction level for an agent.
726
+ # @param abstraction [String] "u", "p", or "i"
727
+ def agent_set_abstraction(abstraction)
728
+ action = { "type" => "agentSetAbstraction", "abstraction" => abstraction }
729
+ post_action(action)
730
+ end
731
+
732
+ # Set dex abstraction for a user (user-signed action).
733
+ # @param user [String] user address
734
+ # @param enabled [Boolean]
735
+ def user_dex_abstraction(user, enabled:)
736
+ action = {
737
+ "type" => "userDexAbstraction",
738
+ "user" => user.downcase,
739
+ "enabled" => enabled,
740
+ "nonce" => timestamp_ms
741
+ }
742
+ post_user_signed_action(action, primary_type: "UserDexAbstraction")
743
+ end
744
+
745
+ # Set user abstraction level (user-signed action).
746
+ # @param user [String] user address
747
+ # @param abstraction [String] "unifiedAccount", "portfolioMargin", or "disabled"
748
+ def user_set_abstraction(user, abstraction:)
749
+ action = {
750
+ "type" => "userSetAbstraction",
751
+ "user" => user.downcase,
752
+ "abstraction" => abstraction,
753
+ "nonce" => timestamp_ms
754
+ }
755
+ post_user_signed_action(action, primary_type: "UserSetAbstraction")
756
+ end
757
+
758
+ # ---- Noop ----
759
+
760
+ # Send a no-op action (useful for testing signing).
761
+ # @param nonce [Integer]
762
+ def noop(nonce)
763
+ action = { "type" => "noop" }
764
+ post_action(action, nonce: nonce)
765
+ end
766
+
767
+ private
768
+
769
+ def post_action(action, vault_address: :default, nonce: nil)
770
+ vault = vault_address == :default ? @vault_address : vault_address
771
+ # usdClassTransfer and sendAsset don't include vaultAddress
772
+ vault = nil if %w[usdClassTransfer sendAsset].include?(action["type"])
773
+
774
+ nonce ||= timestamp_ms
775
+ sig = @signer.sign_l1_action(action, nonce: nonce, vault_address: vault, expires_after: @expires_after)
776
+
777
+ payload = { action: action, nonce: nonce, signature: sig }
778
+ payload[:vaultAddress] = vault if vault
779
+ payload[:expiresAfter] = @expires_after if @expires_after
780
+
781
+ @transport.post_exchange(payload)
782
+ end
783
+
784
+ def post_user_signed_action(action, primary_type:)
785
+ payload_types = USER_SIGNED_TYPES[primary_type]
786
+ raise Error, "Unknown user-signed action type: #{primary_type}" unless payload_types
787
+
788
+ sig = @signer.sign_user_signed_action(
789
+ action,
790
+ primary_type: primary_type,
791
+ payload_types: payload_types
792
+ )
793
+
794
+ payload = { action: action, nonce: timestamp_ms, signature: sig }
795
+ @transport.post_exchange(payload)
796
+ end
797
+
798
+ def slippage_price(coin, is_buy, slippage, px = nil)
799
+ unless px
800
+ full_coin = @info.name_to_coin(coin)
801
+ dex = full_coin.include?(":") ? full_coin.split(":")[0] : ""
802
+ mids = @info.all_mids(dex: dex)
803
+ mid = mids[full_coin]
804
+ raise Error, "No mid price for #{coin}" unless mid
805
+
806
+ px = mid.to_f
807
+ end
808
+
809
+ asset = @info.name_to_asset(coin)
810
+ is_spot = asset >= 10_000
811
+
812
+ px = if is_buy
813
+ px * (1 + slippage)
814
+ else
815
+ [px * (1 - slippage), 0].max
816
+ end
817
+
818
+ # Round to 5 significant figures, with appropriate decimal places
819
+ sz_decimals = @info.asset_to_sz_decimals(asset) || 0
820
+ max_decimals = is_spot ? 8 : 6
821
+ decimal_places = [max_decimals - sz_decimals, 0].max
822
+ rounded = format("%.5g", px).to_f
823
+ rounded.round(decimal_places)
824
+ end
825
+
826
+ def spot_deploy_token_action(variant, token)
827
+ action = { "type" => "spotDeploy", variant => { "token" => token } }
828
+ post_action(action, vault_address: nil)
829
+ end
830
+
831
+ def c_signer_action(variant)
832
+ action = { "type" => "CSignerAction", variant => nil }
833
+ post_action(action, vault_address: nil)
834
+ end
835
+
836
+ def timestamp_ms
837
+ (Time.now.to_f * 1000).to_i
838
+ end
839
+ end
840
+ end