hyperliquid 1.5.0 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bf3db383f0da2f70b1d7e98e8901f29fbb4cde940a18859c57c98592c4ffbf3
4
- data.tar.gz: 0055af5f445d241193e27189a585423e3410d296a7df96ba447827299a9b9cd4
3
+ metadata.gz: fa1051cb2277e69795819ed2ae8564e89ac3e6b34139d9112a8273f0604d2fa1
4
+ data.tar.gz: 072416d6751fa766f0b4eeb5c416365ea96652b980301932d3ab32a549750b24
5
5
  SHA512:
6
- metadata.gz: 32986974696870940a6ca517f963be23faf84c3dfc7adbfe14f0bcfb5d919118c683ab7f3faa9fd3cfa3249c95c92d89f240cc15917e3e18092331e6141a59de
7
- data.tar.gz: 9a2d495448ba88243b14bfae7f0fe81ddebab3dbc743dce21742b58955196ff9cdd7a6e6a45cea2d958e00cd023748250c923d203258e32d346390638dd4e9de
6
+ metadata.gz: 41059a1ef505d93e061fdd575f42ca340c6eb1e8100f14adf176c9512bd65ce4c1afdd1ddeeb831a1833bce63b9ca065f602681c673bba9b96f7a298130b05a0
7
+ data.tar.gz: 9cfb58786186bafc873dc9dd1747c2b87a645770fddc8d759ea3b5420e7367f1118de056de8e470dbe8bff06e44e55033ce063d839a3c81803fc418e3be68890
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  ## [Ruby Hyperliquid SDK Changelog]
2
2
 
3
+ ## [1.7.0] - 2026-06-11
4
+
5
+ ### New Exchange actions
6
+
7
+ - `Exchange#authorize_aqav2_role(token:, role:)` — L1 action authorizing an AQAv2 role (e.g. `"treasury"`); supports `expires_after`.
8
+ - `Exchange#staking_link_disable_trading_user(trading_user:)` — user-signed action linking a staking account to a trading user (irreversible). Adds `STAKING_LINK_DISABLE_TRADING_USER_TYPES`.
9
+ - `Exchange#finalize_evm_contract(input:)` — L1 action linking a HyperCore spot token to a HyperEVM ERC-20 contract. Accepts a `Hash` (`{create: {nonce:}}`) or string variant (`"firstStorageSlot"` / `"customStorageSlot"`).
10
+ - HIP-4 `userOutcome` variants (L1 actions, not user-signed): `split_outcome(question:, outcome:, amount:)`, `merge_outcome(question:, amount:)`, `merge_question(question:, amount:)`, `negate_outcome(question:, outcome:, amount:)`. `merge_outcome` and `merge_question` accept `amount: nil` for the max-available case.
11
+
12
+ ### New Info methods
13
+
14
+ - `Info#settled_outcome(outcome:)` — returns settled prediction-market outcome data.
15
+
16
+ ### Fixes
17
+
18
+ - `Signing::MultiSig.payload_action` now normalizes `userSetAbstraction` long-form abstraction values (`"disabled"`, `"unifiedAccount"`, `"portfolioMargin"`) to their wire enum equivalents (`"i"`, `"u"`, `"p"`) in the L1 payload posted to `/exchange`, matching Python SDK 0.24.0. Pre-translated short codes pass through unchanged.
19
+
20
+ ## [1.6.0] - 2026-05-26
21
+
22
+ ### New Exchange actions
23
+
24
+ - `Exchange#twap_order(asset:, is_buy:, sz:, reduce_only:, minutes:, randomize:, ...)` — L1 action that places a TWAP order with the nested `twap` dict shape (`a`/`b`/`s`/`r`/`m`/`t`). Size via `float_to_wire`; supports `vault_address`.
25
+ - `Exchange#twap_cancel(asset:, twap_id:, ...)` — L1 action that cancels an active TWAP by asset index + twap id; supports `vault_address`.
26
+ - `Exchange#reserve_request_weight(weight:, ...)` — L1 action with a weight-only body; supports `expires_after`, no `vault_address` per TS schema.
27
+ - `Exchange#c_deposit(wei:)` — user-signed action depositing native HYPE to staking. Adds `C_DEPOSIT_TYPES`.
28
+ - `Exchange#c_withdraw(wei:)` — user-signed action withdrawing native HYPE from staking. Adds `C_WITHDRAW_TYPES`.
29
+ - `Exchange#user_portfolio_margin(user:, enabled:)` — user-signed action toggling cross-portfolio-margin mode. Adds `USER_PORTFOLIO_MARGIN_TYPES`.
30
+ - `Exchange#spot_user(opt_out:)` — L1 action toggling spot-dusting opt-out (wraps the wire-shape `toggleSpotDusting` inner object); supports `expires_after`.
31
+
32
+ ### New Info methods
33
+
34
+ - `Info#gossip_priority_auction_status` — returns the current `gossipPriorityAuctionStatus`.
35
+
36
+ ### Tests
37
+
38
+ - New integration scripts `scripts/test_18_user_portfolio_margin.rb` and `scripts/test_19_spot_user.rb` wired into `test_all.rb` (not yet in `test_automated.rb`).
39
+
3
40
  ## [1.5.0] - 2026-05-08
4
41
 
5
42
  ### New Exchange actions
@@ -1017,6 +1017,31 @@ module Hyperliquid
1017
1017
  post_action(action, signature, nonce, nil)
1018
1018
  end
1019
1019
 
1020
+ # Permanently disable a linked trading user, locking its funds
1021
+ # (`stakingLinkDisableTradingUser` user-signed action). Sent by the staking user.
1022
+ # After 1 year of locking, funds from the trading user are automatically transferred
1023
+ # to the staking user. **This action is irreversible.** The `trading_user` address is
1024
+ # lowercased to match the address-field convention used by other user-signed actions.
1025
+ # @param trading_user [String] Trading user address to disable
1026
+ # @return [Hash] Exchange response
1027
+ def staking_link_disable_trading_user(trading_user:)
1028
+ nonce = timestamp_ms
1029
+ trading_user_lower = trading_user.downcase
1030
+ action = {
1031
+ type: 'stakingLinkDisableTradingUser',
1032
+ signatureChainId: '0x66eee',
1033
+ hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
1034
+ tradingUser: trading_user_lower,
1035
+ nonce: nonce
1036
+ }
1037
+ signature = @signer.sign_user_signed_action(
1038
+ { tradingUser: trading_user_lower, nonce: nonce },
1039
+ 'HyperliquidTransaction:StakingLinkDisableTradingUser',
1040
+ Signing::EIP712::STAKING_LINK_DISABLE_TRADING_USER_TYPES
1041
+ )
1042
+ post_action(action, signature, nonce, nil)
1043
+ end
1044
+
1020
1045
  # Move assets between DEX instances on behalf of an agent's principal
1021
1046
  # (`agentSendAsset` L1 action). Unlike `send_asset` (which is user-signed),
1022
1047
  # this is signed by an agent and the destination must equal the agent's
@@ -1110,6 +1135,260 @@ module Hyperliquid
1110
1135
  post_action(action, signature, nonce, nil)
1111
1136
  end
1112
1137
 
1138
+ # Toggle cross-portfolio-margin mode for a user (`userPortfolioMargin` user-signed action).
1139
+ # The `user` address is lowercased to match the Python SDK and protocol expectations.
1140
+ # @param user [String] Wallet address whose portfolio-margin mode is being toggled
1141
+ # @param enabled [Boolean] True to enable cross-portfolio margin, false to disable
1142
+ # @return [Hash] Exchange response
1143
+ def user_portfolio_margin(user:, enabled:)
1144
+ nonce = timestamp_ms
1145
+ user_lower = user.downcase
1146
+ action = {
1147
+ type: 'userPortfolioMargin',
1148
+ signatureChainId: '0x66eee',
1149
+ hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
1150
+ user: user_lower,
1151
+ enabled: enabled,
1152
+ nonce: nonce
1153
+ }
1154
+ signature = @signer.sign_user_signed_action(
1155
+ { user: user_lower, enabled: enabled, nonce: nonce },
1156
+ 'HyperliquidTransaction:UserPortfolioMargin',
1157
+ Signing::EIP712::USER_PORTFOLIO_MARGIN_TYPES
1158
+ )
1159
+ post_action(action, signature, nonce, nil)
1160
+ end
1161
+
1162
+ # Deposit native HYPE from the user's spot account into staking (`cDeposit` user-signed action).
1163
+ # @param wei [Integer] Amount of wei to deposit into staking (float * 1e8)
1164
+ # @return [Hash] Exchange response
1165
+ def c_deposit(wei:)
1166
+ nonce = timestamp_ms
1167
+ wei_int = wei.to_i
1168
+ action = {
1169
+ type: 'cDeposit',
1170
+ signatureChainId: '0x66eee',
1171
+ hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
1172
+ wei: wei_int,
1173
+ nonce: nonce
1174
+ }
1175
+ signature = @signer.sign_user_signed_action(
1176
+ { wei: wei_int, nonce: nonce },
1177
+ 'HyperliquidTransaction:CDeposit',
1178
+ Signing::EIP712::C_DEPOSIT_TYPES
1179
+ )
1180
+ post_action(action, signature, nonce, nil)
1181
+ end
1182
+
1183
+ # Withdraw native HYPE from staking back into the user's spot account (`cWithdraw` user-signed action).
1184
+ # @param wei [Integer] Amount of wei to withdraw from staking (float * 1e8)
1185
+ # @return [Hash] Exchange response
1186
+ def c_withdraw(wei:)
1187
+ nonce = timestamp_ms
1188
+ wei_int = wei.to_i
1189
+ action = {
1190
+ type: 'cWithdraw',
1191
+ signatureChainId: '0x66eee',
1192
+ hyperliquidChain: Signing::EIP712.hyperliquid_chain(testnet: @testnet),
1193
+ wei: wei_int,
1194
+ nonce: nonce
1195
+ }
1196
+ signature = @signer.sign_user_signed_action(
1197
+ { wei: wei_int, nonce: nonce },
1198
+ 'HyperliquidTransaction:CWithdraw',
1199
+ Signing::EIP712::C_WITHDRAW_TYPES
1200
+ )
1201
+ post_action(action, signature, nonce, nil)
1202
+ end
1203
+
1204
+ # Place a TWAP order (`twapOrder` L1 action). The order is sliced over `minutes`
1205
+ # minutes (5..1440). When `randomize` is true the protocol randomizes the timing
1206
+ # of individual child orders. `size` is denominated in base currency units.
1207
+ # @param coin [String] Asset symbol
1208
+ # @param is_buy [Boolean] True for buy/long, false for sell/short
1209
+ # @param size [String, Numeric] Total order size (base currency units)
1210
+ # @param reduce_only [Boolean] Reduce-only flag
1211
+ # @param minutes [Integer] TWAP duration in minutes (5..1440)
1212
+ # @param randomize [Boolean] Randomize order timing
1213
+ # @param vault_address [String, nil] Vault address if acting on behalf of a vault
1214
+ # @return [Hash] Exchange response — on success `response.data.status.running.twapId`
1215
+ def twap_order(coin:, is_buy:, size:, reduce_only:, minutes:, randomize:, vault_address: nil)
1216
+ nonce = timestamp_ms
1217
+ action = {
1218
+ type: 'twapOrder',
1219
+ twap: {
1220
+ a: asset_index(coin),
1221
+ b: is_buy,
1222
+ s: float_to_wire(size),
1223
+ r: reduce_only,
1224
+ m: minutes,
1225
+ t: randomize
1226
+ }
1227
+ }
1228
+ signature = @signer.sign_l1_action(
1229
+ action, nonce,
1230
+ vault_address: vault_address,
1231
+ expires_after: @expires_after
1232
+ )
1233
+ post_action(action, signature, nonce, vault_address)
1234
+ end
1235
+
1236
+ # Cancel a TWAP order by id (`twapCancel` L1 action).
1237
+ # @param coin [String] Asset symbol the TWAP applies to
1238
+ # @param twap_id [Integer] TWAP id returned by `twap_order`
1239
+ # @param vault_address [String, nil] Vault address if acting on behalf of a vault
1240
+ # @return [Hash] Exchange response
1241
+ def twap_cancel(coin:, twap_id:, vault_address: nil)
1242
+ nonce = timestamp_ms
1243
+ action = { type: 'twapCancel', a: asset_index(coin), t: twap_id }
1244
+ signature = @signer.sign_l1_action(
1245
+ action, nonce,
1246
+ vault_address: vault_address,
1247
+ expires_after: @expires_after
1248
+ )
1249
+ post_action(action, signature, nonce, vault_address)
1250
+ end
1251
+
1252
+ # Reserve additional rate-limited actions for a fee (`reserveRequestWeight` L1 action).
1253
+ # @param weight [Integer] Amount of request weight to reserve
1254
+ # @return [Hash] Exchange response
1255
+ def reserve_request_weight(weight:)
1256
+ nonce = timestamp_ms
1257
+ action = { type: 'reserveRequestWeight', weight: weight }
1258
+ signature = @signer.sign_l1_action(
1259
+ action, nonce,
1260
+ expires_after: @expires_after
1261
+ )
1262
+ post_action(action, signature, nonce, nil)
1263
+ end
1264
+
1265
+ # HIP-4: split `amount` quote tokens into `amount` Yes and `amount` No shares
1266
+ # of an outcome (`userOutcome` L1 action, splitOutcome variant).
1267
+ # @param outcome [Integer] Outcome identifier
1268
+ # @param amount [String, Numeric] Amount of quote tokens to split (UnsignedDecimal, coerced via to_s)
1269
+ # @return [Hash] Exchange response
1270
+ def split_outcome(outcome:, amount:)
1271
+ nonce = timestamp_ms
1272
+ action = {
1273
+ type: 'userOutcome',
1274
+ splitOutcome: { outcome: outcome, amount: amount.to_s }
1275
+ }
1276
+ signature = @signer.sign_l1_action(
1277
+ action, nonce,
1278
+ expires_after: @expires_after
1279
+ )
1280
+ post_action(action, signature, nonce, nil)
1281
+ end
1282
+
1283
+ # HIP-4: merge `amount` Yes and `amount` No shares of an outcome back into
1284
+ # `amount` quote tokens (`userOutcome` L1 action, mergeOutcome variant).
1285
+ # Pass `amount: nil` to merge the maximum available.
1286
+ # @param outcome [Integer] Outcome identifier
1287
+ # @param amount [String, Numeric, nil] Amount of shares to merge; nil = maximum available
1288
+ # @return [Hash] Exchange response
1289
+ def merge_outcome(outcome:, amount: nil)
1290
+ nonce = timestamp_ms
1291
+ action = {
1292
+ type: 'userOutcome',
1293
+ mergeOutcome: { outcome: outcome, amount: amount&.to_s }
1294
+ }
1295
+ signature = @signer.sign_l1_action(
1296
+ action, nonce,
1297
+ expires_after: @expires_after
1298
+ )
1299
+ post_action(action, signature, nonce, nil)
1300
+ end
1301
+
1302
+ # HIP-4: merge `amount` Yes shares from every outcome of a question into
1303
+ # `amount` quote tokens (`userOutcome` L1 action, mergeQuestion variant).
1304
+ # Pass `amount: nil` to merge the maximum available.
1305
+ # @param question [Integer] Question identifier
1306
+ # @param amount [String, Numeric, nil] Amount of shares to merge; nil = maximum available
1307
+ # @return [Hash] Exchange response
1308
+ def merge_question(question:, amount: nil)
1309
+ nonce = timestamp_ms
1310
+ action = {
1311
+ type: 'userOutcome',
1312
+ mergeQuestion: { question: question, amount: amount&.to_s }
1313
+ }
1314
+ signature = @signer.sign_l1_action(
1315
+ action, nonce,
1316
+ expires_after: @expires_after
1317
+ )
1318
+ post_action(action, signature, nonce, nil)
1319
+ end
1320
+
1321
+ # HIP-4: convert `amount` No shares from one outcome of a question into
1322
+ # `amount` Yes shares of every other outcome associated with that question
1323
+ # (`userOutcome` L1 action, negateOutcome variant).
1324
+ # @param question [Integer] Question identifier
1325
+ # @param outcome [Integer] Outcome identifier whose No shares are being negated
1326
+ # @param amount [String, Numeric] Amount of No shares to negate (UnsignedDecimal, coerced via to_s)
1327
+ # @return [Hash] Exchange response
1328
+ def negate_outcome(question:, outcome:, amount:)
1329
+ nonce = timestamp_ms
1330
+ action = {
1331
+ type: 'userOutcome',
1332
+ negateOutcome: { question: question, outcome: outcome, amount: amount.to_s }
1333
+ }
1334
+ signature = @signer.sign_l1_action(
1335
+ action, nonce,
1336
+ expires_after: @expires_after
1337
+ )
1338
+ post_action(action, signature, nonce, nil)
1339
+ end
1340
+
1341
+ # Finalize the link between a HyperCore spot token and an ERC-20 contract on
1342
+ # HyperEVM (`finalizeEvmContract` L1 action). `input` selects the verification method
1343
+ # and is passed through verbatim — accepts a Hash `{ create: { nonce: <int> } }` for an
1344
+ # EOA-deployed contract, or one of the strings `"firstStorageSlot"` / `"customStorageSlot"`
1345
+ # for contracts that store the finalizer address in a known storage slot.
1346
+ # @param token [Integer] HyperCore spot token identifier to link
1347
+ # @param input [Hash, String] Verification method (see above)
1348
+ # @return [Hash] Exchange response
1349
+ def finalize_evm_contract(token:, input:)
1350
+ nonce = timestamp_ms
1351
+ action = {
1352
+ type: 'finalizeEvmContract',
1353
+ token: token,
1354
+ input: input
1355
+ }
1356
+ signature = @signer.sign_l1_action(
1357
+ action, nonce,
1358
+ expires_after: @expires_after
1359
+ )
1360
+ post_action(action, signature, nonce, nil)
1361
+ end
1362
+
1363
+ # Opt in or out of spot dusting (`spotUser` L1 action).
1364
+ # Spot dusting is the protocol's automatic conversion of small spot balances.
1365
+ # Despite the generic action name, this method exclusively toggles that opt-out flag.
1366
+ # @param opt_out [Boolean] True to opt out of spot dusting, false to opt in
1367
+ # @return [Hash] Exchange response
1368
+ def spot_user(opt_out:)
1369
+ nonce = timestamp_ms
1370
+ action = { type: 'spotUser', toggleSpotDusting: { optOut: opt_out } }
1371
+ signature = @signer.sign_l1_action(
1372
+ action, nonce,
1373
+ expires_after: @expires_after
1374
+ )
1375
+ post_action(action, signature, nonce, nil)
1376
+ end
1377
+
1378
+ # Authorize an AQAv2 role (`authorizeAqav2Role` L1 action).
1379
+ # @param token [Integer] Token identifier
1380
+ # @param role [String] Role to authorize ("technical" or "treasury")
1381
+ # @return [Hash] Exchange response
1382
+ def authorize_aqav2_role(token:, role:)
1383
+ nonce = timestamp_ms
1384
+ action = { type: 'authorizeAqav2Role', token: token.to_i, role: role }
1385
+ signature = @signer.sign_l1_action(
1386
+ action, nonce,
1387
+ expires_after: @expires_after
1388
+ )
1389
+ post_action(action, signature, nonce, nil)
1390
+ end
1391
+
1113
1392
  # Clear the asset metadata cache
1114
1393
  # Call this if metadata has been updated
1115
1394
  def reload_metadata!
@@ -359,6 +359,13 @@ module Hyperliquid
359
359
  @client.post(Constants::INFO_ENDPOINT, { type: 'gossipRootIps' })
360
360
  end
361
361
 
362
+ # Retrieve gossip priority auction status (previous winners and current auctions)
363
+ # @return [Array] Two-element tuple: [0] array of previous-winner IPs per slot (String or nil),
364
+ # [1] array of current auction statuses per slot (same shape as perp_deploy_auction_status)
365
+ def gossip_priority_auction_status
366
+ @client.post(Constants::INFO_ENDPOINT, { type: 'gossipPriorityAuctionStatus' })
367
+ end
368
+
362
369
  # Retrieve a user's legal verification status
363
370
  # @param user [String] Wallet address
364
371
  # @return [Hash] Keys: ipAllowed (Boolean), acceptedTerms (Boolean), userAllowed (Boolean)
@@ -508,6 +515,13 @@ module Hyperliquid
508
515
  @client.post(Constants::INFO_ENDPOINT, { type: 'outcomeMeta' })
509
516
  end
510
517
 
518
+ # Retrieve information about a settled outcome
519
+ # @param outcome [Integer] Outcome identifier
520
+ # @return [Hash, nil] Hash with spec, settleFraction, and details; or nil if not settled
521
+ def settled_outcome(outcome:)
522
+ @client.post(Constants::INFO_ENDPOINT, { type: 'settledOutcome', outcome: outcome.to_i })
523
+ end
524
+
511
525
  # Retrieve a user's funding history
512
526
  # @param user [String]
513
527
  # @param start_time [Integer]
@@ -130,6 +130,14 @@ module Hyperliquid
130
130
  ]
131
131
  }.freeze
132
132
 
133
+ STAKING_LINK_DISABLE_TRADING_USER_TYPES = {
134
+ 'HyperliquidTransaction:StakingLinkDisableTradingUser': [
135
+ { name: :hyperliquidChain, type: 'string' },
136
+ { name: :tradingUser, type: 'address' },
137
+ { name: :nonce, type: 'uint64' }
138
+ ]
139
+ }.freeze
140
+
133
141
  MULTI_SIG_TYPES = {
134
142
  'HyperliquidTransaction:SendMultiSig': [
135
143
  { name: :hyperliquidChain, type: 'string' },
@@ -138,6 +146,31 @@ module Hyperliquid
138
146
  ]
139
147
  }.freeze
140
148
 
149
+ USER_PORTFOLIO_MARGIN_TYPES = {
150
+ 'HyperliquidTransaction:UserPortfolioMargin': [
151
+ { name: :hyperliquidChain, type: 'string' },
152
+ { name: :user, type: 'address' },
153
+ { name: :enabled, type: 'bool' },
154
+ { name: :nonce, type: 'uint64' }
155
+ ]
156
+ }.freeze
157
+
158
+ C_DEPOSIT_TYPES = {
159
+ 'HyperliquidTransaction:CDeposit': [
160
+ { name: :hyperliquidChain, type: 'string' },
161
+ { name: :wei, type: 'uint64' },
162
+ { name: :nonce, type: 'uint64' }
163
+ ]
164
+ }.freeze
165
+
166
+ C_WITHDRAW_TYPES = {
167
+ 'HyperliquidTransaction:CWithdraw': [
168
+ { name: :hyperliquidChain, type: 'string' },
169
+ { name: :wei, type: 'uint64' },
170
+ { name: :nonce, type: 'uint64' }
171
+ ]
172
+ }.freeze
173
+
141
174
  SEND_TO_EVM_WITH_DATA_TYPES = {
142
175
  'HyperliquidTransaction:SendToEvmWithData': [
143
176
  { name: :hyperliquidChain, type: 'string' },
@@ -17,6 +17,16 @@ module Hyperliquid
17
17
  module MultiSig
18
18
  OUTER_PRIMARY_TYPE = 'HyperliquidTransaction:SendMultiSig'
19
19
 
20
+ # Wire-format normalization for userSetAbstraction inside multi_sig envelopes.
21
+ # Each co-signer's EIP-712 hash uses the human-readable abstraction string, but the
22
+ # L1 payload requires the single-char wire enum. Mirrors Python SDK 0.24.0's
23
+ # `_multi_sig_payload_action`.
24
+ USER_SET_ABSTRACTION_WIRE_VALUES = {
25
+ 'disabled' => 'i',
26
+ 'unifiedAccount' => 'u',
27
+ 'portfolioMargin' => 'p'
28
+ }.freeze
29
+
20
30
  # Build the outer multi-sig envelope (the action body posted to /exchange).
21
31
  # @param inner_action [Hash] The wrapped action (any L1 or user-signed action body)
22
32
  # @param multi_sig_user [String] Address of the multi-sig user (lowercased)
@@ -31,11 +41,26 @@ module Hyperliquid
31
41
  payload: {
32
42
  multiSigUser: multi_sig_user.downcase,
33
43
  outerSigner: outer_signer.downcase,
34
- action: inner_action
44
+ action: payload_action(inner_action)
35
45
  }
36
46
  }
37
47
  end
38
48
 
49
+ # Normalize an inner action for the L1 payload. Currently only userSetAbstraction
50
+ # needs translation (long-form abstraction string → wire enum); all other actions
51
+ # pass through verbatim.
52
+ # @param inner_action [Hash] Inner action (symbol or string keys)
53
+ # @return [Hash] Possibly-normalized copy (or the original if no change needed)
54
+ def self.payload_action(inner_action)
55
+ return inner_action unless (inner_action[:type] || inner_action['type']) == 'userSetAbstraction'
56
+
57
+ key = inner_action.key?(:abstraction) ? :abstraction : 'abstraction'
58
+ abstraction = inner_action[key]
59
+ return inner_action unless USER_SET_ABSTRACTION_WIRE_VALUES.key?(abstraction)
60
+
61
+ inner_action.merge(key => USER_SET_ABSTRACTION_WIRE_VALUES[abstraction])
62
+ end
63
+
39
64
  # Compute the multiSigActionHash that the submitter signs over.
40
65
  # Mirrors Python's `sign_multi_sig_action`: action_hash(envelope - type, vault, nonce, expires).
41
66
  # @param envelope [Hash] The multi-sig envelope (will have :type stripped before hashing)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperliquid
4
- VERSION = '1.5.0'
4
+ VERSION = '1.7.0'
5
5
  end
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Test 18: userPortfolioMargin (user-signed exchange action)
5
+ #
6
+ # Toggles cross-portfolio-margin mode for the calling wallet. Sends two actions:
7
+ # enable, then disable, leaving the wallet in its original state.
8
+ #
9
+ # WARNING: Toggling portfolio margin alters margining math on existing perp positions.
10
+ # Run only on a wallet without significant open exposure on testnet.
11
+ #
12
+ # Usage:
13
+ # HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_18_user_portfolio_margin.rb
14
+
15
+ require_relative 'test_helpers'
16
+
17
+ sdk = build_sdk
18
+ separator('TEST 18: userPortfolioMargin')
19
+
20
+ user = sdk.exchange.address
21
+ puts "Wallet: #{user}"
22
+ puts
23
+
24
+ puts 'Enabling portfolio margin...'
25
+ result = sdk.exchange.user_portfolio_margin(user: user, enabled: true)
26
+
27
+ # The protocol enforces a $10k account value or $5m volume threshold for portfolio
28
+ # margin eligibility. The agent testnet wallet typically meets neither, so this
29
+ # precondition failure is not an SDK bug — downgrade to warning and exit cleanly,
30
+ # matching the test_08 / test_11 pattern.
31
+ if result.is_a?(Hash) && result['status'] == 'err' &&
32
+ result['response'].to_s.include?('Portfolio margin requires')
33
+ puts red("WARNING: #{result['response']}")
34
+ puts ' Skipping — this is a testnet precondition, not an SDK failure.'
35
+ puts ' The action correctly serialized and signed (server returned a structured'
36
+ puts ' rejection, not a signing error).'
37
+ test_passed('Test 18 user_portfolio_margin')
38
+ exit 0
39
+ end
40
+
41
+ if api_error?(result)
42
+ puts red("user_portfolio_margin (enable) FAILED: #{result.inspect}")
43
+ test_passed('Test 18 user_portfolio_margin')
44
+ exit 1
45
+ end
46
+
47
+ unless result.is_a?(Hash) && result['status'] == 'ok' && result.dig('response', 'type') == 'default'
48
+ $test_failed = true
49
+ puts red("Unexpected enable response: #{result.inspect}")
50
+ end
51
+
52
+ puts green("Enable OK: #{result.inspect}") unless $test_failed
53
+
54
+ wait_with_countdown(WAIT_SECONDS, 'Settling before toggle back...')
55
+
56
+ puts 'Disabling portfolio margin...'
57
+ result = sdk.exchange.user_portfolio_margin(user: user, enabled: false)
58
+
59
+ if api_error?(result)
60
+ puts red("user_portfolio_margin (disable) FAILED: #{result.inspect}")
61
+ test_passed('Test 18 user_portfolio_margin')
62
+ exit 1
63
+ end
64
+
65
+ unless result.is_a?(Hash) && result['status'] == 'ok' && result.dig('response', 'type') == 'default'
66
+ $test_failed = true
67
+ puts red("Unexpected disable response: #{result.inspect}")
68
+ end
69
+
70
+ puts green("Disable OK: #{result.inspect}") unless $test_failed
71
+
72
+ test_passed('Test 18 user_portfolio_margin')
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Test 19: spotUser (L1 exchange action)
5
+ #
6
+ # Toggles spot-dusting opt-out for the calling wallet. Sends two actions:
7
+ # opt_out: true, then opt_out: false, leaving the wallet in its original state
8
+ # (spot dusting opted-in by default).
9
+ #
10
+ # Usage:
11
+ # HYPERLIQUID_PRIVATE_KEY=0x... ruby scripts/test_19_spot_user.rb
12
+
13
+ require_relative 'test_helpers'
14
+
15
+ sdk = build_sdk
16
+ separator('TEST 19: spotUser (spot-dusting opt-out)')
17
+
18
+ puts "Wallet: #{sdk.exchange.address}"
19
+ puts
20
+
21
+ puts 'Opting out of spot dusting...'
22
+ result = sdk.exchange.spot_user(opt_out: true)
23
+
24
+ if api_error?(result)
25
+ puts red("spot_user (opt_out: true) FAILED: #{result.inspect}")
26
+ test_passed('Test 19 spot_user')
27
+ exit 1
28
+ end
29
+
30
+ unless result.is_a?(Hash) && result['status'] == 'ok' && result.dig('response', 'type') == 'default'
31
+ $test_failed = true
32
+ puts red("Unexpected opt-out response: #{result.inspect}")
33
+ end
34
+
35
+ puts green("Opt-out OK: #{result.inspect}") unless $test_failed
36
+
37
+ wait_with_countdown(WAIT_SECONDS, 'Settling before toggle back...')
38
+
39
+ puts 'Opting back in to spot dusting...'
40
+ result = sdk.exchange.spot_user(opt_out: false)
41
+
42
+ if api_error?(result)
43
+ puts red("spot_user (opt_out: false) FAILED: #{result.inspect}")
44
+ test_passed('Test 19 spot_user')
45
+ exit 1
46
+ end
47
+
48
+ unless result.is_a?(Hash) && result['status'] == 'ok' && result.dig('response', 'type') == 'default'
49
+ $test_failed = true
50
+ puts red("Unexpected opt-in response: #{result.inspect}")
51
+ end
52
+
53
+ puts green("Opt-in OK: #{result.inspect}") unless $test_failed
54
+
55
+ test_passed('Test 19 spot_user')
data/scripts/test_all.rb CHANGED
@@ -35,7 +35,9 @@ SCRIPTS = [
35
35
  'test_14_ws_candle.rb',
36
36
  'test_15_explorer.rb',
37
37
  'test_16_send_to_evm_with_data.rb',
38
- 'test_17_create_vault.rb'
38
+ 'test_17_create_vault.rb',
39
+ 'test_18_user_portfolio_margin.rb',
40
+ 'test_19_spot_user.rb'
39
41
  ].freeze
40
42
 
41
43
  def green(text)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperliquid
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - carter2099
@@ -133,6 +133,8 @@ files:
133
133
  - scripts/test_15_explorer.rb
134
134
  - scripts/test_16_send_to_evm_with_data.rb
135
135
  - scripts/test_17_create_vault.rb
136
+ - scripts/test_18_user_portfolio_margin.rb
137
+ - scripts/test_19_spot_user.rb
136
138
  - scripts/test_all.rb
137
139
  - scripts/test_automated.rb
138
140
  - scripts/test_helpers.rb