perp-cli 0.12.15 → 0.14.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 (361) hide show
  1. package/README.md +278 -31
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +286 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +74 -84
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +316 -337
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1383 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +157 -682
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +14 -0
  287. package/dist/landing.js +36 -0
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +338 -134
  348. package/skills/perp-cli/references/agent-operations.md +181 -92
  349. package/skills/perp-cli/references/commands.md +475 -132
  350. package/skills/perp-cli/references/strategies.md +44 -38
  351. package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
  352. package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +33 -16
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,19 +1,357 @@
1
- import { Hyperliquid } from "hyperliquid";
2
1
  import { LocalEvmSigner } from "../signer/index.js";
3
- import { PerpError } from "../errors.js";
2
+ import { DEFINITE_ORDER_FAILURE_CODES, PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure } from "../errors.js";
3
+ import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
4
+ import { alignProtectionPrice, floorSize, formatComputedPrice, slippagePrice } from "./precision.js";
5
+ import { unsupported } from "./trading-v2.js";
6
+ import { buildHlUserSignedAction, hlUserSignedNonceField, signHlL1Action, signHlUserSignedAction } from "./hl-signing.js";
7
+ import { HL_CLOID_RE } from "./venues/hyperliquid.js";
8
+ import { rateLimitedError } from "./rate-limiter.js";
9
+ import { fetchAndCache, withCache, TTL_ACCOUNT } from "../cache.js";
10
+ import { approvalFromMax, builderGateOf, hlFeeTenthsBps, NO_BUILDER, requireFeeBps } from "./builder-fee.js";
11
+ /**
12
+ * Map a TIF input to HL's native value (design §13.3). Accepts the common names
13
+ * GTC/IOC/ALO and the native spellings case-insensitively. FOK is not offered by
14
+ * HL (no FOK emulation, §13.3) → CAPABILITY_UNSUPPORTED; anything else likewise.
15
+ */
16
+ export function hlTif(raw) {
17
+ const t = raw.trim().toUpperCase();
18
+ if (t === "GTC")
19
+ return "Gtc";
20
+ if (t === "IOC")
21
+ return "Ioc";
22
+ if (t === "ALO")
23
+ return "Alo";
24
+ if (t === "FOK") {
25
+ throw unsupported("Hyperliquid", "fok", "Hyperliquid has no fill-or-kill time-in-force (partial IOC fills cannot be undone)", "Use --tif IOC (partial fills possible) or GTC");
26
+ }
27
+ throw unsupported("Hyperliquid", "timeInForce", `time-in-force ${raw} is not offered`, "Use one of: GTC, IOC, ALO");
28
+ }
29
+ /** HL lot step implied by szDecimals ("1" for 0, "0.001" for 3) — sizes are floored to it (floorSize). */
30
+ export function hlSizeStep(szDecimals) {
31
+ return szDecimals <= 0 ? "1" : `0.${"0".repeat(szDecimals - 1)}1`;
32
+ }
33
+ /**
34
+ * Slippage-bounded IOC protection price (design §13.2, §13.10 #5).
35
+ *
36
+ * `reference` × (1 ± bps/10000), then aligned TOWARD CROSSING (buy → up, sell →
37
+ * down) on HL's price grid: at most 5 significant figures and at most
38
+ * `maxDecimals − szDecimals` decimals (maxDecimals 6 perps / 8 spot); integer
39
+ * prices are always valid. The returned `appliedBps` is the bound actually sent
40
+ * (after rounding) relative to the reference — it can exceed the requested bps
41
+ * by up to one grid step because alignment never narrows the bound toward
42
+ * not-crossing.
43
+ */
44
+ export function hlProtectionPrice(reference, side, bps, szDecimals, maxDecimals) {
45
+ const raw = slippagePrice(String(reference), side, bps);
46
+ const px = Number(raw);
47
+ let e = Math.floor(Math.log10(px));
48
+ if (10 ** (e + 1) <= px)
49
+ e += 1;
50
+ if (10 ** e > px)
51
+ e -= 1;
52
+ const decimals = Math.max(0, Math.min(maxDecimals - szDecimals, 4 - e));
53
+ const tick = decimals === 0 ? "1" : `0.${"0".repeat(decimals - 1)}1`;
54
+ const protectionPrice = alignProtectionPrice(raw, tick, side);
55
+ const appliedBps = Math.abs(Number(protectionPrice) - reference) / reference * 10_000;
56
+ return { protectionPrice, appliedBps };
57
+ }
58
+ /**
59
+ * A USER-SUPPLIED limit / trigger / modify price checked against HL's price grid (design §10.10:
60
+ * an off-grid limit price is refused, never rounded — rounding to nearest can move a buy price UP).
61
+ * HL docs (trading/tick-and-lot-size): "Prices can have up to 5 significant figures, but no more
62
+ * than MAX_DECIMALS - szDecimals decimal places where MAX_DECIMALS is 6 for perps and 8 for spot.
63
+ * Integer prices are always allowed, regardless of the number of significant figures."
64
+ * Non-numeric / non-positive → INVALID_PARAMS; off-grid → INVALID_TICK (with the nearest on-grid
65
+ * prices below / above). Returns the price normalized (no leading / trailing zeros). Computed
66
+ * prices (protection, market-trigger `p`) are aligned with hlProtectionPrice instead.
67
+ */
68
+ export function hlRequirePriceOnGrid(price, szDecimals, maxDecimals, what) {
69
+ const s = String(price).trim();
70
+ if (!/^\d+(\.\d+)?$/.test(s) || !(Number(s) > 0)) {
71
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid ${what} must be a positive plain decimal (got "${price}")`, {
72
+ exchange: "hyperliquid", remediation: "Pass a plain decimal price such as 3012.5 (no exponent, sign or spaces)",
73
+ });
74
+ }
75
+ const [ipRaw, fpRaw = ""] = s.split(".");
76
+ const ip = ipRaw.replace(/^0+(?=\d)/, "");
77
+ const fp = fpRaw.replace(/0+$/, "");
78
+ const normalized = fp === "" ? ip : `${ip}.${fp}`;
79
+ if (fp === "")
80
+ return normalized; // integer prices are always valid
81
+ const maxDec = Math.max(0, maxDecimals - szDecimals);
82
+ const sigFigs = (ip + fp).replace(/^0+/, "").length;
83
+ if (fp.length <= maxDec && sigFigs <= 5)
84
+ return normalized;
85
+ // Nearest on-grid prices: the grid tick at this magnitude (≤ 5 sig figs, ≤ maxDec decimals).
86
+ const px = Number(normalized);
87
+ const e = Math.floor(Math.log10(px));
88
+ const decimals = Math.max(0, Math.min(maxDec, 4 - e));
89
+ const tick = decimals === 0 ? "1" : `0.${"0".repeat(decimals - 1)}1`;
90
+ const above = alignProtectionPrice(normalized, tick, "buy");
91
+ const below = px >= Number(tick) ? alignProtectionPrice(normalized, tick, "sell") : null;
92
+ throw new PerpError("INVALID_TICK", `Hyperliquid ${what} ${price} is off the price grid (max 5 significant figures and ${maxDec} decimals for szDecimals ${szDecimals}); nothing was sent`, {
93
+ exchange: "hyperliquid",
94
+ remediation: `Use an on-grid price, e.g. ${below !== null ? `${below} or ` : ""}${above}`,
95
+ });
96
+ }
97
+ export const HL_CCTP_ROUTES = {
98
+ // Forwarding fee: Circle documents 0.2 USDC for Arbitrum; other chains' fee is not published (null = unknown).
99
+ arbitrum: { cctpDomain: 3, forwardingFeeUsdc: "0.2" },
100
+ ethereum: { cctpDomain: 0, forwardingFeeUsdc: null },
101
+ base: { cctpDomain: 6, forwardingFeeUsdc: null },
102
+ };
103
+ /**
104
+ * A COMPUTED price (strategy quote, grid level, slice limit) moved onto the HL price grid in the
105
+ * passive direction: "down" for a buy, "up" for a sell — never more aggressive than computed.
106
+ * User-supplied prices are never aligned (hlRequirePriceOnGrid refuses them instead).
107
+ */
108
+ export function hlAlignPriceToGrid(price, szDecimals, maxDecimals, direction) {
109
+ const s = String(price).trim();
110
+ if (!/^\d+(\.\d+)?$/.test(s) || !(Number(s) > 0)) {
111
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid computed price must be a positive plain decimal (got "${price}")`, { exchange: "hyperliquid" });
112
+ }
113
+ const px = Number(s);
114
+ const maxDec = Math.max(0, maxDecimals - szDecimals);
115
+ const e = Math.floor(Math.log10(px));
116
+ // ≤ 5 significant figures at this magnitude, ≤ maxDec decimals; integers are always valid.
117
+ const decimals = Math.max(0, Math.min(maxDec, 4 - e));
118
+ const tick = decimals === 0 ? "1" : `0.${"0".repeat(decimals - 1)}1`;
119
+ const aligned = alignProtectionPrice(s, tick, direction === "up" ? "buy" : "sell");
120
+ if (!(Number(aligned) > 0)) {
121
+ throw new PerpError("INVALID_TICK", `Hyperliquid computed price ${price} aligns to 0 on the grid`, { exchange: "hyperliquid" });
122
+ }
123
+ // Aligning up across a power of ten (e.g. 9.99995 → 10.0000) stays valid: hlRequirePriceOnGrid re-checks.
124
+ return hlRequirePriceOnGrid(aligned, szDecimals, maxDecimals, "computed price");
125
+ }
126
+ /**
127
+ * Grid rounding (toward crossing) can widen the bound past the requested bps; a bound
128
+ * actually sent above the venue's maxSlippageBps → INVALID_PARAMS before anything is sent.
129
+ * (slippage.js is imported lazily: it loads settings and the venue registry.)
130
+ */
131
+ export async function assertHlAppliedBps(appliedBps, what) {
132
+ const { venueMaxSlippageBps } = await import("./slippage.js");
133
+ const max = venueMaxSlippageBps("hyperliquid");
134
+ if (!(appliedBps <= max)) {
135
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid ${what}: the bound after HL price-grid rounding is ${appliedBps.toFixed(2)} bps, above the venue maximum ${max} bps`, {
136
+ exchange: "hyperliquid",
137
+ remediation: "Lower --slippage-bps, or place a limit order",
138
+ });
139
+ }
140
+ }
141
+ /**
142
+ * Definite IOC outcome of a raw HL `order` response (shared contract with the
143
+ * perp-order lane, design §13.10 #4). Reads `response.data.statuses[0]`:
144
+ * - `{ filled: { totalSz } }` with a plain decimal totalSz → that size, final;
145
+ * - `{ error }` that is HL's IOC no-match cancel ("could not immediately match")
146
+ * → the venue explicitly cancelled the IOC with nothing filled → "0", final;
147
+ * - anything else (resting, other errors, unexpected shape) → null: the shape
148
+ * does not prove a final fill size.
149
+ */
150
+ export function hlIocOutcome(raw) {
151
+ const statuses = raw?.response?.data?.statuses;
152
+ if (!Array.isArray(statuses) || statuses.length === 0)
153
+ return null;
154
+ const st = statuses[0];
155
+ if (!st || typeof st !== "object")
156
+ return null;
157
+ const filled = st.filled;
158
+ if (filled && typeof filled === "object") {
159
+ return typeof filled.totalSz === "string" && /^\d+(\.\d+)?$/.test(filled.totalSz)
160
+ ? { filledSize: filled.totalSz, final: true }
161
+ : null;
162
+ }
163
+ if (st.resting === undefined && typeof st.error === "string" && /could not immediately match/i.test(st.error)) {
164
+ return { filledSize: "0", final: true };
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * Decimal amount string for HL actions ("12.50" → "12.5"). Non-positive / non-numeric → INVALID_PARAMS
170
+ * (nothing is rounded or substituted).
171
+ */
172
+ export function hlAmountString(amount) {
173
+ const a = amount.trim();
174
+ if (!/^\d+(\.\d+)?$/.test(a) || !(Number(a) > 0)) {
175
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid amount must be a positive decimal (got "${amount}")`, { exchange: "hyperliquid" });
176
+ }
177
+ const noLead = a.replace(/^0+(?=\d)/, "");
178
+ return noLead.includes(".") ? noLead.replace(/\.?0+$/, "") : noLead;
179
+ }
180
+ /**
181
+ * Verdict of a twapOrder / twapCancel response. The envelope is "ok" even when the TWAP was
182
+ * refused: the verdict is `response.data.status` — `{ running: { twapId } }` (placed), the string
183
+ * "success" (cancelled) or `{ error }` (refused → ORDER_REJECTED). Any other shape → ORDER_PENDING.
184
+ */
185
+ export function hlValidateTwapResult(result, what) {
186
+ const status = result?.response?.data?.status;
187
+ if (status === "success")
188
+ return;
189
+ if (status !== null && typeof status === "object") {
190
+ const st = status;
191
+ if (typeof st.error === "string" && st.error !== "") {
192
+ throw new PerpError("ORDER_REJECTED", `Hyperliquid ${what} rejected: ${st.error}`, { exchange: "hyperliquid" });
193
+ }
194
+ if (st.running !== null && typeof st.running === "object" && typeof st.running.twapId === "number")
195
+ return;
196
+ }
197
+ throw orderOutcomeUnknown("hyperliquid", what, new Error(`unexpected TWAP response ${JSON.stringify(result).slice(0, 200)}`), "Do NOT resubmit — check the account's TWAP orders on Hyperliquid first");
198
+ }
199
+ /** HL TWAP running time bounds in minutes: "from 5 minutes to 7 days" (docs trading/order-types, TWAP details). */
200
+ export const HL_TWAP_MIN_MINUTES = 5;
201
+ export const HL_TWAP_MAX_MINUTES = 7 * 24 * 60;
202
+ /** HL TWAP "$100 minimum total order size" (same page). */
203
+ export const HL_TWAP_MIN_NOTIONAL_USD = 100;
204
+ /**
205
+ * Pre-check of a native TWAP against the venue's documented bounds — docs trading/order-types:
206
+ * "Running time can be set from 5 minutes to 7 days, with a $100 minimum total order size."
207
+ * The total order size is valued at the mark price. Nothing is sent when it throws.
208
+ */
209
+ export function hlAssertTwapBounds(symbol, size, durationMinutes, markPrice) {
210
+ if (!Number.isInteger(durationMinutes) || durationMinutes < HL_TWAP_MIN_MINUTES || durationMinutes > HL_TWAP_MAX_MINUTES) {
211
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid TWAP running time must be a whole number of minutes from ${HL_TWAP_MIN_MINUTES} to ${HL_TWAP_MAX_MINUTES} (7 days); got ${durationMinutes}`, {
212
+ exchange: "hyperliquid", remediation: `Pass a duration between ${HL_TWAP_MIN_MINUTES} and ${HL_TWAP_MAX_MINUTES} minutes`,
213
+ });
214
+ }
215
+ const sz = Number(size);
216
+ if (!(sz > 0) || !Number.isFinite(sz)) {
217
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid TWAP size must be a positive number (got "${size}")`, { exchange: "hyperliquid" });
218
+ }
219
+ const notional = sz * markPrice;
220
+ if (notional < HL_TWAP_MIN_NOTIONAL_USD) {
221
+ throw new PerpError("SIZE_TOO_SMALL", `Hyperliquid TWAP total order size must be at least $${HL_TWAP_MIN_NOTIONAL_USD}; ${size} ${symbol.toUpperCase()} is ~$${notional.toFixed(2)} at mark ${markPrice}`, {
222
+ exchange: "hyperliquid", remediation: `Raise the size to at least $${HL_TWAP_MIN_NOTIONAL_USD} notional, or place a regular order`,
223
+ });
224
+ }
225
+ }
226
+ /**
227
+ * "sl" when the trigger is on the adverse side of the reference (buy ≥ ref, sell ≤ ref), else "tp".
228
+ * The reference must be the MARK price — docs trading/take-profit-and-stop-loss-orders-tp-sl:
229
+ * "The mark price is used to trigger TP/SL orders."
230
+ */
231
+ export function hlTriggerKind(side, trigger, reference) {
232
+ return (side === "buy" ? trigger >= reference : trigger <= reference) ? "sl" : "tp";
233
+ }
234
+ /** HL spot coin names: "@<pair index>" or "<BASE>/<QUOTE>" (e.g. "@107", "PURR/USDC"). Perp coins are neither. */
235
+ /** Markets without a position whose setting `account settings` reads (recent fills, activeAssetData weight 20 each). */
236
+ export const HL_SETTINGS_FILL_MARKETS = 5;
237
+ export function hlIsSpotCoin(coin) {
238
+ return coin.startsWith("@") || coin.includes("/");
239
+ }
240
+ /**
241
+ * Destination of a withdraw3 / usdSend: a 20-byte hex address, sent lower-cased (the venue
242
+ * hashes the string as signed; the official SDKs send lower-case). Anything else → INVALID_PARAMS.
243
+ */
244
+ export function hlDestination(destination) {
245
+ const d = String(destination).trim();
246
+ if (!/^0x[0-9a-fA-F]{40}$/.test(d) || /^0x0{40}$/.test(d)) {
247
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid destination must be a 0x-prefixed 20-byte address (got "${destination}"); nothing was sent`, { exchange: "hyperliquid" });
248
+ }
249
+ return d.toLowerCase();
250
+ }
251
+ /**
252
+ * On-disk lifetime of the public market metadata (info `meta`, `perpDexs`) shared by every CLI
253
+ * process (cache.ts). Asset ids are universe positions; a market listed inside this window is
254
+ * unknown (SYMBOL_NOT_FOUND) until the entry expires.
255
+ */
256
+ export const HL_MARKET_META_TTL_MS = 5 * 60_000;
257
+ /**
258
+ * On-disk lifetime of the user's account abstraction mode (info `userAbstraction`), shared by
259
+ * every CLI process of the same network + address. Docs ("Rate limits and user limits"): "REST
260
+ * requests share an aggregated weight limit of 1200 per minute" and `userAbstraction` is one of
261
+ * "All other documented `info` requests [that] have weight 20" — read on every invocation it was
262
+ * the largest fixed cost of a command. The mode is a per-user setting that only changes by an
263
+ * explicit `userSetAbstraction`; `perp wallet manage account-mode` drops the entry when it sets
264
+ * one and always reads it fresh. A change made outside this CLI (web app) is seen after at most
265
+ * this window. The key is outside the `acct:` prefix, so the per-command account-cache
266
+ * invalidation does not force a re-read.
267
+ */
268
+ export const HL_ABSTRACTION_TTL_MS = 60_000;
269
+ /** Cache-key prefix of the abstraction mode for a network (invalidateCache prefix: every address). */
270
+ export function hlAbstractionCachePrefix(network) {
271
+ return `mode:hl:${network}:abstraction:`;
272
+ }
273
+ /** Venue margin restriction of a universe entry: strictIsolated (no margin removal) / noCross; null = unrestricted. */
274
+ export function hlMarginModeOf(asset) {
275
+ if (asset.marginMode === "strictIsolated")
276
+ return "strictIsolated";
277
+ if (asset.marginMode === "noCross" || asset.onlyIsolated === true)
278
+ return "noCross";
279
+ return null;
280
+ }
281
+ /** HIP-3 (builder-deployed) asset ids start at 110000 (110000 + dexIndex × 10000 + universe index). */
282
+ export const HIP3_MIN_ASSET_ID = 110_000;
283
+ /**
284
+ * HL's documented slippage tolerance for MARKET TP/SL triggers ("TP/SL market orders have a
285
+ * slippage tolerance of 10%", docs trading/take-profit-and-stop-loss-orders-tp-sl). A market
286
+ * trigger's `p` is sent at trigger ± this bound (the official SDK / app send a wide limit, never
287
+ * the trigger itself): if the venue honours `p` as a limit, a `p` equal to the trigger could
288
+ * rest unfilled once the stop fires.
289
+ */
290
+ export const HL_MARKET_TPSL_SLIPPAGE_BPS = 1000;
291
+ /** A `{"status":"err","response":…}` envelope from POST /exchange: the venue processed the request and refused it. */
292
+ export class HlErrResponse extends Error {
293
+ constructor(message) {
294
+ super(message);
295
+ this.name = "HlErrResponse";
296
+ }
297
+ }
298
+ /**
299
+ * A user-signed action (withdraw3 / usdSend / usdClassTransfer / approveBuilderFee / tokenDelegate)
300
+ * the venue answered with an `err` envelope executed nothing (live testnet 2026-10-02:
301
+ * withdraw3 → {"status":"err","response":"Error withdrawing from bridge"}, ledger and balance
302
+ * unchanged). A balance / margin refusal keeps its code (INSUFFICIENT_BALANCE — callers size from
303
+ * it); any other refusal → ACTION_REJECTED (definite, non-retryable). Transport / parse failures
304
+ * (no envelope) are returned unchanged — their outcome is unknown.
305
+ */
306
+ export function hlUserActionRejection(type, e) {
307
+ if (!(e instanceof HlErrResponse))
308
+ return e;
309
+ const c = classifyError(e);
310
+ if (c.code === "INSUFFICIENT_BALANCE" || c.code === "MARGIN_INSUFFICIENT") {
311
+ return new PerpError(c.code, e.message, { exchange: "hyperliquid", action: type });
312
+ }
313
+ return new PerpError("ACTION_REJECTED", `Hyperliquid refused ${type}: ${e.message} — nothing was executed`, {
314
+ exchange: "hyperliquid", action: type, venueResponse: e.message,
315
+ remediation: "The venue refused the request and moved nothing; check the venue message above (balances: perp -e hyperliquid portfolio)",
316
+ });
317
+ }
318
+ /** L1 action types that place or amend orders (their refusal is ORDER_REJECTED; every other type → ACTION_REJECTED). */
319
+ const HL_ORDER_ACTION_TYPES = new Set(["order", "batchModify", "modify", "twapOrder"]);
320
+ /**
321
+ * An L1 action (order / cancel / updateLeverage / agentSendAsset / …) answered with a top-level
322
+ * `err` envelope was refused as a whole and executed nothing — docs for-developers/api/error-responses:
323
+ * "Some errors are a deterministic function of the payload itself, and these are instead returned
324
+ * earlier as part of pre-validation. In this case only one error is returned for the entire payload".
325
+ * A balance / margin refusal keeps its code; any other refusal → ORDER_REJECTED (order actions) or
326
+ * ACTION_REJECTED — definite and non-retryable, never the retryable EXCHANGE_ERROR. Transport /
327
+ * parse failures (no envelope) are returned unchanged.
328
+ */
329
+ export function hlL1ActionRejection(type, e) {
330
+ if (!(e instanceof HlErrResponse))
331
+ return e;
332
+ const c = classifyError(e);
333
+ if (c.code === "INSUFFICIENT_BALANCE" || c.code === "MARGIN_INSUFFICIENT") {
334
+ return new PerpError(c.code, e.message, { exchange: "hyperliquid", action: type });
335
+ }
336
+ const isOrder = HL_ORDER_ACTION_TYPES.has(type);
337
+ return new PerpError(isOrder ? "ORDER_REJECTED" : "ACTION_REJECTED", `Hyperliquid refused ${type}: ${e.message} — nothing was executed`, {
338
+ exchange: "hyperliquid", action: type, venueResponse: e.message,
339
+ remediation: "The venue refused the whole request and executed nothing; fix the cause named in the venue message above before sending it again",
340
+ });
341
+ }
4
342
  export class HyperliquidAdapter {
5
343
  name = "hyperliquid";
6
344
  chain = "evm";
7
345
  aliases = ["hl"];
8
346
  /**
9
- * Reflects whether perp + spot share the same USDC pool (true for unified /
10
- * portfolio mode) or are separately margined (false for standard mode and
11
- * HIP-3 dex accounts). Populated during init() from _getAbstractionMode();
347
+ * Reflects whether perp + spot share the same collateral pool (true for unified /
348
+ * portfolio mode — a per-user setting, HIP-3 dexes included) or are separately
349
+ * margined (false for standard / unset "default"). Populated during init() from _getAbstractionMode()
350
+ * (disk-cached for HL_ABSTRACTION_TTL_MS, so init reads the venue at most once per window);
12
351
  * defaults to false until init resolves so callers never assume unified
13
352
  * semantics for accounts that have never opted in. Codex v0.12.12 final QA #1.
14
353
  */
15
354
  isUnifiedAccount = false;
16
- sdk;
17
355
  _address;
18
356
  _privateKey;
19
357
  _testnet;
@@ -24,22 +362,23 @@ export class HyperliquidAdapter {
24
362
  _assetMap = new Map();
25
363
  _assetMapReverse = new Map();
26
364
  _szDecimalsMap = new Map(); // symbol → szDecimals
365
+ /** symbol → venue marginMode restriction (meta universe `marginMode` / `onlyIsolated`); absent = none. */
366
+ _marginModeMap = new Map();
27
367
  /** HIP-3 deployed perp dex name. Empty string = native (validator) perps. */
28
368
  _dex = "";
369
+ /** Why the last asset-map load refused to map the dex (unknown dex / asset id below the HIP-3 range). */
370
+ _assetMapError = null;
29
371
  static _lastNonce = 0;
30
372
  _marketsCache = null;
31
373
  _marketsCacheTime = 0;
32
374
  static CACHE_TTL = 30_000;
375
+ /**
376
+ * Builder-fee gate (settings builder.hyperliquid + confirmed master approval); null = not
377
+ * configured. Set by createHyperliquid — the adapter never reads settings.
378
+ */
379
+ builderFee = null;
33
380
  constructor(privateKey, testnet = false) {
34
- // Disable WebSocket in SDK — we use REST for all info calls and raw
35
- // fetch for /exchange. This avoids the false "Please install ws" warning
36
- // caused by the SDK's require('ws') failing in ESM context.
37
- this.sdk = new Hyperliquid({
38
- privateKey,
39
- testnet,
40
- walletAddress: undefined,
41
- enableWs: false,
42
- });
381
+ // No SDK: /info reads go through _infoPost, /exchange actions are signed here (hl-signing.ts).
43
382
  this._address = "";
44
383
  this._privateKey = privateKey ?? "";
45
384
  this._testnet = testnet;
@@ -54,12 +393,44 @@ export class HyperliquidAdapter {
54
393
  get dex() {
55
394
  return this._dex;
56
395
  }
57
- get client() {
58
- return this.sdk;
59
- }
60
396
  get address() {
61
397
  return this._address;
62
398
  }
399
+ /**
400
+ * Builder decision for an order on `assetIndex` (null = adapter-level, e.g. dry-run).
401
+ * HIP-3 (this adapter's dex, or a builder-deployed asset id ≥ 100000) never carries it.
402
+ */
403
+ async builderDecision(assetIndex) {
404
+ const gate = builderGateOf(this);
405
+ if (gate === null)
406
+ return NO_BUILDER;
407
+ const hip3 = this._dex !== "" || (assetIndex !== null && assetIndex >= 100_000);
408
+ return gate.decide({ hip3 });
409
+ }
410
+ /**
411
+ * HL `order` action. `builder: { b, f }` (f = tenths of a bp, builder-codes docs) is appended
412
+ * only when the gate approves it — key order type, orders, grouping, builder (msgpack hash).
413
+ */
414
+ async _orderAction(orders, grouping, assetIndex) {
415
+ const action = { type: "order", orders, grouping };
416
+ const d = await this.builderDecision(assetIndex);
417
+ if (d.applied !== null)
418
+ action.builder = { b: d.applied.identity.toLowerCase(), f: hlFeeTenthsBps(requireFeeBps(d.applied)) };
419
+ return action;
420
+ }
421
+ /**
422
+ * Master approval for `target` (info maxBuilderFee: the approved ceiling in tenths of a bp,
423
+ * 0 = none). Read for this adapter's account address (the master — agents cannot approve).
424
+ */
425
+ async builderApprovalOf(target) {
426
+ if (!this._address)
427
+ return { status: "unknown", reason: "no account address loaded" };
428
+ const max = await this._infoPost({ type: "maxBuilderFee", user: this._address, builder: target.identity });
429
+ if (typeof max !== "number" || !Number.isFinite(max) || max < 0) {
430
+ return { status: "unknown", reason: `unexpected maxBuilderFee response ${JSON.stringify(max).slice(0, 120)}` };
431
+ }
432
+ return approvalFromMax(max === 0 ? null : max / 10, requireFeeBps(target));
433
+ }
63
434
  get isTestnet() {
64
435
  return this._testnet;
65
436
  }
@@ -100,22 +471,17 @@ export class HyperliquidAdapter {
100
471
  }
101
472
  /** Resolve the active signer across three tiers. */
102
473
  _resolveSigner() {
103
- // Tier 1: agent (when registered, not expired, --no-agent NOT set)
474
+ // Tier 1: agent (when registered, --no-agent NOT set). An expired agent record is an
475
+ // explicit error — never a silent switch to the master signer; --no-agent is the opt-in.
104
476
  if (!this._useNoAgent && this._agentSigner && this._agentMeta) {
105
477
  const expiresAt = new Date(this._agentMeta.expiresAt).getTime();
106
- const expired = expiresAt < Date.now();
107
- if (expired) {
108
- // Skip Tier 1 only if Tier 2/3 available; otherwise throw AGENT_EXPIRED
109
- if (!this._evmSigner && !this._privateKey) {
110
- throw new PerpError("AGENT_EXPIRED", "Agent wallet has expired", {
111
- remediation: "perp wallet agent approve hyperliquid --rotate",
112
- });
113
- }
114
- // fall through to Tier 2/3
115
- }
116
- else {
117
- return { tier: "agent", signer: this._agentSigner };
478
+ if (expiresAt < Date.now()) {
479
+ throw new PerpError("AGENT_EXPIRED", `Hyperliquid agent wallet ${this._agentMeta.agentEvmAddress} expired at ${this._agentMeta.expiresAt}`, {
480
+ exchange: "hyperliquid",
481
+ remediation: "Re-approve the agent: perp wallet agent approve hyperliquid — or sign with the master wallet for this command: --no-agent",
482
+ });
118
483
  }
484
+ return { tier: "agent", signer: this._agentSigner };
119
485
  }
120
486
  // Tier 2: OWS master (_evmSigner set via setSigner)
121
487
  if (this._evmSigner) {
@@ -135,15 +501,6 @@ export class HyperliquidAdapter {
135
501
  });
136
502
  }
137
503
  async init() {
138
- // Suppress "WebSocket connected" noise from hyperliquid SDK
139
- const origLog = console.log;
140
- console.log = () => { };
141
- try {
142
- await this.sdk.connect();
143
- }
144
- finally {
145
- console.log = origLog;
146
- }
147
504
  // Initialize EVM signer if not externally injected (skip if no key — read-only mode)
148
505
  if (!this._evmSigner && this._privateKey) {
149
506
  this._evmSigner = await LocalEvmSigner.create(this._privateKey);
@@ -155,31 +512,41 @@ export class HyperliquidAdapter {
155
512
  // Build asset index map
156
513
  await this._loadAssetMap();
157
514
  // Resolve abstraction mode → isUnifiedAccount flag for callers
158
- // (portfolio accounting + spot-perp arb sizing). Standard/HIP-3 dex
159
- // accounts are NOT unified (perp + spot are separately margined).
515
+ // (portfolio accounting + spot-perp arb sizing). The mode is per user;
516
+ // standard / "default" accounts are NOT unified.
160
517
  // Read-only paths (no address yet) can't query userAbstraction; leave the
161
518
  // default false until a downstream caller invokes setAddress + the cache
162
519
  // populates during getBalance(). Codex v0.12.12 final QA #1.
520
+ // A failed userAbstraction read propagates: a silently-kept `false` would mis-size
521
+ // every caller that branches on the account mode.
163
522
  if (this._address) {
164
- try {
165
- const mode = await this._getAbstractionMode();
166
- this.isUnifiedAccount = mode === "unified" || mode === "portfolio";
167
- }
168
- catch {
169
- // If userAbstraction lookup fails, retain conservative default
170
- // (false). getBalance() will surface the underlying error when called.
171
- }
523
+ const mode = await this._getAbstractionMode();
524
+ // dexAbstraction is not unified (USDC stays in the perp balance).
525
+ this.isUnifiedAccount = mode === "unified" || mode === "portfolio";
172
526
  }
173
527
  }
528
+ /**
529
+ * info `meta` of this adapter's perp dex (universe: names, szDecimals, margin restrictions;
530
+ * collateralToken) — public static metadata, shared on disk by every CLI process for
531
+ * HL_MARKET_META_TTL_MS. Docs ("Rate limits and user limits"): "REST requests share an
532
+ * aggregated weight limit of 1200 per minute" and `meta` is one of "All other documented `info`
533
+ * requests [that] have weight 20" — re-reading it on every invocation (twice on a balance read)
534
+ * was most of a command's weight. Account state is never cached here.
535
+ */
536
+ _metaCached() {
537
+ return withCache(`mkt:hl:${this._network}:meta:${this._dex}`, HL_MARKET_META_TTL_MS, () => this._infoPost(this._dex ? { type: "meta", dex: this._dex } : { type: "meta" }));
538
+ }
174
539
  /** Load asset index map — supports native and HIP-3 dex. */
175
540
  async _loadAssetMap() {
541
+ this._assetMapError = null;
176
542
  try {
177
543
  if (this._dex) {
178
544
  // HIP-3 deployed dex: asset indices use global offset scheme
179
545
  // offset = 110000 + dexListIndex * 10000 (per Python SDK convention)
180
546
  const [meta, dexList] = await Promise.all([
181
- this._infoPost({ type: "meta", dex: this._dex }),
182
- this._infoPost({ type: "perpDexs" }),
547
+ this._metaCached(),
548
+ // The dex list (weight 20) is public static metadata too; cached like `meta`.
549
+ withCache(`mkt:hl:${this._network}:perpDexs`, HL_MARKET_META_TTL_MS, () => this._infoPost({ type: "perpDexs" })),
183
550
  ]);
184
551
  // Find this dex's position in the list (index 0 is native/null)
185
552
  let dexOffset = 0;
@@ -190,11 +557,17 @@ export class HyperliquidAdapter {
190
557
  }
191
558
  }
192
559
  if (dexOffset === 0) {
193
- console.error(`[hyperliquid] Dex "${this._dex}" not found in perpDexs list`);
560
+ // Fail closed: offset 0 would map every HIP-3 coin onto a NATIVE asset id.
561
+ throw new PerpError("SYMBOL_NOT_FOUND", `Hyperliquid perp dex "${this._dex}" is not in the venue's perpDexs list; no asset ids loaded`, {
562
+ exchange: "hyperliquid", remediation: "Check the dex name: perp -e hyperliquid market dexes",
563
+ });
194
564
  }
195
565
  if (meta?.universe) {
196
566
  meta.universe.forEach((asset, idx) => {
197
567
  const globalIdx = dexOffset + idx;
568
+ if (!(globalIdx >= HIP3_MIN_ASSET_ID)) {
569
+ throw new PerpError("SYMBOL_NOT_FOUND", `Hyperliquid dex "${this._dex}" asset ${asset.name} resolved to asset id ${globalIdx} (< ${HIP3_MIN_ASSET_ID}); refusing to load`, { exchange: "hyperliquid" });
570
+ }
198
571
  // Store both with and without dex prefix for flexible lookup
199
572
  // API returns "km:GOOGL" but callers may use "GOOGL" or "KM:GOOGL"
200
573
  const fullName = asset.name; // e.g., "km:GOOGL"
@@ -207,24 +580,44 @@ export class HyperliquidAdapter {
207
580
  this._szDecimalsMap.set(fullName, asset.szDecimals);
208
581
  this._szDecimalsMap.set(baseName, asset.szDecimals);
209
582
  }
583
+ const mm = hlMarginModeOf(asset);
584
+ if (mm !== null) {
585
+ this._marginModeMap.set(fullName, mm);
586
+ this._marginModeMap.set(baseName, mm);
587
+ }
210
588
  });
211
589
  }
212
590
  }
213
591
  else {
214
- const meta = await this.sdk.info.perpetuals.getMeta();
592
+ // Native perps keep the "<COIN>-PERP" display names the former SDK symbol
593
+ // conversion produced (market list, positions, orders); _venueCoin() maps back.
594
+ const meta = await this._metaCached();
215
595
  if (meta && meta.universe) {
216
596
  meta.universe.forEach((asset, idx) => {
217
- this._assetMap.set(asset.name, idx);
218
- this._assetMapReverse.set(idx, asset.name);
597
+ const sym = `${asset.name}-PERP`;
598
+ this._assetMap.set(sym, idx);
599
+ this._assetMapReverse.set(idx, sym);
219
600
  if (asset.szDecimals !== undefined) {
220
- this._szDecimalsMap.set(asset.name, asset.szDecimals);
601
+ this._szDecimalsMap.set(sym, asset.szDecimals);
221
602
  }
603
+ const mm = hlMarginModeOf(asset);
604
+ if (mm !== null)
605
+ this._marginModeMap.set(sym, mm);
222
606
  });
223
607
  }
224
608
  }
225
609
  }
226
610
  catch (e) {
227
- console.error("[hyperliquid] Failed to load asset map:", e instanceof Error ? e.message : e);
611
+ // A HIP-3 map that cannot be proven correct is never left half-loaded.
612
+ if (this._dex) {
613
+ this._assetMap.clear();
614
+ this._assetMapReverse.clear();
615
+ }
616
+ // Kept and re-thrown at the point of use (getAssetIndex, getSzDecimals): a failed metadata
617
+ // read (429, transport) must surface as itself, never as "Unknown asset" / "not loaded" for
618
+ // every symbol. Commands that need no market metadata are not failed by it and print nothing.
619
+ this._assetMapError = e instanceof PerpError ? e
620
+ : new PerpError("EXCHANGE_ERROR", `Hyperliquid market metadata could not be loaded: ${e instanceof Error ? e.message : String(e)}`, { exchange: "hyperliquid" });
228
621
  }
229
622
  }
230
623
  /**
@@ -233,57 +626,90 @@ export class HyperliquidAdapter {
233
626
  */
234
627
  resolveSymbol(symbol) {
235
628
  const sym = symbol.toUpperCase();
236
- if (this._assetMap.has(sym))
237
- return sym;
238
- if (this._assetMap.has(`${sym}-PERP`))
239
- return `${sym}-PERP`;
240
- if (sym.endsWith("-PERP") && this._assetMap.has(sym.replace(/-PERP$/, "")))
241
- return sym.replace(/-PERP$/, "");
242
629
  if (sym.includes(":")) {
630
+ // A dex-prefixed symbol must name THIS adapter's dex — never rewritten onto another dex.
243
631
  const [prefix, base] = sym.split(":");
632
+ if (prefix.toLowerCase() !== this._dex.toLowerCase()) {
633
+ throw new PerpError("SYMBOL_NOT_FOUND", `Hyperliquid symbol ${symbol} is on dex "${prefix.toLowerCase()}", not ${this._dex ? `this adapter's dex "${this._dex}"` : "the native perp dex"}`, {
634
+ exchange: "hyperliquid", remediation: `Select the dex: perp -e hyperliquid --dex ${prefix.toLowerCase()} ...`,
635
+ });
636
+ }
244
637
  const lower = `${prefix.toLowerCase()}:${base}`;
245
638
  if (this._assetMap.has(lower))
246
639
  return lower;
247
640
  if (this._assetMap.has(base))
248
641
  return base;
642
+ return this._caseInsensitive(sym) ?? sym;
643
+ }
644
+ if (this._assetMap.has(sym))
645
+ return sym;
646
+ if (this._assetMap.has(`${sym}-PERP`))
647
+ return `${sym}-PERP`;
648
+ if (sym.endsWith("-PERP") && this._assetMap.has(sym.replace(/-PERP$/, "")))
649
+ return sym.replace(/-PERP$/, "");
650
+ // Coins whose venue name is not upper-case (kPEPE, kSHIB, …): match the map case-insensitively.
651
+ return this._caseInsensitive(sym) ?? this._caseInsensitive(`${sym}-PERP`) ?? this._caseInsensitive(sym.replace(/-PERP$/, "")) ?? sym; // unknown → as-is, downstream SYMBOL_NOT_FOUND
652
+ }
653
+ /** The asset-map key equal to `upper` ignoring case, when exactly one exists (kPEPE-PERP for KPEPE-PERP). */
654
+ _caseInsensitive(upper) {
655
+ let hit = null;
656
+ for (const k of this._assetMap.keys()) {
657
+ if (k.toUpperCase() !== upper)
658
+ continue;
659
+ if (hit !== null && hit !== k)
660
+ return null;
661
+ hit = k;
249
662
  }
250
- return sym; // return as-is, let downstream error
663
+ return hit;
251
664
  }
252
- /** Get szDecimals for a symbol (cached from init/getMeta) */
665
+ /**
666
+ * szDecimals for a symbol (cached from init/getMeta). Rule #2: never assumed —
667
+ * meta not loaded → PRICE_STALE (retryable); loaded but no entry → SYMBOL_NOT_FOUND.
668
+ */
253
669
  getSzDecimals(symbol) {
254
670
  const resolved = this.resolveSymbol(symbol.toUpperCase());
255
671
  const dec = this._szDecimalsMap.get(resolved);
256
672
  if (dec !== undefined)
257
673
  return dec;
258
- // Retry asset map load if empty
674
+ if (this._assetMapError)
675
+ throw this._assetMapError;
259
676
  if (this._szDecimalsMap.size === 0) {
260
- console.error(`[hyperliquid] szDecimals not loaded for ${symbol}, using fallback 2`);
677
+ throw new PerpError("PRICE_STALE", `Hyperliquid market metadata (szDecimals) not loaded for ${symbol.toUpperCase()}`, {
678
+ exchange: "hyperliquid",
679
+ remediation: "Retry shortly — the meta request may have failed",
680
+ });
261
681
  }
262
- return 2;
682
+ throw new PerpError("SYMBOL_NOT_FOUND", `Hyperliquid ${symbol.toUpperCase()}: no szDecimals in market metadata`, {
683
+ exchange: "hyperliquid",
684
+ remediation: "Check the symbol: perp market list -e hyperliquid",
685
+ });
263
686
  }
264
- /** HL official price rounding: 5 significant figures */
265
- _hlRoundPrice(px, szDecimals, maxDecimals = 6) {
266
- if (px > 100_000)
267
- return String(Math.round(px));
268
- const sig5 = Number(px.toPrecision(5));
269
- const priceDec = Math.max(0, maxDecimals - szDecimals);
270
- return Number(sig5.toFixed(priceDec)).toString();
687
+ /** Lot step of a market from the loaded `meta` szDecimals (precision.lotStepOf) — no metaAndAssetCtxs / allMids read per order. */
688
+ lotStepOf(symbol) {
689
+ return hlSizeStep(this.getSzDecimals(symbol));
271
690
  }
272
691
  async getAssetIndex(symbol) {
273
692
  // Retry once if asset map is empty (e.g. init failed silently)
274
693
  if (this._assetMap.size === 0) {
275
694
  await this._loadAssetMap();
276
695
  }
696
+ if (this._assetMapError)
697
+ throw this._assetMapError;
277
698
  const resolved = this.resolveSymbol(symbol);
278
699
  const idx = this._assetMap.get(resolved);
279
- if (idx !== undefined)
700
+ if (idx !== undefined) {
701
+ if (this._dex && !(idx >= HIP3_MIN_ASSET_ID)) {
702
+ throw new PerpError("SYMBOL_NOT_FOUND", `Hyperliquid dex "${this._dex}" asset ${symbol} has asset id ${idx} (< ${HIP3_MIN_ASSET_ID}); refusing to trade a native asset id`, { exchange: "hyperliquid" });
703
+ }
280
704
  return idx;
281
- throw new Error(`Unknown asset: ${symbol}`);
705
+ }
706
+ throw new PerpError("SYMBOL_NOT_FOUND", `Unknown asset: ${symbol}`, { exchange: "hyperliquid" });
282
707
  }
283
708
  async getMarkets() {
284
709
  if (this._marketsCache && Date.now() - this._marketsCacheTime < HyperliquidAdapter.CACHE_TTL)
285
710
  return this._marketsCache;
286
711
  let universe;
712
+ let marginTables;
287
713
  let ctxs;
288
714
  let mids = {};
289
715
  if (this._dex) {
@@ -291,15 +717,42 @@ export class HyperliquidAdapter {
291
717
  const meta = await this._infoPost({ type: "metaAndAssetCtxs", dex: this._dex });
292
718
  universe = meta[0]?.universe ?? [];
293
719
  ctxs = meta[1] ?? [];
720
+ marginTables = meta[0]?.marginTables;
294
721
  }
295
722
  else {
296
723
  const [meta, allMids] = await Promise.all([
297
- this.sdk.info.perpetuals.getMetaAndAssetCtxs(),
298
- this.sdk.info.getAllMids(),
724
+ this._infoPost({ type: "metaAndAssetCtxs" }),
725
+ this._infoPost({ type: "allMids" }),
299
726
  ]);
300
- universe = meta[0]?.universe ?? [];
727
+ // Native perps: "<COIN>-PERP" display names (see _loadAssetMap); allMids is keyed by the raw coin.
728
+ universe = (meta[0]?.universe ?? []).map(a => ({ ...a, name: `${String(a.name)}-PERP` }));
301
729
  ctxs = meta[1] ?? [];
302
- mids = allMids;
730
+ marginTables = meta[0]?.marginTables;
731
+ for (const [coin, px] of Object.entries(allMids ?? {}))
732
+ mids[`${coin}-PERP`] = px;
733
+ }
734
+ // `marginTables` rides along in the SAME metaAndAssetCtxs response we
735
+ // already fetch — zero extra network calls. Shape: [[tableId, {description,
736
+ // marginTiers: [{lowerBound, maxLeverage}]}], ...]. `universe[i].maxLeverage`
737
+ // is only the TOP tier: 36 of 233 symbols step down above a notional
738
+ // threshold (BTC 40x, then 20x above $150M), so a pre-trade check that reads
739
+ // the flat value alone waves through orders the venue rejects.
740
+ const tierMap = new Map();
741
+ if (Array.isArray(marginTables)) {
742
+ for (const entry of marginTables) {
743
+ if (!Array.isArray(entry) || entry.length < 2)
744
+ continue;
745
+ const id = Number(entry[0]);
746
+ const tiers = entry[1]?.marginTiers;
747
+ if (!Number.isFinite(id) || !Array.isArray(tiers))
748
+ continue;
749
+ const parsed = tiers
750
+ .map((t) => ({ lowerBound: Number(t.lowerBound), maxLeverage: Number(t.maxLeverage) }))
751
+ .filter((t) => Number.isFinite(t.lowerBound) && Number.isFinite(t.maxLeverage) && t.maxLeverage > 0)
752
+ .sort((a, b) => a.lowerBound - b.lowerBound);
753
+ if (parsed.length > 0)
754
+ tierMap.set(id, parsed);
755
+ }
303
756
  }
304
757
  // Rebuild asset map if empty (fallback — normally populated by init/_loadAssetMap)
305
758
  if (this._assetMap.size === 0 && !this._dex) {
@@ -312,16 +765,21 @@ export class HyperliquidAdapter {
312
765
  const result = universe.map((asset, i) => {
313
766
  const ctx = (ctxs[i] ?? {});
314
767
  const sym = String(asset.name);
315
- const szDec = this._szDecimalsMap.get(sym) ?? (asset.szDecimals !== undefined ? Number(asset.szDecimals) : undefined);
768
+ const szDec = this._szDecimalsMap.get(sym) ?? (asset.szDecimals !== undefined ? Number(asset.szDecimals) : null);
316
769
  return {
317
770
  symbol: sym,
318
771
  markPrice: String(ctx.markPx ?? mids[sym] ?? "0"),
319
- indexPrice: String(ctx.oraclePx ?? "0"),
772
+ indexPrice: ctx.oraclePx != null ? String(ctx.oraclePx) : null,
320
773
  fundingRate: ctx.funding != null ? String(ctx.funding) : null,
321
- volume24h: String(ctx.dayNtlVlm ?? "0"),
322
- openInterest: String(ctx.openInterest ?? "0"),
774
+ volume24h: ctx.dayNtlVlm != null ? String(ctx.dayNtlVlm) : null,
775
+ openInterest: ctx.openInterest != null ? String(ctx.openInterest) : null,
323
776
  maxLeverage: Number(asset.maxLeverage ?? 50),
324
777
  sizeDecimals: szDec,
778
+ // HL has no separate lot-size field; the step is implied by szDecimals.
779
+ stepSize: null,
780
+ // HL funding is uniform (venue definition), not per-market.
781
+ fundingHours: null,
782
+ marginTiers: tierMap.get(Number(asset.marginTableId)) ?? null,
325
783
  };
326
784
  });
327
785
  this._marketsCache = result;
@@ -331,20 +789,16 @@ export class HyperliquidAdapter {
331
789
  async getOrderbook(symbol) {
332
790
  let levels;
333
791
  if (this._dex) {
334
- // HIP-3 dex: SDK's getL2Book() doesn't support dex parameter
335
- // Resolve symbol to full dex-prefixed name (e.g. "US500" → "km:US500")
336
- const resolved = this.resolveSymbol(symbol.toUpperCase());
337
- // Ensure dex prefix is present (API requires it)
338
- const coin = resolved.includes(":") ? resolved : `${this._dex}:${resolved}`;
792
+ // HIP-3 dex: full dex-prefixed coin (e.g. "US500" → "km:US500") + dex param
339
793
  const book = await this._infoPost({
340
794
  type: "l2Book",
341
- coin,
795
+ coin: this._venueCoin(symbol),
342
796
  dex: this._dex,
343
797
  });
344
798
  levels = book?.levels ?? [[], []];
345
799
  }
346
800
  else {
347
- const book = await this.sdk.info.getL2Book(symbol.toUpperCase());
801
+ const book = await this._infoPost({ type: "l2Book", coin: this._venueCoin(symbol) });
348
802
  levels = book?.levels ?? [[], []];
349
803
  }
350
804
  return {
@@ -358,47 +812,66 @@ export class HyperliquidAdapter {
358
812
  ]),
359
813
  };
360
814
  }
815
+ /**
816
+ * Venue coin for a symbol: native perps drop the "-PERP" display suffix ("BTC-PERP" →
817
+ * "BTC"); HIP-3 coins carry the dex prefix ("US500" → "km:US500").
818
+ */
819
+ _venueCoin(symbol) {
820
+ const resolved = this.resolveSymbol(symbol.toUpperCase());
821
+ if (this._dex)
822
+ return resolved.includes(":") ? resolved : `${this._dex}:${resolved}`;
823
+ return resolved.replace(/-PERP$/, "");
824
+ }
825
+ /** Display symbol for a venue coin: a native perp coin "BTC" → "BTC-PERP"; anything else unchanged. */
826
+ _displaySymbol(coin) {
827
+ return !this._dex && this._assetMap.has(`${coin}-PERP`) ? `${coin}-PERP` : coin;
828
+ }
361
829
  ensureAddress() {
362
830
  if (!this._address) {
363
831
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
364
832
  }
365
833
  }
834
+ /** The in-flight clearinghouseState read: parallel callers (getBalance + getPositions) share one request. */
835
+ _chsInFlight = null;
366
836
  /** Read-through cached clearinghouseState — returns cached data if fresh */
367
837
  async _getClearinghouseState() {
368
838
  this.ensureAddress();
369
- const { withCache, TTL_ACCOUNT } = await import("../cache.js");
370
- const key = this._dex ? `acct:hl:chs:${this._address}:${this._dex}` : `acct:hl:chs:${this._address}`;
371
- return withCache(key, TTL_ACCOUNT, async () => {
839
+ if (this._chsInFlight !== null)
840
+ return this._chsInFlight;
841
+ const key = this._dex ? `acct:hl:${this._network}:chs:${this._address}:${this._dex}` : `acct:hl:${this._network}:chs:${this._address}`;
842
+ const p = withCache(key, TTL_ACCOUNT, async () => {
372
843
  const state = this._dex
373
844
  ? await this._infoPost({ type: "clearinghouseState", user: this._address, dex: this._dex })
374
- : await this.sdk.info.perpetuals.getClearinghouseState(this._address);
845
+ : await this._infoPost({ type: "clearinghouseState", user: this._address });
375
846
  return state;
376
847
  });
848
+ this._chsInFlight = p;
849
+ const clear = () => { if (this._chsInFlight === p)
850
+ this._chsInFlight = null; };
851
+ p.then(clear, clear);
852
+ return p;
377
853
  }
378
854
  /**
379
855
  * Read the user's account abstraction mode from HL.
380
856
  *
381
- * Returns the canonical mode used to branch collateral accounting:
382
- * - "unified" → spot clearinghouseState is the truth source (USDC total
383
- * is unified across spot + perp). app.hyperliquid.xyz default.
384
- * - "standard" → perp clearinghouseState is the truth source. Required
385
- * mode for builder fee accrual.
386
- * - "portfolio" → multi-asset cross-collateral; for now treated like
387
- * unified (spot truth). Refine when portfolio margin GA.
857
+ * Abstraction is a property of the USER (not of a dex): it is always queried,
858
+ * HIP-3 dex adapters included.
859
+ * - "unified" → spot clearinghouseState is the truth source (one balance per
860
+ * collateral token, shared by spot and every perp dex).
861
+ * - "portfolio" → multi-asset cross-collateral; balances also live in spot state.
862
+ * - "standard" → perp clearinghouseState is the truth source (venue "disabled").
863
+ * - "default" → the user never set a mode. Not assumed to be either: balances
864
+ * are read from the perp state and getBalance() warns when that
865
+ * reads 0 while spot holds the collateral token.
388
866
  *
389
- * Per SSOT Rule #2: an unrecognized venue response throws — no silent
390
- * default. HIP-3 dex accounts (`this._dex` set) bypass the lookup since
391
- * cross margin scopes per-DEX in standard semantics.
867
+ * Per SSOT Rule #2: an unrecognized venue response throws — no silent default.
392
868
  *
393
- * Endpoint: `POST /info {"type":"userAbstraction","user":"0x..."}` →
394
- * returns one of `"unifiedAccount" | "disabled" | "portfolioMargin"`.
869
+ * Endpoint: `POST /info {"type":"userAbstraction","user":"0x..."}`.
395
870
  */
396
- async _getAbstractionMode() {
397
- if (this._dex)
398
- return "standard";
871
+ async _getAbstractionMode(opts = {}) {
399
872
  this.ensureAddress();
400
- const { withCache, TTL_ACCOUNT } = await import("../cache.js");
401
- return withCache(`acct:hl:abstraction:${this._address}`, TTL_ACCOUNT, async () => {
873
+ const key = `${hlAbstractionCachePrefix(this._network)}${this._address.toLowerCase()}`;
874
+ return (opts.fresh === true ? fetchAndCache : withCache)(key, HL_ABSTRACTION_TTL_MS, async () => {
402
875
  const raw = (await this._infoPost({ type: "userAbstraction", user: this._address }));
403
876
  if (raw === "unifiedAccount")
404
877
  return "unified";
@@ -406,89 +879,129 @@ export class HyperliquidAdapter {
406
879
  return "portfolio";
407
880
  if (raw === "disabled")
408
881
  return "standard";
409
- // "default" is the implicit pre-set value HL returns when a user has
410
- // never invoked `userSetAbstraction`. Per HL docs the four modes are
411
- // Standard / Unified / Portfolio / (legacy) DEX abstraction. The set
412
- // API only accepts the latter three; Standard is the de-facto baseline
413
- // for unset accounts. Map "default" → "standard" so accounting branches
414
- // pick the perp clearinghouse path. Users can opt into unified explicitly
415
- // via `wallet manage account-mode unified`.
416
882
  if (raw === "default")
417
- return "standard";
418
- // "dexAbstraction" is HL's legacy fourth mode, slated for deprecation
419
- // but still returned by the venue for some users. Per HL docs the spot
420
- // pool acts as collateral (same accounting semantics as unified), so
421
- // route through the unified branch rather than throwing.
883
+ return "default";
884
+ // "dexAbstraction" is HL's discontinued fourth mode, still returned for some users. It is
885
+ // NOT unified: "USDC balances default to perps balance, all other collateral defaults to
886
+ // spot balance" (docs trading/account-abstraction-modes). Handled explicitly in getBalance().
422
887
  if (raw === "dexAbstraction")
423
- return "unified";
888
+ return "dexAbstraction";
424
889
  throw new PerpError("INVALID_PARAMS", `UNKNOWN_ACCOUNT_MODE: HL returned unrecognized userAbstraction mode: ${JSON.stringify(raw)}`, { remediation: "Check app.hyperliquid.xyz Settings → Account Mode" });
425
890
  });
426
891
  }
427
- /** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
428
- async _getSpotClearinghouseState() {
892
+ /** Collateral token index of this adapter's perp dex (info `meta{dex}.collateralToken`; 0 = USDC on native perps). */
893
+ _collateralTokenIdx = null;
894
+ async _getCollateralToken() {
895
+ if (this._collateralTokenIdx !== null)
896
+ return this._collateralTokenIdx;
897
+ const meta = await this._metaCached();
898
+ const idx = meta?.collateralToken;
899
+ if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0) {
900
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid meta${this._dex ? `{dex:${this._dex}}` : ""} has no collateralToken (got ${JSON.stringify(idx)})`, {
901
+ exchange: "hyperliquid",
902
+ });
903
+ }
904
+ this._collateralTokenIdx = idx;
905
+ return idx;
906
+ }
907
+ /** Cached spot clearinghouse state (raw venue shape) — shared between getBalance() and HyperliquidSpotAdapter */
908
+ async _getSpotClearinghouseState(opts) {
429
909
  this.ensureAddress();
430
- const { withCache, TTL_ACCOUNT } = await import("../cache.js");
431
- return withCache(`acct:hl:spot_chs:${this._address}`, TTL_ACCOUNT, async () => {
432
- return await this.sdk.info.spot.getSpotClearinghouseState(this._address);
433
- });
910
+ const { withCache, fetchAndCache, TTL_ACCOUNT } = await import("../cache.js");
911
+ const fetcher = async () => await this._infoPost({ type: "spotClearinghouseState", user: this._address });
912
+ const key = `acct:hl:${this._network}:spot_chs:${this._address}`;
913
+ // fresh: bypass the shared TTL cache (fill measurement / post-trade verification).
914
+ return opts?.fresh === true ? fetchAndCache(key, TTL_ACCOUNT, fetcher) : withCache(key, TTL_ACCOUNT, fetcher);
915
+ }
916
+ /** Spot balance row of this dex's collateral token (matched by token index, not by coin name); null = none held. */
917
+ async _spotCollateralRow() {
918
+ const [spotState, token] = await Promise.all([this._getSpotClearinghouseState(), this._getCollateralToken()]);
919
+ const balances = (spotState?.balances ?? []);
920
+ const row = balances.find((b) => Number(b.token) === token) ?? null;
921
+ return { row, balances, token };
434
922
  }
435
923
  async getBalance() {
436
924
  const state = await this._getClearinghouseState();
437
925
  const s = state;
438
926
  const margin = (s?.marginSummary ?? {});
439
927
  const cross = (s?.crossMarginSummary ?? {});
440
- const marginUsed = Number(margin.totalMarginUsed ?? cross.totalMarginUsed ?? 0);
928
+ const marginUsed = parseFiniteVenueNumber(margin.totalMarginUsed ?? cross.totalMarginUsed, "marginSummary.totalMarginUsed", "hyperliquid");
441
929
  // Sum unrealized PnL directly from positions (reliable for both main + dex accounts)
442
930
  const positions = (s?.assetPositions ?? []);
443
931
  let unrealizedPnl = 0;
444
932
  for (const entry of positions) {
445
933
  const pos = (entry.position ?? entry);
446
- unrealizedPnl += Number(pos.unrealizedPnl ?? 0);
934
+ unrealizedPnl += parseFiniteVenueNumber(pos.unrealizedPnl, "position.unrealizedPnl", "hyperliquid");
447
935
  }
448
936
  let equity;
449
937
  let available;
450
- // Branch by user's account abstraction mode (Rule #2: no silent fallback).
451
- // - "unified" / "portfolio": spot USDC total = true equity; perp margin
452
- // is held against the same USDC pool, so adding both double-counts.
453
- // - "standard": separate perp/spot accounting; perp clearinghouse is the
454
- // only collateral source. Required mode for builder fee accrual.
455
- // HIP-3 dex accounts (this._dex) always use standard semantics.
938
+ // Branch by the user's account abstraction mode (Rule #2: no silent fallback).
939
+ // - "unified" / "portfolio": per HL docs, API users see all balances and holds
940
+ // in the spot clearinghouse state; the dex's collateral token row (by token
941
+ // index from meta{dex}.collateralToken) is the equity. Adding perp margin
942
+ // would double-count.
943
+ // - "standard" / "default": perp clearinghouse (per dex) is the collateral source.
456
944
  //
457
- // Portfolio-margin mode: per HL portfolio-margin docs, eligible collateral
458
- // includes HYPE, BTC, USDH, USDC. We currently use USDC-only as a minimum
459
- // viable baseline (mark-priced multi-asset accounting requires fetching
460
- // spot mark prices and is deferred to a follow-up). Emit a stderr note so
461
- // users see the breakdown when non-USDC collateral is present.
945
+ // Portfolio-margin mode: eligible collateral includes HYPE, BTC, USDT besides
946
+ // USDC. Only the collateral token is counted; a stderr note lists the rest.
462
947
  const mode = await this._getAbstractionMode();
463
- if (mode === "unified" || mode === "portfolio") {
464
- const spotState = await this._getSpotClearinghouseState();
465
- const balances = (spotState?.balances ?? []);
466
- const usdc = balances.find((b) => String(b.coin).startsWith("USDC"));
467
- const spotTotal = Number(usdc?.total ?? 0);
468
- const spotHold = Number(usdc?.hold ?? 0);
948
+ // dexAbstraction (discontinued): USDC (token 0) lives in the NATIVE perp balance, any other
949
+ // collateral token in spot. Native perps → the perp state below. A HIP-3 dex with non-USDC
950
+ // collateral → its spot row. A USDC-collateral HIP-3 dex is refused: its dex state is not the
951
+ // balance ("Cross margin on HIP-3 DEXs does not behave intuitively for DEX abstraction users").
952
+ let spotIsTruth = mode === "unified" || mode === "portfolio";
953
+ if (mode === "dexAbstraction" && this._dex) {
954
+ if (await this._getCollateralToken() === 0) {
955
+ throw new PerpError("NOT_IMPLEMENTED", `Hyperliquid account mode "dexAbstraction" (discontinued by the venue): the USDC balance of HIP-3 dex "${this._dex}" cannot be read reliably`, {
956
+ exchange: "hyperliquid",
957
+ remediation: "Switch the account mode first: perp -e hyperliquid wallet manage account-mode unified (or standard)",
958
+ });
959
+ }
960
+ spotIsTruth = true;
961
+ }
962
+ if (spotIsTruth) {
963
+ const { row, balances, token } = await this._spotCollateralRow();
964
+ const coin = row === null ? `token#${token}` : String(row.coin);
965
+ const spotTotal = parseFiniteVenueNumber(row?.total, `spotBalance.${coin}.total`, "hyperliquid");
966
+ const spotHold = parseFiniteVenueNumber(row?.hold, `spotBalance.${coin}.hold`, "hyperliquid");
469
967
  equity = spotTotal;
470
968
  available = spotTotal - spotHold;
471
969
  if (mode === "portfolio") {
472
- const PORTFOLIO_COLLATERAL = ["HYPE", "BTC", "USDH"];
473
- const nonUsdc = balances.filter((b) => {
474
- const coin = String(b.coin);
475
- if (coin.startsWith("USDC"))
970
+ // Eligible collateral per trading/account-abstraction-modes.md ("eligible
971
+ // assets, which are currently HYPE, BTC, USDC, USDT").
972
+ const PORTFOLIO_COLLATERAL = ["HYPE", "BTC", "USDT"];
973
+ const others = balances.filter((b) => {
974
+ if (Number(b.token) === token)
476
975
  return false;
477
- if (Number(b.total ?? 0) <= 0)
976
+ const c = String(b.coin);
977
+ // Reject NaN/empty here too — venue payload corruption shouldn't
978
+ // silently filter a token in or out.
979
+ const total = parseFiniteVenueNumber(b.total, `spotBalance.${c}.total`, "hyperliquid");
980
+ if (total <= 0)
478
981
  return false;
479
- return PORTFOLIO_COLLATERAL.some((c) => coin === c || coin.startsWith(`${c}-`));
982
+ return PORTFOLIO_COLLATERAL.some((p) => c === p || c.startsWith(`${p}-`));
480
983
  });
481
- if (nonUsdc.length > 0) {
482
- const summary = nonUsdc.map((b) => `${b.coin}=${b.total}`).join(", ");
483
- process.stderr.write(`[hl] portfolio mode: non-USDC collateral present but not counted in equity (${summary}). ` +
484
- `Equity reflects USDC only; full multi-asset accounting pending.\n`);
984
+ if (others.length > 0) {
985
+ const summary = others.map((b) => `${b.coin}=${b.total}`).join(", ");
986
+ process.stderr.write(`[hl] portfolio mode: non-${coin} collateral present but not counted in equity (${summary}). ` +
987
+ `Equity reflects ${coin} only; full multi-asset accounting pending.\n`);
485
988
  }
486
989
  }
487
990
  }
488
991
  else {
489
- // mode === "standard"
490
- equity = Number(margin.accountValue ?? cross.accountValue ?? 0);
491
- available = Number(s?.withdrawable ?? 0);
992
+ equity = parseFiniteVenueNumber(margin.accountValue ?? cross.accountValue, "marginSummary.accountValue", "hyperliquid");
993
+ available = parseFiniteVenueNumber(s?.withdrawable, "withdrawable", "hyperliquid");
994
+ if (mode === "default" && equity === 0) {
995
+ // Unset mode: do not assume unified. If the perp side reads 0 while spot holds
996
+ // the collateral token, say so instead of reporting an empty account silently.
997
+ const { row, token } = await this._spotCollateralRow();
998
+ const spotTotal = parseFiniteVenueNumber(row?.total, `spotBalance.token#${token}.total`, "hyperliquid");
999
+ if (spotTotal > 0) {
1000
+ process.stderr.write(`[hl] account mode is unset ("default"): perp equity${this._dex ? ` on dex ${this._dex}` : ""} is 0 while spot holds ${spotTotal} ${String(row?.coin)}. ` +
1001
+ `That spot balance is not perp collateral here. Either switch the account to unified ` +
1002
+ `(perp wallet manage account-mode unified) or move it: perp -e hyperliquid trade spot-transfer to-perp <amount>\n`);
1003
+ }
1004
+ }
492
1005
  }
493
1006
  return {
494
1007
  equity: String(equity),
@@ -503,56 +1016,158 @@ export class HyperliquidAdapter {
503
1016
  return positions
504
1017
  .filter((p) => {
505
1018
  const pos = (p.position ?? p);
506
- return Number(pos.szi ?? 0) !== 0;
1019
+ return parseFiniteVenueNumber(pos.szi, "position.szi", "hyperliquid") !== 0;
507
1020
  })
508
1021
  .map((p) => {
509
1022
  const pos = (p.position ?? p);
510
- const szi = Number(pos.szi ?? 0);
1023
+ const szi = parseFiniteVenueNumber(pos.szi, "position.szi", "hyperliquid");
1024
+ const positionValue = pos.positionValue !== undefined && pos.positionValue !== null
1025
+ ? parseFiniteVenueNumber(pos.positionValue, "position.positionValue", "hyperliquid")
1026
+ : 0;
511
1027
  return {
512
- symbol: String(pos.coin ?? ""),
1028
+ symbol: this._displaySymbol(String(pos.coin ?? "")),
513
1029
  side: szi > 0 ? "long" : "short",
514
1030
  size: String(Math.abs(szi)),
515
1031
  entryPrice: String(pos.entryPx ?? "0"),
516
- markPrice: String(szi !== 0 && pos.positionValue && Number(pos.positionValue) !== 0
517
- ? Number(pos.positionValue) / Math.abs(szi)
518
- : pos.markPx ?? "0"),
519
- liquidationPrice: String(pos.liquidationPx ?? "N/A"),
1032
+ // clearinghouseState has no per-position mark: positionValue / |szi| is computed, so it is
1033
+ // formatted (no "2727.2000000000003").
1034
+ markPrice: szi !== 0 && positionValue !== 0
1035
+ ? formatComputedPrice(positionValue / Math.abs(szi))
1036
+ : String(pos.markPx ?? "0"),
1037
+ liquidationPrice: pos.liquidationPx != null ? String(pos.liquidationPx) : null,
520
1038
  unrealizedPnl: String(pos.unrealizedPnl ?? "0"),
521
- leverage: Number(pos.leverage?.value ?? 1),
1039
+ leverage: parseFiniteVenueNumber(pos.leverage?.value, "position.leverage.value", "hyperliquid", { defaultValue: 1 }),
522
1040
  };
523
1041
  });
524
1042
  }
1043
+ /**
1044
+ * The leverage this account applies to `symbol`, position or not — info `activeAssetData`
1045
+ * {user, coin} → `"leverage": { "type": "cross", "value": 3 }` (docs info-endpoint/perpetuals).
1046
+ * Unreadable → EXCHANGE_ERROR.
1047
+ */
1048
+ async marketLeverage(symbol) {
1049
+ this.ensureAddress();
1050
+ const coin = this._venueCoin(symbol);
1051
+ const r = await this._infoPost({ type: "activeAssetData", user: this._address, coin });
1052
+ const v = r?.leverage?.value;
1053
+ if (typeof v !== "number" || !(v >= 1)) {
1054
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid activeAssetData ${coin}: unreadable leverage ${JSON.stringify(r?.leverage ?? null)}`, { exchange: "hyperliquid" });
1055
+ }
1056
+ return v;
1057
+ }
1058
+ /**
1059
+ * Per-market leverage / margin mode:
1060
+ * - every market with a position: the `leverage` object of its `assetPositions` entry of info
1061
+ * `clearinghouseState` (docs info-endpoint/perpetuals: `"leverage": { "rawUsd": "-95.059824",
1062
+ * "type": "isolated", "value": 20 }`);
1063
+ * - markets WITHOUT a position the account traded: the HL_SETTINGS_FILL_MARKETS most recently
1064
+ * traded perp markets of this adapter's dex in info `userFills` ("at most 2000 most recent
1065
+ * fills"; spot "@<i>" / "BASE/QUOTE", HIP-4 "#<enc>", other dexes and delisted coins are not
1066
+ * markets of this adapter and are skipped), each read with info `activeAssetData` {user, coin}
1067
+ * → `"leverage": { "type": "cross", "value": 3 }` (docs info-endpoint/perpetuals; live
1068
+ * 2026-10-13 `{"type":"cross","value":20}` for an account that never set ETH).
1069
+ * Weight (docs "Rate limits and user limits"): clearinghouseState 2 + userFills 20 (+1 per 20
1070
+ * fills) + activeAssetData 20 per market — bounded by HL_SETTINGS_FILL_MARKETS.
1071
+ * An unreadable leverage → EXCHANGE_ERROR.
1072
+ */
1073
+ async getMarketSettings() {
1074
+ const state = await this._getClearinghouseState();
1075
+ if (!Array.isArray(state.assetPositions)) {
1076
+ throw new PerpError("EXCHANGE_ERROR", "Hyperliquid clearinghouseState has no assetPositions list — per-market settings unknown", { exchange: "hyperliquid" });
1077
+ }
1078
+ const leverageOf = (lev, what) => {
1079
+ const l = lev;
1080
+ if ((l?.type !== "cross" && l?.type !== "isolated") || typeof l.value !== "number" || !(l.value > 0)) {
1081
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid ${what}: unreadable leverage ${JSON.stringify(lev ?? null)}`, { exchange: "hyperliquid" });
1082
+ }
1083
+ return { leverage: l.value, marginMode: l.type };
1084
+ };
1085
+ const rows = state.assetPositions.map((p) => {
1086
+ const pos = (p.position ?? p);
1087
+ return { coin: String(pos.coin), ...leverageOf(pos.leverage, `position ${String(pos.coin)}`) };
1088
+ });
1089
+ const held = new Set(rows.map(r => r.coin));
1090
+ const fills = await this._infoPost({ type: "userFills", user: this._address });
1091
+ if (!Array.isArray(fills)) {
1092
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid userFills returned no list (got ${JSON.stringify(fills).slice(0, 160)})`, { exchange: "hyperliquid" });
1093
+ }
1094
+ const listed = (coin) => this._dex ? coin.startsWith(`${this._dex}:`) && this._assetMap.has(coin) : this._assetMap.has(`${coin}-PERP`);
1095
+ const traded = [];
1096
+ for (const f of [...fills].sort((a, b) => Number(b.time) - Number(a.time))) {
1097
+ const coin = String(f.coin);
1098
+ if (traded.length >= HL_SETTINGS_FILL_MARKETS)
1099
+ break;
1100
+ if (!held.has(coin) && !traded.includes(coin) && listed(coin))
1101
+ traded.push(coin);
1102
+ }
1103
+ for (const coin of traded) {
1104
+ const r = await this._infoPost({ type: "activeAssetData", user: this._address, coin });
1105
+ rows.push({ coin, ...leverageOf(r?.leverage, `activeAssetData ${coin}`) });
1106
+ }
1107
+ return rows.map(r => ({ symbol: this._displaySymbol(r.coin), leverage: r.leverage, marginMode: r.marginMode }));
1108
+ }
1109
+ /**
1110
+ * Open orders from info `frontendOpenOrders` (native perps, or the HIP-3 dex): unlike
1111
+ * `openOrders` it labels trigger orders (isTrigger, orderType "Stop Market" / "Take Profit
1112
+ * Limit" …, triggerPx), so stops / TP / SL are listed as such and cancel-all / flatten cancel
1113
+ * them. `type` = the venue orderType for a trigger order, else "limit".
1114
+ */
525
1115
  async getOpenOrders() {
526
1116
  this.ensureAddress();
527
- if (this._dex) {
528
- // HIP-3 dex: use frontendOpenOrders with dex parameter
529
- const orders = await this._infoPost({
530
- type: "frontendOpenOrders", user: this._address, dex: this._dex,
1117
+ const req = { type: "frontendOpenOrders", user: this._address };
1118
+ if (this._dex)
1119
+ req.dex = this._dex;
1120
+ const orders = await this._infoPost(req);
1121
+ // Perp orders only. Without a dex, frontendOpenOrders also lists the account's SPOT orders
1122
+ // (coin "@<index>" or "<BASE>/<QUOTE>"): they belong to the spot commands — a perp
1123
+ // cancel-all / flatten must neither cancel nor be failed by a resting spot limit.
1124
+ if (!Array.isArray(orders)) {
1125
+ throw new PerpError("EXCHANGE_ERROR", "Hyperliquid frontendOpenOrders returned no array", { exchange: "hyperliquid", remediation: "Retry: perp -e hyperliquid account orders" });
1126
+ }
1127
+ // Rule #2: a row without its oid / limitPx / sz is unreadable — never listed as id "" or price/size "0".
1128
+ const field = (o, k) => {
1129
+ const v = o[k];
1130
+ if ((typeof v === "number" && Number.isFinite(v)) || (typeof v === "string" && v.trim() !== ""))
1131
+ return String(v);
1132
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid frontendOpenOrders row without ${k}: ${JSON.stringify(o).slice(0, 200)}`, {
1133
+ exchange: "hyperliquid", remediation: "Retry: perp -e hyperliquid account orders",
531
1134
  });
532
- return (orders ?? []).map((o) => ({
533
- orderId: String(o.oid ?? ""),
534
- symbol: String(o.coin ?? ""),
535
- side: String(o.side) === "B" ? "buy" : String(o.side) === "A" ? "sell" : String(o.side).toLowerCase(),
536
- price: String(o.limitPx ?? "0"),
537
- size: String(o.sz ?? "0"),
538
- filled: "0",
539
- status: "open",
540
- type: "limit",
541
- }));
542
- }
543
- const orders = await this.sdk.info.getUserOpenOrders(this._address);
544
- return (orders ?? []).map((o) => ({
545
- orderId: String(o.oid ?? ""),
546
- symbol: String(o.coin ?? ""),
547
- // SDK convertSymbolsInObject already converts "B"→"buy", "A"→"sell"
548
- side: String(o.side) === "B" ? "buy" : String(o.side) === "A" ? "sell" : o.side,
549
- price: String(o.limitPx ?? "0"),
550
- size: String(o.sz ?? "0"),
1135
+ };
1136
+ return orders.filter((o) => !hlIsSpotCoin(String(o.coin ?? ""))).map((o) => ({
1137
+ orderId: field(o, "oid"),
1138
+ symbol: this._dex ? String(o.coin ?? "") : this._displaySymbol(String(o.coin ?? "")),
1139
+ side: String(o.side) === "B" ? "buy" : String(o.side) === "A" ? "sell" : String(o.side).toLowerCase(),
1140
+ price: field(o, "limitPx"),
1141
+ size: field(o, "sz"),
551
1142
  filled: "0",
552
1143
  status: "open",
553
- type: "limit",
1144
+ type: o.isTrigger === true && typeof o.orderType === "string" ? o.orderType : "limit",
1145
+ triggerPrice: o.isTrigger === true && Number(o.triggerPx) > 0 ? String(o.triggerPx) : null,
554
1146
  }));
555
1147
  }
1148
+ /** HL order id for a cancel: a decimal integer that is exactly representable (else INVALID_PARAMS, nothing sent). */
1149
+ /**
1150
+ * A Hyperliquid client order id (cloid) as sent / queried: "0x" + 32 lowercase hex chars
1151
+ * (exchange-endpoint docs: `c` "an optional 128 bit hex string, e.g.
1152
+ * 0x1234567890abcdef1234567890abcdef"). Anything else → INVALID_PARAMS, nothing sent.
1153
+ */
1154
+ static requireCloid(cloid) {
1155
+ if (!HL_CLOID_RE.test(cloid)) {
1156
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid client order id "${cloid}" must be a 128-bit hex cloid: 0x + 32 lowercase hex chars`, {
1157
+ exchange: "hyperliquid", remediation: "Pass e.g. --client-id 0x1234567890abcdef1234567890abcdef, or use --auto-id (a valid cloid is generated and reported)",
1158
+ });
1159
+ }
1160
+ return cloid;
1161
+ }
1162
+ _oidOf(orderId) {
1163
+ const n = Number(orderId);
1164
+ if (!/^\d+$/.test(orderId) || !Number.isSafeInteger(n)) {
1165
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid order id must be a decimal integer (got "${orderId}"); nothing was cancelled`, {
1166
+ exchange: "hyperliquid", remediation: "Look up the order id: perp account orders -e hyperliquid",
1167
+ });
1168
+ }
1169
+ return n;
1170
+ }
556
1171
  async _invalidateAccountCache() {
557
1172
  try {
558
1173
  const { invalidateCache } = await import("../cache.js");
@@ -569,76 +1184,134 @@ export class HyperliquidAdapter {
569
1184
  _validateOrderFill(result, symbol, side, requestedSize) {
570
1185
  const r = result;
571
1186
  const statuses = r?.response?.data?.statuses;
1187
+ const what = `market ${side} ${requestedSize} ${symbol}`;
572
1188
  if (!statuses || statuses.length === 0) {
573
- throw new Error(`Market ${side} ${symbol}: no order status in response`);
1189
+ throw orderOutcomeUnknown("hyperliquid", what, new Error("no order status in response"));
574
1190
  }
575
1191
  const st = statuses[0];
576
1192
  if (st.error) {
577
- throw new Error(`Market ${side} ${symbol}: ${st.error}`);
1193
+ // Explicit per-order venue rejection: definitely not placed.
1194
+ throw new PerpError("ORDER_REJECTED", `Market ${side} ${symbol}: ${st.error}`, { exchange: "hyperliquid" });
578
1195
  }
579
1196
  const filled = st.filled;
580
- if (!filled || Number(filled.totalSz ?? 0) === 0) {
581
- throw new Error(`Market ${side} ${symbol}: order not filled (0 of ${requestedSize}). Check price/liquidity.`);
1197
+ if (!filled && st.resting !== undefined) {
1198
+ // An IOC reported resting is live on the book — outcome NOT definite.
1199
+ throw orderOutcomeUnknown("hyperliquid", what, new Error("the IOC was reported resting on the book"));
1200
+ }
1201
+ if (!filled) {
1202
+ // No filled / resting / error field: unexpected shape — outcome not definite.
1203
+ throw orderOutcomeUnknown("hyperliquid", what, new Error("unexpected order status shape (no filled/resting/error)"));
1204
+ }
1205
+ if (filled.totalSz === undefined || !Number.isFinite(Number(filled.totalSz))) {
1206
+ throw orderOutcomeUnknown("hyperliquid", what, new Error("filled status without a readable totalSz"));
1207
+ }
1208
+ if (Number(filled.totalSz) === 0) {
1209
+ // IOC zero-fill: the venue cancelled the remainder — nothing traded (definite).
1210
+ throw new PerpError("ORDER_REJECTED", `Market ${side} ${symbol}: order not filled (0 of ${requestedSize}). Check price/liquidity.`, { exchange: "hyperliquid" });
582
1211
  }
583
1212
  }
1213
+ /**
1214
+ * Market order = slippage-bounded IOC limit (design §13.2, §13.10 #1/#5) for
1215
+ * native perps and HIP-3 dexes alike. Replaces the SDK's marketOpen/marketClose
1216
+ * (hidden built-in slippage) and the former HIP-3 5% constant.
1217
+ *
1218
+ * Reference price (the single declared source, §13.10 #5): the l2Book mid
1219
+ * (best bid + best ask) / 2 of this coin — see referencePrice(). No runtime
1220
+ * substitute (mark, allMids, last trade): a missing side → PRICE_STALE before
1221
+ * anything is sent. Protection = reference ± opts.slippage.bps, aligned toward
1222
+ * crossing on HL's price grid (hlProtectionPrice, max 6 − szDecimals decimals).
1223
+ *
1224
+ * reduceOnly is sent as the order's `r` flag: the venue can only reduce the
1225
+ * position with it (never open/flip), so the caller's side is used as given.
1226
+ */
584
1227
  async marketOrder(symbol, side, size, opts) {
585
- this.ensureSigner();
586
- if (this._dex) {
587
- const r = await this._dexMarketOrder(symbol, side, size);
588
- this._validateOrderFill(r, symbol, side, size);
589
- await this._invalidateAccountCache();
590
- return r;
591
- }
592
- // Suppress SDK console.log noise (slippage price, decimals, order details)
593
- const origLog = console.log;
594
- console.log = () => { };
1228
+ // Everything before the signed POST /exchange (signer, `meta` asset id, l2Book reference
1229
+ // price, builder approval read) is pre-send: a failed read there (429, transport) is a
1230
+ // definite "not sent" (orderNotSent), never a retryable error with an unknown write outcome.
1231
+ let action;
1232
+ let sentSize;
1233
+ let protection;
1234
+ const cloid = opts.clientOrderId === undefined ? null : HyperliquidAdapter.requireCloid(opts.clientOrderId);
595
1235
  try {
596
- const result = await this.sdk.custom.marketOpen(symbol.toUpperCase(), side === "buy", parseFloat(size));
597
- this._validateOrderFill(result, symbol, side, size);
598
- await this._invalidateAccountCache();
599
- return result;
1236
+ this.ensureSigner();
1237
+ const assetIndex = await this.getAssetIndex(symbol.toUpperCase());
1238
+ const szDec = this.getSzDecimals(symbol);
1239
+ // Floor to the szDecimals lot (never round up past the requested size); 0 → SIZE_TOO_SMALL.
1240
+ sentSize = floorSize(size, hlSizeStep(szDec));
1241
+ protection = await this.computeProtectionPrice(symbol, side, opts.slippage);
1242
+ action = await this._orderAction([this._orderWire({
1243
+ assetIndex,
1244
+ isBuy: side === "buy",
1245
+ price: protection.protectionPrice,
1246
+ size: sentSize,
1247
+ orderType: { limit: { tif: "Ioc" } },
1248
+ reduceOnly: opts.reduceOnly,
1249
+ cloid,
1250
+ })], "na", assetIndex);
600
1251
  }
601
- finally {
602
- console.log = origLog;
1252
+ catch (e) {
1253
+ throw orderNotSent("hyperliquid", `market ${side} ${size} ${symbol.toUpperCase()}`, e);
603
1254
  }
1255
+ const result = await this._signAndSendAction(action);
1256
+ // Per-order verdict: error → ORDER_REJECTED, zero fill → ORDER_REJECTED, resting/unknown → throws.
1257
+ this._validateOrderFill(result, symbol, side, sentSize);
1258
+ await this._invalidateAccountCache();
1259
+ // The protection actually sent rides along with the raw response (like Lighter's
1260
+ // `protection`) so a TradingV2 wrapper records it in execution; `sentSize` is the
1261
+ // size actually sent (floored to szDecimals) that verification compares against.
1262
+ return { ...result, protection, sentSize, ...(cloid !== null ? { clientOrderId: cloid } : {}) };
604
1263
  }
605
1264
  /**
606
- * Place a market order on a HIP-3 deployed dex.
607
- * Bypasses SDK's symbolConversion (which only knows native perps)
608
- * and constructs + signs the order action directly.
1265
+ * The single declared reference price for market-type protection prices:
1266
+ * l2Book mid = (best bid + best ask) / 2. Works for native perps and HIP-3
1267
+ * (getOrderbook passes the dex). Either side missing / non-positive → PRICE_STALE.
609
1268
  */
610
- async _dexMarketOrder(symbol, side, size) {
611
- const assetIndex = await this.getAssetIndex(symbol.toUpperCase());
612
- const szDec = this.getSzDecimals(symbol);
613
- // Get current mark price from dex meta (lookup by coin name, not array index)
614
- const resolved = this.resolveSymbol(symbol.toUpperCase());
615
- const coin = resolved.includes(":") ? resolved : `${this._dex}:${resolved}`;
616
- const meta = await this._infoPost({
617
- type: "metaAndAssetCtxs",
618
- dex: this._dex,
619
- });
620
- // Find local index by matching coin name in universe
621
- const localIdx = meta[0]?.universe?.findIndex(a => a.name === coin) ?? -1;
622
- const ctx = localIdx >= 0 ? (meta[1] ?? [])[localIdx] : undefined;
623
- const midPrice = Number(ctx?.markPx ?? 0);
624
- if (midPrice <= 0)
625
- throw new Error(`Cannot get price for ${symbol} on dex ${this._dex}`);
626
- // HL official rounding: 5 significant figures, max 6 decimals for perps
627
- const MAX_DECIMALS_PERP = 6;
628
- const slippage = 0.05;
629
- const isBuy = side === "buy";
630
- const slippagePrice = isBuy ? midPrice * (1 + slippage) : midPrice * (1 - slippage);
631
- const limitPrice = slippagePrice > 100_000
632
- ? String(Math.round(slippagePrice))
633
- : Number(Number(slippagePrice.toPrecision(5)).toFixed(Math.max(0, MAX_DECIMALS_PERP - szDec))).toString();
634
- return this._rawPlaceOrder({
635
- assetIndex,
636
- isBuy,
637
- price: limitPrice,
638
- size: Number(size).toFixed(szDec),
639
- orderType: { limit: { tif: "Ioc" } },
640
- reduceOnly: false,
641
- });
1269
+ async referencePrice(symbol) {
1270
+ const book = await this.getOrderbook(symbol);
1271
+ const bid = Number(book.bids[0]?.[0]);
1272
+ const ask = Number(book.asks[0]?.[0]);
1273
+ if (!(bid > 0) || !(ask > 0) || !Number.isFinite(bid) || !Number.isFinite(ask)) {
1274
+ throw new PerpError("PRICE_STALE", `Hyperliquid ${symbol.toUpperCase()}${this._dex ? ` (dex ${this._dex})` : ""}: no two-sided l2Book to derive the mid reference price`, {
1275
+ exchange: "hyperliquid",
1276
+ remediation: "Retry when the book has both bids and asks, or place a limit order",
1277
+ });
1278
+ }
1279
+ return (bid + ask) / 2;
1280
+ }
1281
+ /**
1282
+ * Venue mark price of `symbol` (info `metaAndAssetCtxs` → `markPx`; passes the dex for HIP-3).
1283
+ * Read fresh (never the markets cache, whose markPrice may be an allMids substitute).
1284
+ * Missing / non-positive → PRICE_STALE.
1285
+ */
1286
+ async markPrice(symbol) {
1287
+ const coin = this._venueCoin(symbol);
1288
+ const meta = await this._infoPost(this._dex ? { type: "metaAndAssetCtxs", dex: this._dex } : { type: "metaAndAssetCtxs" });
1289
+ const universe = meta?.[0]?.universe;
1290
+ const idx = Array.isArray(universe) ? universe.findIndex((a) => a?.name === coin) : -1;
1291
+ const raw = idx >= 0 && Array.isArray(meta?.[1]) ? meta[1][idx]?.markPx : undefined;
1292
+ const mark = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : NaN;
1293
+ if (!(mark > 0) || !Number.isFinite(mark)) {
1294
+ throw new PerpError("PRICE_STALE", `Hyperliquid ${symbol.toUpperCase()}${this._dex ? ` (dex ${this._dex})` : ""}: no mark price in metaAndAssetCtxs (got ${JSON.stringify(raw)})`, {
1295
+ exchange: "hyperliquid",
1296
+ remediation: "Retry when the venue publishes a mark price for this market",
1297
+ });
1298
+ }
1299
+ return mark;
1300
+ }
1301
+ /**
1302
+ * Protection price an IOC market order on `symbol` would be sent with now.
1303
+ * Exposed so a TradingV2 wrapper can record execution.{protectionPrice, slippageBps}
1304
+ * (appliedBps = the bound after HL rounding).
1305
+ */
1306
+ async computeProtectionPrice(symbol, side, slippage) {
1307
+ const ref = await this.referencePrice(symbol);
1308
+ const { protectionPrice, appliedBps } = hlProtectionPrice(ref, side, slippage.bps, this.getSzDecimals(symbol), 6);
1309
+ await assertHlAppliedBps(appliedBps, `${symbol.toUpperCase()} protection ${protectionPrice} vs reference ${ref}`);
1310
+ return { referencePrice: String(ref), protectionPrice, appliedBps };
1311
+ }
1312
+ /** Definite IOC outcome of a raw order response (see hlIocOutcome). */
1313
+ iocOutcomeOf(raw) {
1314
+ return hlIocOutcome(raw);
642
1315
  }
643
1316
  /**
644
1317
  * Sign and send any exchange action via raw EIP-712 signing.
@@ -682,85 +1355,129 @@ export class HyperliquidAdapter {
682
1355
  }
683
1356
  return result;
684
1357
  }
685
- async _signAndSendAction(action) {
1358
+ /** Monotonic ms nonce: unique even for rapid-fire requests in the same millisecond. */
1359
+ static _nextNonce() {
1360
+ const now = Date.now();
1361
+ HyperliquidAdapter._lastNonce = now > HyperliquidAdapter._lastNonce ? now : HyperliquidAdapter._lastNonce + 1;
1362
+ return HyperliquidAdapter._lastNonce;
1363
+ }
1364
+ get _network() {
1365
+ return this._testnet ? "testnet" : "mainnet";
1366
+ }
1367
+ /**
1368
+ * Sign an L1 action with the active signer tier (agent or master) and POST it.
1369
+ * `nonce` must be passed when the action itself carries it (agentSendAsset).
1370
+ */
1371
+ async _signAndSendAction(action, nonce = HyperliquidAdapter._nextNonce()) {
686
1372
  const { signer } = this._resolveSigner();
687
- const { encode } = await import("@msgpack/msgpack");
688
- const { ethers, keccak256 } = await import("ethers");
689
- const isMainnet = !this._testnet;
690
- const baseUrl = isMainnet
691
- ? "https://api.hyperliquid.xyz"
692
- : "https://api.hyperliquid-testnet.xyz";
693
1373
  // Normalize trailing zeros from 'p' and 's' fields before hashing.
694
1374
  // The HL validator does the same; without this, the hash differs and the
695
1375
  // recovered signer address is wrong → "User or API Wallet does not exist".
696
1376
  const normalizedAction = HyperliquidAdapter._normalizeAction(action);
697
- // Sign L1 action (replicates SDK's signL1Action)
698
- // Monotonic nonce: ensures uniqueness even for rapid-fire requests in same millisecond
699
- const now = Date.now();
700
- HyperliquidAdapter._lastNonce = now > HyperliquidAdapter._lastNonce ? now : HyperliquidAdapter._lastNonce + 1;
701
- const nonce = HyperliquidAdapter._lastNonce;
702
- const msgPackBytes = encode(normalizedAction);
703
- const data = new Uint8Array(msgPackBytes.length + 9); // +8 nonce +1 vault flag
704
- data.set(msgPackBytes);
705
- const view = new DataView(data.buffer);
706
- view.setBigUint64(msgPackBytes.length, BigInt(nonce), false);
707
- view.setUint8(msgPackBytes.length + 8, 0); // no vault
708
- const hash = keccak256(data);
709
- const phantomDomain = {
710
- name: "Exchange",
711
- version: "1",
712
- chainId: 1337,
713
- verifyingContract: "0x0000000000000000000000000000000000000000",
714
- };
715
- const agentTypes = {
716
- Agent: [
717
- { name: "source", type: "string" },
718
- { name: "connectionId", type: "bytes32" },
719
- ],
720
- };
721
- const phantomAgent = {
722
- source: isMainnet ? "a" : "b",
723
- connectionId: hash,
724
- };
725
- const sig = await signer.signTypedData(phantomDomain, agentTypes, phantomAgent);
726
- const parsed = ethers.Signature.from(sig);
727
- const payload = {
728
- action,
729
- nonce,
730
- signature: { r: parsed.r, s: parsed.s, v: parsed.v },
731
- vaultAddress: null,
732
- };
733
- const res = await fetch(`${baseUrl}/exchange`, {
734
- method: "POST",
735
- headers: { "Content-Type": "application/json" },
736
- body: JSON.stringify(payload),
737
- });
738
- const text = await res.text();
1377
+ let signature;
1378
+ try {
1379
+ signature = await signHlL1Action(signer, normalizedAction, nonce, !this._testnet);
1380
+ }
1381
+ catch (e) {
1382
+ // Pre-send: nothing reached the venue (definite, never ORDER_PENDING).
1383
+ if (e instanceof PerpError)
1384
+ throw e;
1385
+ throw new PerpError("SIGNATURE_FAILED", `HL ${String(action.type)} action signing failed: ${e instanceof Error ? e.message : String(e)}`, { exchange: "hyperliquid" });
1386
+ }
1387
+ try {
1388
+ return await this._postExchange({ action, nonce, signature, vaultAddress: null });
1389
+ }
1390
+ catch (e) {
1391
+ throw hlL1ActionRejection(String(action.type), e);
1392
+ }
1393
+ }
1394
+ /**
1395
+ * Sign a USER-signed action (HyperliquidSignTransaction EIP-712) with the MASTER
1396
+ * signer and POST it. Agents cannot sign these — no agent fallback: without a
1397
+ * master signer this throws NO_SIGNER_AVAILABLE before anything is sent.
1398
+ */
1399
+ async _signAndSendUserAction(type, fields) {
1400
+ const master = this._evmSigner;
1401
+ if (!master) {
1402
+ throw new PerpError("NO_SIGNER_AVAILABLE", `Hyperliquid ${type} must be signed by the master wallet (an agent wallet cannot sign it), and none is configured`, {
1403
+ exchange: "hyperliquid",
1404
+ remediation: "Run with the master wallet: perp -e hyperliquid --ows <wallet> ... (or export PRIVATE_KEY=0x...)",
1405
+ });
1406
+ }
1407
+ const nonce = HyperliquidAdapter._nextNonce();
1408
+ const action = buildHlUserSignedAction(type, this._network, { ...fields, [hlUserSignedNonceField(type)]: nonce });
1409
+ let signature;
1410
+ try {
1411
+ signature = await signHlUserSignedAction(master, action);
1412
+ }
1413
+ catch (e) {
1414
+ if (e instanceof PerpError)
1415
+ throw e;
1416
+ throw new PerpError("SIGNATURE_FAILED", `HL ${type} signing failed: ${e instanceof Error ? e.message : String(e)}`, { exchange: "hyperliquid" });
1417
+ }
1418
+ // User-signed body: {action, nonce, signature} — no vaultAddress (python SDK _post_action
1419
+ // sends null for these; the venue reads the signer from the EIP-712 signature).
1420
+ try {
1421
+ return await this._postExchange({ action, nonce, signature });
1422
+ }
1423
+ catch (e) {
1424
+ throw hlUserActionRejection(type, e);
1425
+ }
1426
+ }
1427
+ /** POST /exchange request timeout: a request the venue never answers must not hang the CLI. */
1428
+ static EXCHANGE_TIMEOUT_MS = 30_000;
1429
+ /**
1430
+ * POST /exchange; an `err` envelope or a 4xx non-JSON body throws (the venue refused it).
1431
+ * Once the signed action is on the wire its outcome is unknown unless the venue answers:
1432
+ * a connection reset, timeout, 5xx or unreadable 2xx body → ORDER_PENDING (sendFailure),
1433
+ * never a retryable EXCHANGE_UNREACHABLE / TIMEOUT. No connection at all → not sent.
1434
+ */
1435
+ async _postExchange(payload) {
1436
+ const baseUrl = this._testnet ? "https://api.hyperliquid-testnet.xyz" : "https://api.hyperliquid.xyz";
1437
+ const type = String(payload.action?.type);
1438
+ const what = `${type} action`;
1439
+ const remediation = `Do NOT resubmit — the ${type} may have been executed. Check open orders, positions and balances first: perp -e hyperliquid account orders / portfolio`;
1440
+ let res;
1441
+ let text;
1442
+ try {
1443
+ res = await fetch(`${baseUrl}/exchange`, {
1444
+ method: "POST",
1445
+ headers: { "Content-Type": "application/json" },
1446
+ body: JSON.stringify(payload),
1447
+ signal: AbortSignal.timeout(HyperliquidAdapter.EXCHANGE_TIMEOUT_MS),
1448
+ });
1449
+ text = await res.text();
1450
+ }
1451
+ catch (e) {
1452
+ throw sendFailure("hyperliquid", what, e, remediation);
1453
+ }
1454
+ if (res.status >= 500) {
1455
+ throw orderOutcomeUnknown("hyperliquid", what, new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`), remediation);
1456
+ }
739
1457
  let result;
740
1458
  try {
741
1459
  result = JSON.parse(text);
742
1460
  }
743
1461
  catch {
744
- throw new Error(`HL exchange API error (${res.status}): ${text.slice(0, 200)}`);
1462
+ if (res.status >= 400)
1463
+ throw new Error(`HL exchange API error (${res.status}): ${text.slice(0, 200)}`);
1464
+ throw orderOutcomeUnknown("hyperliquid", what, new Error(`unreadable response (HTTP ${res.status}): ${text.slice(0, 200)}`), remediation);
745
1465
  }
746
1466
  if (result?.status === "err") {
747
- throw new Error(typeof result.response === "string" ? result.response : JSON.stringify(result));
1467
+ throw new HlErrResponse(typeof result.response === "string" ? result.response : JSON.stringify(result));
748
1468
  }
749
1469
  return result;
750
1470
  }
751
- /**
752
- * Place an order via raw exchange API with EIP-712 signing.
753
- * This bypasses the SDK's symbolConversion and supports dex-specific orders.
754
- */
755
- async _rawPlaceOrder(opts) {
756
- // Remove trailing zeros (HL API requirement)
1471
+ /** One order wire (HL `order` action entry); p/s trailing zeros removed (HL API requirement). */
1472
+ _orderWire(opts) {
757
1473
  const trimZeros = (s) => {
758
1474
  if (!s.includes("."))
759
1475
  return s;
760
1476
  const n = s.replace(/\.?0+$/, "");
761
1477
  return n === "-0" ? "0" : n;
762
1478
  };
763
- const orderWire = {
1479
+ // No "dex" field needed — the global asset index (110000+) encodes the dex
1480
+ const wire = {
764
1481
  a: opts.assetIndex,
765
1482
  b: opts.isBuy,
766
1483
  p: trimZeros(opts.price),
@@ -768,33 +1485,79 @@ export class HyperliquidAdapter {
768
1485
  r: opts.reduceOnly,
769
1486
  t: opts.orderType,
770
1487
  };
771
- const action = {
772
- type: "order",
773
- orders: [orderWire],
774
- grouping: "na",
775
- };
776
- // No "dex" field needed — the global asset index (110000+) encodes the dex
777
- return this._signAndSendAction(action);
1488
+ // `c` last, only when set — the signed msgpack follows key order (python SDK order_request_to_order_wire).
1489
+ if (opts.cloid !== undefined && opts.cloid !== null)
1490
+ wire.c = opts.cloid;
1491
+ return wire;
1492
+ }
1493
+ /**
1494
+ * Place an order via raw exchange API with EIP-712 signing.
1495
+ * This bypasses the SDK's symbolConversion and supports dex-specific orders.
1496
+ */
1497
+ async _rawPlaceOrder(opts) {
1498
+ const result = await this._signAndSendAction(opts.action);
1499
+ this._validateVenueAck(result, "order");
1500
+ return result;
1501
+ }
1502
+ /**
1503
+ * A computed limit price for `symbol` on the HL grid, aligned passively for `side` (buy ↓,
1504
+ * sell ↑). For strategy / grid / slice prices — limitOrder itself refuses off-grid input.
1505
+ */
1506
+ async alignComputedPrice(symbol, side, price) {
1507
+ await this.getAssetIndex(symbol.toUpperCase()); // loads meta (szDecimals) when empty
1508
+ return hlAlignPriceToGrid(price, this.getSzDecimals(symbol), 6, side === "buy" ? "down" : "up");
778
1509
  }
779
1510
  async limitOrder(symbol, side, price, size, opts) {
780
- // Normalize TIF: accept "IOC"/"GTC"/"ALO" (uppercase) or native "Ioc"/"Gtc"/"Alo"
781
- const rawTif = opts?.tif ?? "Gtc";
782
- const tifMap = { IOC: "Ioc", GTC: "Gtc", ALO: "Alo" };
783
- const tif = (tifMap[rawTif.toUpperCase()] ?? rawTif);
784
- const reduceOnly = opts?.reduceOnly ?? false;
785
- // Round price/size to HL-supported decimals
786
- const szDec = this.getSzDecimals(symbol);
787
- const roundedPrice = this._hlRoundPrice(Number(price), szDec);
788
- const roundedSize = Number(size).toFixed(szDec);
1511
+ // TIF aliases (§13.3): GTC/IOC/ALO or native spellings; FOK → CAPABILITY_UNSUPPORTED.
1512
+ let action;
1513
+ const cloid = opts?.clientOrderId === undefined ? null : HyperliquidAdapter.requireCloid(opts.clientOrderId);
1514
+ try { // pre-send (see marketOrder)
1515
+ const tif = hlTif(opts?.tif ?? "GTC");
1516
+ const reduceOnly = opts?.reduceOnly ?? false;
1517
+ const assetIndex = await this.getAssetIndex(symbol.toUpperCase()); // loads meta (szDecimals) when empty
1518
+ // Off-grid price → INVALID_TICK (refused, never rounded); size floored to the lot.
1519
+ const szDec = this.getSzDecimals(symbol);
1520
+ const roundedPrice = hlRequirePriceOnGrid(price, szDec, 6, "limit price");
1521
+ const roundedSize = floorSize(size, hlSizeStep(szDec)); // floored to the lot; 0 → SIZE_TOO_SMALL
1522
+ action = await this._orderAction([this._orderWire({
1523
+ assetIndex,
1524
+ isBuy: side === "buy",
1525
+ price: roundedPrice,
1526
+ size: roundedSize,
1527
+ orderType: { limit: { tif } },
1528
+ reduceOnly,
1529
+ cloid,
1530
+ })], "na", assetIndex);
1531
+ }
1532
+ catch (e) {
1533
+ throw orderNotSent("hyperliquid", `limit ${side} ${size} ${symbol.toUpperCase()} @ ${price}`, e);
1534
+ }
789
1535
  // Use _rawPlaceOrder for both DEX and non-DEX (SDK exchange.placeOrder is unavailable)
790
- const result = await this._rawPlaceOrder({
791
- assetIndex: await this.getAssetIndex(symbol.toUpperCase()),
792
- isBuy: side === "buy",
793
- price: roundedPrice,
794
- size: roundedSize,
795
- orderType: { limit: { tif } },
796
- reduceOnly,
1536
+ const result = await this._rawPlaceOrder({ action });
1537
+ await this._invalidateAccountCache();
1538
+ // The cloid sent rides on the result (`clientOrderId`), like Pacifica's.
1539
+ return cloid !== null ? { ...result, clientOrderId: cloid } : result;
1540
+ }
1541
+ /**
1542
+ * Cancel one order by client order id — exchange-endpoint docs "Cancel order(s) by cloid":
1543
+ * `{ "type": "cancelByCloid", "cancels": [{ "asset": Number, "cloid": String }] }`. The asset is
1544
+ * the global id, so a HIP-3 dex order is addressed the same way. Per-entry error → ORDER_REJECTED.
1545
+ */
1546
+ async cancelByCloid(symbol, cloid) {
1547
+ this.ensureSigner();
1548
+ const id = HyperliquidAdapter.requireCloid(cloid);
1549
+ let assetIndex;
1550
+ try { // pre-send: the `meta` read behind the asset id
1551
+ assetIndex = await this.getAssetIndex(symbol.toUpperCase());
1552
+ }
1553
+ catch (e) {
1554
+ throw orderNotSent("hyperliquid", `cancel c:${id} ${symbol.toUpperCase()}`, e);
1555
+ }
1556
+ const result = await this._signAndSendAction({
1557
+ type: "cancelByCloid",
1558
+ cancels: [{ asset: assetIndex, cloid: id }],
797
1559
  });
1560
+ this._validateVenueAck(result, `cancel c:${id}`);
798
1561
  await this._invalidateAccountCache();
799
1562
  return result;
800
1563
  }
@@ -805,17 +1568,33 @@ export class HyperliquidAdapter {
805
1568
  // expose it as a callable method. Use raw cancel action via _signAndSendAction
806
1569
  // instead — same approach as the HIP-3 dex branch — which works uniformly for
807
1570
  // standard perps and HIP-3 dexes.
808
- const assetIndex = await this.getAssetIndex(symbol.toUpperCase());
1571
+ const oid = this._oidOf(orderId);
1572
+ let assetIndex;
1573
+ try { // pre-send (see marketOrder): the `meta` read behind the asset id
1574
+ assetIndex = await this.getAssetIndex(symbol.toUpperCase());
1575
+ }
1576
+ catch (e) {
1577
+ throw orderNotSent("hyperliquid", `cancel ${orderId} ${symbol.toUpperCase()}`, e);
1578
+ }
809
1579
  const result = await this._signAndSendAction({
810
1580
  type: "cancel",
811
- cancels: [{ a: assetIndex, o: parseInt(orderId) }],
1581
+ cancels: [{ a: assetIndex, o: oid }],
812
1582
  });
1583
+ // A refused cancel (already filled, unknown oid) also comes back under an
1584
+ // "ok" envelope — without this the user is told the order was cancelled.
1585
+ this._validateVenueAck(result, `cancel ${orderId}`);
813
1586
  await this._invalidateAccountCache();
814
1587
  return result;
815
1588
  }
816
1589
  async cancelAllOrders(symbol) {
817
1590
  this.ensureSigner();
818
- const orders = await this.getOpenOrders();
1591
+ let orders;
1592
+ try { // pre-send: the frontendOpenOrders listing — nothing is cancelled when it fails
1593
+ orders = await this.getOpenOrders();
1594
+ }
1595
+ catch (e) {
1596
+ throw orderNotSent("hyperliquid", `cancel-all${symbol ? ` ${symbol.toUpperCase()}` : ""}`, e);
1597
+ }
819
1598
  const toCancel = symbol
820
1599
  ? orders.filter((o) => {
821
1600
  const norm = (s) => s.toUpperCase().replace(/-PERP$/, "").replace(/^[^:]+:/, "");
@@ -825,35 +1604,150 @@ export class HyperliquidAdapter {
825
1604
  // Use _signAndSendAction uniformly for both standard perps and HIP-3 dexes.
826
1605
  // SDK exchange.cancelOrder has the same runtime unavailability as single
827
1606
  // cancelOrder (see comment there); raw cancel action works for both paths.
1607
+ // Every target is attempted (like lighter.ts cancelAllOrders): one refused / unknown cancel
1608
+ // must not leave the orders after it untouched and unreported. A per-entry "Order was never
1609
+ // placed, already canceled, or filled." means the order is no longer open — reported as gone
1610
+ // (not cancelled by this call), not a failure. All cancelled / gone → the raw results; any
1611
+ // failure → one error carrying the per-order outcome.
828
1612
  const results = [];
1613
+ const perOrder = [];
829
1614
  for (const o of toCancel) {
830
- const assetIndex = await this.getAssetIndex(o.symbol);
831
- results.push(await this._signAndSendAction({
832
- type: "cancel",
833
- cancels: [{ a: assetIndex, o: parseInt(o.orderId) }],
834
- }));
1615
+ try {
1616
+ const oid = this._oidOf(o.orderId);
1617
+ let assetIndex;
1618
+ try { // pre-send: the `meta` read behind the asset id
1619
+ assetIndex = await this.getAssetIndex(o.symbol);
1620
+ }
1621
+ catch (e) {
1622
+ throw orderNotSent("hyperliquid", `cancel ${o.orderId} ${o.symbol}`, e);
1623
+ }
1624
+ const res = await this._signAndSendAction({
1625
+ type: "cancel",
1626
+ cancels: [{ a: assetIndex, o: oid }],
1627
+ });
1628
+ this._validateVenueAck(res, `cancel ${o.orderId}`);
1629
+ results.push(res);
1630
+ perOrder.push({ orderId: o.orderId, symbol: o.symbol, outcome: "cancelled", code: null, error: null });
1631
+ }
1632
+ catch (e) {
1633
+ const venueErrors = e instanceof PerpError ? e.structured.details?.venueErrors : undefined;
1634
+ const gone = Array.isArray(venueErrors) && venueErrors.length > 0
1635
+ && venueErrors.every(v => typeof v === "string" && /already canceled, or filled/i.test(v));
1636
+ perOrder.push({ orderId: o.orderId, symbol: o.symbol, outcome: gone ? "gone" : "failed", code: classifyError(e, "hyperliquid").code, error: extractErrorMessage(e) });
1637
+ }
835
1638
  }
836
- return results;
1639
+ const failed = perOrder.filter(r => r.outcome === "failed");
1640
+ const gone = perOrder.filter(r => r.outcome === "gone");
1641
+ if (failed.length === 0) {
1642
+ if (gone.length > 0) {
1643
+ console.error(`[hyperliquid] cancel-all: ${gone.length} order(s) were no longer open (already cancelled or filled, not cancelled by this call): ${gone.map(r => r.orderId).join(", ")}`);
1644
+ }
1645
+ return results;
1646
+ }
1647
+ const cancelled = perOrder.filter(r => r.outcome === "cancelled").length;
1648
+ // Some cancelled → ORDER_PARTIAL; none cancelled → ORDER_REJECTED only when every failure is a
1649
+ // definite refusal, otherwise the outcome of at least one cancel is unknown (ORDER_PENDING).
1650
+ const code = cancelled > 0 ? "ORDER_PARTIAL"
1651
+ : failed.every(r => r.code !== null && DEFINITE_ORDER_FAILURE_CODES.has(r.code)) ? "ORDER_REJECTED" : "ORDER_PENDING";
1652
+ throw new PerpError(code, `hyperliquid cancel-all: ${cancelled} of ${perOrder.length} order(s) cancelled${gone.length > 0 ? `, ${gone.length} already gone (cancelled or filled)` : ""}; failed: ${failed.map(r => `${r.orderId} ${r.symbol} (${r.error})`).join("; ")}`, {
1653
+ exchange: "hyperliquid",
1654
+ results: perOrder,
1655
+ remediation: "Check which orders are still open (perp account orders -e hyperliquid) and cancel the remaining ones individually",
1656
+ });
837
1657
  }
838
1658
  // ── Interface methods ──
1659
+ /**
1660
+ * Amend (design §13.4, §13.10 #10) via HL native batchModify. `size` = the NEW
1661
+ * open (unfilled) size — native-modify semantics, not the original total. The
1662
+ * order's own tif (Gtc/Alo) and reduceOnly are read from frontendOpenOrders
1663
+ * and preserved; nothing is hardcoded.
1664
+ */
839
1665
  async editOrder(symbol, orderId, price, size) {
840
- // Look up the existing order's side to preserve it (avoid flipping buy↔sell)
841
- const openOrders = await this.getOpenOrders();
842
- const existing = openOrders.find(o => o.orderId === orderId);
843
- const side = existing?.side ?? "buy";
844
- return this.modifyOrder(symbol, parseInt(orderId), side, price, size);
1666
+ // Read the RAW order: the mapped getOpenOrders() hardcodes type:"limit" and
1667
+ // drops reduceOnly, so it cannot distinguish a trigger order from a plain
1668
+ // limit nor preserve reduce-only across the modify.
1669
+ let raw;
1670
+ try { // pre-send (see marketOrder): the order's own attributes
1671
+ raw = await this._infoPost({
1672
+ type: "frontendOpenOrders",
1673
+ user: this._address,
1674
+ ...(this._dex ? { dex: this._dex } : {}),
1675
+ });
1676
+ }
1677
+ catch (e) {
1678
+ throw orderNotSent("hyperliquid", `edit ${orderId} ${symbol.toUpperCase()}`, e);
1679
+ }
1680
+ const existing = (raw ?? []).find(o => String(o.oid ?? "") === orderId);
1681
+ if (!existing) {
1682
+ // Rule #2: never default the side. Guessing "buy" on a resting sell order
1683
+ // rewrites it to the opposite side, which can open or flip a position.
1684
+ throw new PerpError("ORDER_NOT_FOUND", `Hyperliquid order ${orderId} is not among the open orders; refusing to edit (cannot guess its side).`, { exchange: "hyperliquid" });
1685
+ }
1686
+ // L11: the order id must belong to `symbol` — a modify carries the asset index of
1687
+ // `symbol`, so a mismatched id would move another market's order onto this one.
1688
+ const coinNorm = (s) => s.toUpperCase().replace(/-PERP$/, "");
1689
+ const coin = String(existing.coin ?? "");
1690
+ const base = (s) => coinNorm(s.includes(":") ? s.split(":")[1] : s);
1691
+ // HIP-3 coins are "<dex>:<BASE>" (the query above is already scoped to this adapter's dex).
1692
+ const sameCoin = coinNorm(coin) === coinNorm(symbol) || coinNorm(coin) === coinNorm(this.resolveSymbol(symbol))
1693
+ || (coin.includes(":") && !symbol.includes(":") && base(coin) === base(symbol));
1694
+ if (!sameCoin) {
1695
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid order ${orderId} is on ${coin || "an unknown market"}, not ${symbol.toUpperCase()}; refusing to edit`, {
1696
+ exchange: "hyperliquid",
1697
+ remediation: `Pass the order's own symbol: perp trade edit ${coin || "<symbol>"} ${orderId} ...`,
1698
+ });
1699
+ }
1700
+ if (existing.isTrigger === true) {
1701
+ // batchModify is sent without the `a` (always_place) flag. Per the exchange
1702
+ // docs, that path requires a NON-trigger order, so the venue rejects a
1703
+ // modify into a stop/TP — re-placing it as a plain limit would also drop
1704
+ // the trigger. Refuse rather than corrupt the order's semantics.
1705
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid editOrder only supports non-trigger orders (order ${orderId} is ${String(existing.orderType ?? "a trigger order")}). Cancel and re-place it explicitly.`, { exchange: "hyperliquid" });
1706
+ }
1707
+ const sideRaw = String(existing.side);
1708
+ const side = sideRaw === "B" ? "buy" : sideRaw === "A" ? "sell" : sideRaw.toLowerCase();
1709
+ if (side !== "buy" && side !== "sell") {
1710
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid order ${orderId} has an unrecognized side "${sideRaw}"; refusing to edit.`, { exchange: "hyperliquid" });
1711
+ }
1712
+ const reduceOnly = existing.reduceOnly === true;
1713
+ const tif = existing.tif;
1714
+ if (tif !== "Gtc" && tif !== "Alo" && tif !== "Ioc") {
1715
+ // Rule #2: never guess the time-in-force (re-placing an ALO as GTC can take liquidity).
1716
+ throw unsupported("Hyperliquid", "amend", `original order attributes unknown (order ${orderId} reports tif ${JSON.stringify(tif ?? null)})`, `Cancel order ${orderId} and place a new order with an explicit --tif`);
1717
+ }
1718
+ // Keep the order's cloid across the modify (the modify carries a full order wire; without
1719
+ // `c` the edited order would lose the id `trade status/cancel c:<cloid>` address it by).
1720
+ const cloid = typeof existing.cloid === "string" && HL_CLOID_RE.test(existing.cloid) ? existing.cloid : null;
1721
+ return this.modifyOrder(symbol, parseInt(orderId), side, price, size, { reduceOnly, tif, ...(cloid !== null ? { cloid } : {}) });
845
1722
  }
846
1723
  async setLeverage(symbol, leverage, marginMode = "cross") {
847
1724
  return this.updateLeverage(symbol, leverage, marginMode === "cross");
848
1725
  }
849
1726
  async stopOrder(symbol, side, size, triggerPrice, opts) {
850
- return this.triggerOrder(symbol, side, size, triggerPrice, "sl", {
1727
+ // TP vs SL from the trigger vs the MARK price — HL docs: "The mark price is used to trigger
1728
+ // TP/SL orders." Buy at/above or sell at/below the mark = stop (sl); the other side =
1729
+ // take-profit (tp). A wrong tpsl would make the venue trigger the order immediately.
1730
+ // No mark price → PRICE_STALE.
1731
+ const trigger = Number(triggerPrice);
1732
+ if (!(trigger > 0) || !Number.isFinite(trigger)) {
1733
+ throw new PerpError("INVALID_PARAMS", `Invalid trigger price "${triggerPrice}" for ${symbol.toUpperCase()} stop order`, { exchange: "hyperliquid" });
1734
+ }
1735
+ let mark;
1736
+ try { // pre-send (see marketOrder): the metaAndAssetCtxs mark-price read
1737
+ mark = await this.markPrice(symbol);
1738
+ }
1739
+ catch (e) {
1740
+ throw orderNotSent("hyperliquid", `stop ${side} ${size} ${symbol.toUpperCase()} @ ${triggerPrice}`, e);
1741
+ }
1742
+ const tpsl = hlTriggerKind(side, trigger, mark);
1743
+ return this.triggerOrder(symbol, side, size, triggerPrice, tpsl, {
851
1744
  isMarket: !opts?.limitPrice,
1745
+ limitPrice: opts?.limitPrice,
852
1746
  reduceOnly: opts?.reduceOnly ?? true,
853
1747
  });
854
1748
  }
855
1749
  async getRecentTrades(symbol, limit = 20) {
856
- const trades = await this._infoPost({ type: "recentTrades", coin: symbol.toUpperCase() });
1750
+ const trades = await this._infoPost({ type: "recentTrades", coin: this._venueCoin(symbol) });
857
1751
  return (trades ?? [])
858
1752
  .slice(0, limit)
859
1753
  .map((t) => ({
@@ -866,7 +1760,10 @@ export class HyperliquidAdapter {
866
1760
  }));
867
1761
  }
868
1762
  async getKlines(symbol, interval, startTime, endTime) {
869
- const candles = await this.client.info.getCandleSnapshot(symbol.toUpperCase(), interval, startTime, endTime);
1763
+ const candles = await this._infoPost({
1764
+ type: "candleSnapshot",
1765
+ req: { coin: this._venueCoin(symbol), interval, startTime, endTime },
1766
+ });
870
1767
  return (candles ?? []).map((c) => ({
871
1768
  time: Number(c.t ?? 0),
872
1769
  open: String(c.o ?? "0"),
@@ -879,27 +1776,26 @@ export class HyperliquidAdapter {
879
1776
  }
880
1777
  async getOrderHistory(limit = 30) {
881
1778
  this.ensureAddress();
882
- const fills = await this.client.info.getUserFills(this._address);
1779
+ const fills = await this._infoPost({ type: "userFills", user: this._address });
883
1780
  return (fills ?? []).slice(0, limit).map((f) => ({
884
1781
  orderId: String(f.oid ?? ""),
885
- symbol: String(f.coin ?? ""),
886
- // SDK convertSymbolsInObject already converts "B"→"buy", "A"→"sell"
887
- side: String(f.side) === "B" ? "buy" : String(f.side) === "A" ? "sell" : f.side,
1782
+ symbol: this._displaySymbol(String(f.coin ?? "")),
1783
+ side: String(f.side) === "B" ? "buy" : String(f.side) === "A" ? "sell" : String(f.side).toLowerCase(),
888
1784
  price: String(f.px ?? "0"),
889
1785
  size: String(f.sz ?? ""),
890
1786
  filled: String(f.sz ?? ""),
891
1787
  status: "filled",
892
1788
  type: String(f.dir ?? ""),
1789
+ triggerPrice: null, // fills carry no trigger price
893
1790
  }));
894
1791
  }
895
1792
  async getTradeHistory(limit = 30) {
896
1793
  this.ensureAddress();
897
- const fills = await this.client.info.getUserFills(this._address);
1794
+ const fills = await this._infoPost({ type: "userFills", user: this._address });
898
1795
  return (fills ?? []).slice(0, limit).map((f) => ({
899
1796
  time: Number(f.time ?? 0),
900
- symbol: String(f.coin ?? ""),
901
- // SDK convertSymbolsInObject already converts "B"→"buy", "A"→"sell"
902
- side: String(f.side) === "B" ? "buy" : String(f.side) === "A" ? "sell" : f.side,
1797
+ symbol: this._displaySymbol(String(f.coin ?? "")),
1798
+ side: String(f.side) === "B" ? "buy" : String(f.side) === "A" ? "sell" : String(f.side).toLowerCase(),
903
1799
  price: String(f.px ?? "0"),
904
1800
  size: String(f.sz ?? ""),
905
1801
  fee: String(f.fee ?? "0"),
@@ -923,10 +1819,10 @@ export class HyperliquidAdapter {
923
1819
  }
924
1820
  async getFundingHistory(symbol, limit = 10) {
925
1821
  const now = Date.now();
926
- const history = await this.client.info.perpetuals.getFundingHistory(symbol.toUpperCase(), now - 24 * 60 * 60 * 1000);
1822
+ const history = await this._infoPost({ type: "fundingHistory", coin: this._venueCoin(symbol), startTime: now - 24 * 60 * 60 * 1000 });
927
1823
  return (history ?? []).slice(-limit).map((h) => ({
928
1824
  time: Number(h.time ?? 0),
929
- rate: String(h.fundingRate ?? "0"),
1825
+ rate: venueRateField(h.fundingRate, "fundingHistory fundingRate", "hyperliquid"),
930
1826
  price: null,
931
1827
  }));
932
1828
  }
@@ -936,13 +1832,50 @@ export class HyperliquidAdapter {
936
1832
  /**
937
1833
  * Place a trigger order (stop loss / take profit).
938
1834
  * Python SDK: order() with trigger type {"trigger": {triggerPx, isMarket, tpsl: "tp"|"sl"}}
1835
+ *
1836
+ * Pricing (design §13.10 #3):
1837
+ * - isMarket (default): HL native market trigger; `p` = trigger ± 10 % (the venue's documented
1838
+ * market TP/SL slippage tolerance, HL_MARKET_TPSL_SLIPPAGE_BPS), aligned toward crossing.
1839
+ * - limit trigger with an explicit `limitPrice`: that price, rounded to HL's grid.
1840
+ * - limit trigger without `limitPrice`: protection = TRIGGER price ± opts.slippage.bps
1841
+ * (reference = trigger, not mark), aligned toward crossing. Missing slippage →
1842
+ * INVALID_PARAMS (no implicit zero-slippage limit at the trigger).
939
1843
  */
940
1844
  async triggerOrder(symbol, side, size, triggerPrice, tpsl, opts) {
941
1845
  this.ensureSigner();
942
- const assetIndex = await this.getAssetIndex(symbol.toUpperCase());
1846
+ const what = `${tpsl} trigger ${side} ${size} ${symbol.toUpperCase()} @ ${triggerPrice}`;
1847
+ let assetIndex;
1848
+ try { // pre-send (see marketOrder)
1849
+ assetIndex = await this.getAssetIndex(symbol.toUpperCase());
1850
+ }
1851
+ catch (e) {
1852
+ throw orderNotSent("hyperliquid", what, e);
1853
+ }
943
1854
  const szDec = this.getSzDecimals(symbol);
944
- const roundedSize = Number(size).toFixed(szDec);
945
- const roundedTrigger = this._hlRoundPrice(Number(triggerPrice), szDec);
1855
+ const roundedSize = floorSize(size, hlSizeStep(szDec)); // floored to the lot; 0 → SIZE_TOO_SMALL
1856
+ // User-supplied trigger / limit prices: off-grid → INVALID_TICK (never rounded).
1857
+ const roundedTrigger = hlRequirePriceOnGrid(triggerPrice, szDec, 6, "trigger price");
1858
+ // Stop-limit / TP-limit: the order's limit price is the caller's limitPrice, not the trigger.
1859
+ const isMarket = opts?.isMarket ?? true;
1860
+ let roundedLimit;
1861
+ if (opts?.limitPrice !== undefined) {
1862
+ roundedLimit = hlRequirePriceOnGrid(opts.limitPrice, szDec, 6, "trigger limit price");
1863
+ }
1864
+ else if (isMarket) {
1865
+ // Market trigger: `p` = trigger ± the venue's documented market TP/SL tolerance (never the
1866
+ // trigger itself — see HL_MARKET_TPSL_SLIPPAGE_BPS), aligned toward crossing.
1867
+ roundedLimit = hlProtectionPrice(Number(roundedTrigger), side, HL_MARKET_TPSL_SLIPPAGE_BPS, szDec, 6).protectionPrice;
1868
+ }
1869
+ else {
1870
+ if (!opts?.slippage) {
1871
+ throw new PerpError("INVALID_PARAMS", `Limit trigger order for ${symbol.toUpperCase()} needs a limit price or a resolved slippage bound`, {
1872
+ remediation: "Pass --limit-price, or use a market trigger.",
1873
+ });
1874
+ }
1875
+ const pp = hlProtectionPrice(Number(roundedTrigger), side, opts.slippage.bps, szDec, 6);
1876
+ await assertHlAppliedBps(pp.appliedBps, `${symbol.toUpperCase()} trigger limit ${pp.protectionPrice} vs trigger ${roundedTrigger}`);
1877
+ roundedLimit = pp.protectionPrice;
1878
+ }
946
1879
  // Remove trailing zeros (HL API requirement)
947
1880
  const trimZeros = (s) => {
948
1881
  if (!s.includes("."))
@@ -953,23 +1886,90 @@ export class HyperliquidAdapter {
953
1886
  const orderWire = {
954
1887
  a: assetIndex,
955
1888
  b: side === "buy",
956
- p: trimZeros(roundedTrigger),
1889
+ p: trimZeros(roundedLimit),
957
1890
  s: trimZeros(roundedSize),
958
1891
  r: opts?.reduceOnly ?? true,
959
1892
  t: {
960
1893
  trigger: {
961
- isMarket: opts?.isMarket ?? true,
1894
+ isMarket,
962
1895
  triggerPx: trimZeros(roundedTrigger),
963
1896
  tpsl,
964
1897
  },
965
1898
  },
966
1899
  };
967
- const action = {
968
- type: "order",
969
- orders: [orderWire],
970
- grouping: opts?.grouping ?? "positionTpsl",
971
- };
972
- return this._signAndSendAction(action);
1900
+ let action;
1901
+ try { // pre-send: the builder approval read (info maxBuilderFee) when a builder fee is configured
1902
+ action = await this._orderAction([orderWire], opts?.grouping ?? "positionTpsl", assetIndex);
1903
+ }
1904
+ catch (e) {
1905
+ throw orderNotSent("hyperliquid", what, e);
1906
+ }
1907
+ // The trigger ack is the bare status "waitingForTrigger" (no oid): the open trigger orders are
1908
+ // read before and after so the new order's oid can be identified (_newTriggerOid).
1909
+ let coin = "";
1910
+ let before = null;
1911
+ try {
1912
+ coin = this._venueCoin(symbol);
1913
+ before = await this._triggerOids(coin);
1914
+ }
1915
+ catch (e) {
1916
+ // Only the oid lookup is skipped (the order is still sent); the result then carries no id.
1917
+ console.error(`[hyperliquid] Warning: open-orders read before the trigger order failed (${e instanceof Error ? e.message : String(e)}) — its order id will not be resolved`);
1918
+ }
1919
+ const result = await this._signAndSendAction(action);
1920
+ this._validateVenueAck(result, `${tpsl.toUpperCase()} trigger order for ${symbol.toUpperCase()}`);
1921
+ const statuses = result?.response?.data?.statuses;
1922
+ if (before === null || !Array.isArray(statuses) || statuses[0] !== "waitingForTrigger")
1923
+ return result;
1924
+ const triggerOid = await this._newTriggerOid(coin, before, {
1925
+ side: orderWire.b ? "B" : "A", triggerPx: orderWire.t.trigger.triggerPx, sz: orderWire.s, reduceOnly: orderWire.r,
1926
+ });
1927
+ if (triggerOid === null) {
1928
+ console.error(`[hyperliquid] Warning: ${tpsl.toUpperCase()} trigger order for ${symbol.toUpperCase()} placed, but its order id could not be identified — find it with: perp account orders -e hyperliquid`);
1929
+ return result;
1930
+ }
1931
+ return { ...result, triggerOid };
1932
+ }
1933
+ /** Open trigger orders of `coin` (frontendOpenOrders rows with isTrigger), by oid. */
1934
+ async _triggerOids(coin) {
1935
+ const req = { type: "frontendOpenOrders", user: this._address };
1936
+ if (this._dex)
1937
+ req.dex = this._dex;
1938
+ const rows = await this._infoPost(req);
1939
+ if (!Array.isArray(rows))
1940
+ throw new PerpError("EXCHANGE_ERROR", "frontendOpenOrders returned no array", { exchange: "hyperliquid" });
1941
+ return new Set(rows.filter(r => r.coin === coin && r.isTrigger === true).map(r => String(r.oid)));
1942
+ }
1943
+ /**
1944
+ * The oid of the trigger order just placed: the single open trigger order of `coin` not listed
1945
+ * before, with the side / trigger price / size / reduce-only sent. Several reads (500 ms apart);
1946
+ * none or more than one candidate → null (never guessed).
1947
+ */
1948
+ async _newTriggerOid(coin, before, sent) {
1949
+ for (let attempt = 0; attempt < 4; attempt++) {
1950
+ if (attempt > 0)
1951
+ await new Promise(r => setTimeout(r, 500));
1952
+ const req = { type: "frontendOpenOrders", user: this._address };
1953
+ if (this._dex)
1954
+ req.dex = this._dex;
1955
+ let rows;
1956
+ try {
1957
+ rows = await this._infoPost(req);
1958
+ }
1959
+ catch {
1960
+ continue;
1961
+ }
1962
+ if (!Array.isArray(rows))
1963
+ continue;
1964
+ const hits = rows.filter(r => r.coin === coin && r.isTrigger === true && !before.has(String(r.oid)) && r.side === sent.side
1965
+ && Number(r.triggerPx) === Number(sent.triggerPx) && Number(r.sz) === Number(sent.sz) && r.reduceOnly === sent.reduceOnly
1966
+ && typeof r.oid === "number" && Number.isSafeInteger(r.oid));
1967
+ if (hits.length === 1)
1968
+ return String(hits[0].oid);
1969
+ if (hits.length > 1)
1970
+ return null;
1971
+ }
1972
+ return null;
973
1973
  }
974
1974
  /**
975
1975
  * Place a TWAP order.
@@ -978,6 +1978,7 @@ export class HyperliquidAdapter {
978
1978
  */
979
1979
  async twapOrder(symbol, side, size, durationMinutes, opts) {
980
1980
  const assetIndex = await this.getAssetIndex(symbol);
1981
+ hlAssertTwapBounds(symbol, size, durationMinutes, await this.markPrice(symbol));
981
1982
  const action = {
982
1983
  type: "twapOrder",
983
1984
  twap: {
@@ -989,7 +1990,9 @@ export class HyperliquidAdapter {
989
1990
  t: opts?.randomize ?? true,
990
1991
  },
991
1992
  };
992
- return this._sendExchangeAction(action);
1993
+ const result = await this._sendExchangeAction(action);
1994
+ hlValidateTwapResult(result, `TWAP ${side} ${size} ${symbol}`);
1995
+ return result;
993
1996
  }
994
1997
  /**
995
1998
  * Cancel a TWAP order.
@@ -1001,53 +2004,129 @@ export class HyperliquidAdapter {
1001
2004
  a: assetIndex,
1002
2005
  t: twapId,
1003
2006
  };
1004
- return this._sendExchangeAction(action);
2007
+ const result = await this._sendExchangeAction(action);
2008
+ hlValidateTwapResult(result, `TWAP cancel ${twapId}`);
2009
+ return result;
2010
+ }
2011
+ /** Venue margin restriction of `symbol` (meta marginMode / onlyIsolated); null = unrestricted. */
2012
+ marginModeOf(symbol) {
2013
+ return this._marginModeMap.get(this.resolveSymbol(symbol.toUpperCase())) ?? null;
1005
2014
  }
1006
2015
  /**
1007
- * Update leverage for a symbol.
1008
- * Uses SDK's built-in updateLeverage method.
2016
+ * Update leverage for a symbol — L1 action `updateLeverage` (agent-signable),
2017
+ * python SDK update_leverage. Cross is refused up front on isolated-only assets.
1009
2018
  */
1010
2019
  async updateLeverage(symbol, leverage, isCross = true) {
1011
2020
  this.ensureSigner();
1012
- return this.sdk.exchange.updateLeverage(this.resolveSymbol(symbol), isCross ? "cross" : "isolated", leverage);
2021
+ let asset;
2022
+ try { // pre-send (see marketOrder): the leverage change was not sent
2023
+ asset = await this.getAssetIndex(symbol.toUpperCase());
2024
+ }
2025
+ catch (e) {
2026
+ throw orderNotSent("hyperliquid", `leverage ${leverage}x ${symbol.toUpperCase()}`, e);
2027
+ }
2028
+ if (!Number.isInteger(leverage) || leverage < 1) {
2029
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid leverage must be a positive integer (got ${leverage})`, { exchange: "hyperliquid" });
2030
+ }
2031
+ const mm = this.marginModeOf(symbol);
2032
+ if (isCross && mm !== null) {
2033
+ throw unsupported("Hyperliquid", "cross margin", `${symbol.toUpperCase()} is isolated-only on the venue (marginMode ${mm})`, "Use --isolated for this market");
2034
+ }
2035
+ return this._signAndSendAction({ type: "updateLeverage", asset, isCross, leverage });
1013
2036
  }
1014
2037
  /**
1015
- * Update isolated margin for a position.
1016
- * amount > 0 to add margin, amount < 0 to remove
2038
+ * Add (amount > 0) or remove (amount < 0) isolated margin — L1 action
2039
+ * `updateIsolatedMargin`, python SDK update_isolated_margin: `isBuy` is always
2040
+ * true and the SIGNED `ntli` (USDC × 1e6) carries the direction.
1017
2041
  */
1018
2042
  async updateIsolatedMargin(symbol, amount) {
1019
2043
  this.ensureSigner();
1020
- return this.sdk.exchange.updateIsolatedMargin(this.resolveSymbol(symbol), amount > 0, Math.round(Math.abs(amount) * 1e6));
2044
+ const ntli = Math.round(amount * 1e6);
2045
+ if (!Number.isFinite(amount) || ntli === 0) {
2046
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid isolated margin change must be a non-zero USDC amount (got ${amount})`, { exchange: "hyperliquid" });
2047
+ }
2048
+ const asset = await this.getAssetIndex(symbol.toUpperCase());
2049
+ if (ntli < 0 && this.marginModeOf(symbol) === "strictIsolated") {
2050
+ throw unsupported("Hyperliquid", "isolated margin removal", `${symbol.toUpperCase()} is strictIsolated on the venue (margin cannot be removed)`, "Close or reduce the position to release margin");
2051
+ }
2052
+ return this._signAndSendAction({ type: "updateIsolatedMargin", asset, isBuy: true, ntli });
1021
2053
  }
1022
2054
  /**
1023
- * Withdraw from Hyperliquid L1 bridge.
1024
- * Python SDK: withdraw_from_bridge(amount, destination) → action type "withdraw3"
2055
+ * Withdraw USDC over CCTP (native USDC) — user-signed `sendToEvmWithData` (master only):
2056
+ * HyperCore is debited, the USDC is burned on HyperEVM and CCTP mints it on `chain`
2057
+ * (default Arbitrum). The Arbitrum Bridge2 `withdraw3` path is no longer used.
2058
+ * Docs: developers.circle.com/cctp/howtos/withdraw-usdc-from-hypercore-to-evm —
2059
+ * sourceDex "spot" (unified accounts hold USDC in spot) or "" (standard accounts' perp
2060
+ * balance); data "0x" = automatic forwarding, which CCTP charges a fixed fee for
2061
+ * (Arbitrum 0.2 USDC; an amount below the fee reverts).
1025
2062
  */
1026
- async withdraw(amount, destination, _opts) {
1027
- this.ensureSigner();
1028
- try {
1029
- return await this.sdk.exchange.initiateWithdrawal(destination, parseFloat(amount));
2063
+ async withdraw(amount, destination, opts) {
2064
+ if (opts?.assetId !== undefined || opts?.routeType !== undefined) {
2065
+ throw new PerpError("INVALID_PARAMS", "Hyperliquid withdraw takes no assetId / routeType (USDC over CCTP; pick the destination with --chain)", { exchange: "hyperliquid" });
1030
2066
  }
1031
- catch {
1032
- // Fallback: try raw action if SDK method signature changed
1033
- const action = {
1034
- type: "withdraw3",
1035
- hyperliquidChain: this._testnet ? "Testnet" : "Mainnet",
1036
- signatureChainId: this._testnet ? "0x66eee" : "0xa4b1",
1037
- destination,
1038
- amount,
1039
- time: Date.now(),
1040
- };
1041
- return this._sendExchangeAction(action);
2067
+ const chain = opts?.chain ?? "arbitrum";
2068
+ const route = HL_CCTP_ROUTES[chain];
2069
+ const amountStr = hlAmountString(amount);
2070
+ if (route.forwardingFeeUsdc !== null && !(Number(amountStr) > Number(route.forwardingFeeUsdc))) {
2071
+ throw new PerpError("SIZE_TOO_SMALL", `Hyperliquid CCTP withdraw ${amountStr} USDC does not exceed the ${chain} forwarding fee ${route.forwardingFeeUsdc} USDC (the transfer would revert); nothing was sent`, { exchange: "hyperliquid" });
1042
2072
  }
2073
+ return this._signAndSendUserAction("sendToEvmWithData", {
2074
+ token: "USDC",
2075
+ amount: amountStr,
2076
+ sourceDex: this.isUnifiedAccount ? "spot" : "",
2077
+ destinationRecipient: hlDestination(destination),
2078
+ addressEncoding: "hex",
2079
+ destinationChainId: route.cctpDomain,
2080
+ gasLimit: 200000,
2081
+ data: "0x",
2082
+ });
1043
2083
  }
1044
2084
  /**
1045
- * Transfer USD between accounts on Hyperliquid L1.
1046
- * Python SDK: usd_transfer(amount, destination)
2085
+ * Send USDC to another Hyperliquid account — user-signed `usdSend` (master only),
2086
+ * python SDK usd_transfer.
1047
2087
  */
1048
2088
  async usdTransfer(amount, destination) {
2089
+ return this._signAndSendUserAction("usdSend", { destination: hlDestination(destination), amount: hlAmountString(String(amount)) });
2090
+ }
2091
+ /**
2092
+ * Move USDC between this account's spot and perp balances — user-signed
2093
+ * `usdClassTransfer` (master only), python SDK usd_class_transfer.
2094
+ */
2095
+ async usdClassTransfer(amount, toPerp) {
2096
+ return this._signAndSendUserAction("usdClassTransfer", { amount: hlAmountString(amount), toPerp });
2097
+ }
2098
+ /**
2099
+ * Move this dex's collateral token between spot and this adapter's perp dex as an
2100
+ * L1 action `agentSendAsset` (agent-signable; destination = own account). Spot →
2101
+ * perp when `toPerp`, else perp → spot. The token is "<name>:<tokenId>" of the
2102
+ * dex collateral token, read from spotMeta (never hardcoded).
2103
+ */
2104
+ async agentSendAsset(amount, toPerp) {
1049
2105
  this.ensureSigner();
1050
- return this.sdk.exchange.usdTransfer(destination, amount);
2106
+ this.ensureAddress();
2107
+ const token = await this._collateralTokenWire();
2108
+ const nonce = HyperliquidAdapter._nextNonce();
2109
+ const action = {
2110
+ type: "agentSendAsset",
2111
+ destination: this._address.toLowerCase(),
2112
+ sourceDex: toPerp ? "spot" : this._dex,
2113
+ destinationDex: toPerp ? this._dex : "spot",
2114
+ token,
2115
+ amount: hlAmountString(amount),
2116
+ fromSubAccount: "",
2117
+ nonce,
2118
+ };
2119
+ return this._signAndSendAction(action, nonce);
2120
+ }
2121
+ /** "<name>:<tokenId>" of this dex's collateral token (meta{dex}.collateralToken → spotMeta tokens). */
2122
+ async _collateralTokenWire() {
2123
+ const idx = await this._getCollateralToken();
2124
+ const spotMeta = await this._infoPost({ type: "spotMeta" });
2125
+ const t = (spotMeta?.tokens ?? []).find((x) => x.index === idx);
2126
+ if (!t || typeof t.name !== "string" || typeof t.tokenId !== "string" || !/^0x[0-9a-fA-F]+$/.test(t.tokenId)) {
2127
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid spotMeta has no token id for collateral token index ${idx}`, { exchange: "hyperliquid" });
2128
+ }
2129
+ return `${t.name}:${t.tokenId}`;
1051
2130
  }
1052
2131
  /**
1053
2132
  * Create a sub-account.
@@ -1072,10 +2151,18 @@ export class HyperliquidAdapter {
1072
2151
  return this._sendExchangeAction(action);
1073
2152
  }
1074
2153
  /**
1075
- * Modify an existing order.
2154
+ * Modify an existing order (native batchModify). `newSize` = new open size.
2155
+ * `tif`/`reduceOnly` must be the original order's (editOrder reads them from
2156
+ * frontendOpenOrders) — a modify otherwise silently changes the order's TIF.
1076
2157
  */
1077
2158
  async modifyOrder(symbol, orderId, newSide, newPrice, newSize, opts) {
1078
- const assetIndex = await this.getAssetIndex(symbol);
2159
+ let assetIndex;
2160
+ try { // pre-send (see marketOrder)
2161
+ assetIndex = await this.getAssetIndex(symbol);
2162
+ }
2163
+ catch (e) {
2164
+ throw orderNotSent("hyperliquid", `modify ${orderId} ${symbol.toUpperCase()}`, e);
2165
+ }
1079
2166
  const szDec = this.getSzDecimals(symbol);
1080
2167
  // Remove trailing zeros (HL API requirement)
1081
2168
  const trimZeros = (s) => {
@@ -1091,41 +2178,87 @@ export class HyperliquidAdapter {
1091
2178
  order: {
1092
2179
  a: assetIndex,
1093
2180
  b: newSide === "buy",
1094
- p: trimZeros(this._hlRoundPrice(Number(newPrice), szDec)),
1095
- s: trimZeros(Number(newSize).toFixed(szDec)),
1096
- r: opts?.reduceOnly ?? false,
1097
- t: { limit: { tif: "Gtc" } },
2181
+ p: trimZeros(hlRequirePriceOnGrid(newPrice, szDec, 6, "modify price")),
2182
+ s: trimZeros(floorSize(newSize, hlSizeStep(szDec))), // floored to the lot; 0 → SIZE_TOO_SMALL
2183
+ r: opts.reduceOnly,
2184
+ t: { limit: { tif: opts.tif } },
2185
+ ...(opts.cloid !== undefined && opts.cloid !== null ? { c: opts.cloid } : {}),
1098
2186
  },
1099
2187
  }],
1100
2188
  };
1101
- return this._signAndSendAction(action);
2189
+ const result = await this._signAndSendAction(action);
2190
+ this._validateModifyResult(result, orderId);
2191
+ return result;
2192
+ }
2193
+ /**
2194
+ * Validate that the venue actually accepted an order/cancel/modify action.
2195
+ *
2196
+ * HL returns the envelope `status: "ok"` even when the individual entry was
2197
+ * rejected — the real verdict is per-entry in `response.data.statuses[]`,
2198
+ * where an accepted entry is `{filled}` / `{resting}` / the string "success"
2199
+ * and a rejected one is `{error: "..."}`. `_signAndSendAction` only throws on
2200
+ * the ENVELOPE, so every path that skipped this check reported a rejection to
2201
+ * the user as a green success.
2202
+ * A per-entry `{error}` is the venue's definite verdict on that entry — including an
2203
+ * IOC's "could not immediately match" (cancelled with nothing filled) — so it is
2204
+ * ORDER_REJECTED (definite, never quarantined as an unknown outcome).
2205
+ * Also used by HyperliquidSpotAdapter (spot limit orders).
2206
+ */
2207
+ _validateVenueAck(result, what) {
2208
+ const r = result;
2209
+ const statuses = r?.response?.data?.statuses;
2210
+ if (!Array.isArray(statuses) || statuses.length === 0) {
2211
+ // The signed action was already accepted by the envelope: without a per-entry verdict the
2212
+ // order / modify / cancel may or may not have taken effect (like marketOrder's
2213
+ // _validateOrderFill) — ORDER_PENDING, never the retryable EXCHANGE_ERROR.
2214
+ throw orderOutcomeUnknown("hyperliquid", what, new Error("no order status in response"), /^cancel/i.test(what)
2215
+ ? `Do NOT assume the order was cancelled (nor resubmit the cancel blindly) — check open orders first: perp -e hyperliquid account orders`
2216
+ : `Do NOT resubmit — the ${what} may have taken effect. Check open orders and positions first: perp -e hyperliquid account orders / portfolio`);
2217
+ }
2218
+ const errors = statuses
2219
+ .map((s) => (s && typeof s === "object" ? s.error : undefined))
2220
+ .filter((e) => typeof e === "string" && e.length > 0);
2221
+ if (errors.length > 0) {
2222
+ const noMatch = errors.some(e => /could not immediately match/i.test(e));
2223
+ throw new PerpError("ORDER_REJECTED", `Hyperliquid ${what} rejected: ${errors.join("; ")}${noMatch ? " (IOC cancelled by the venue — nothing filled)" : ""}`, { exchange: "hyperliquid", venueErrors: errors });
2224
+ }
2225
+ }
2226
+ _validateModifyResult(result, orderId) {
2227
+ this._validateVenueAck(result, `modify ${orderId}`);
1102
2228
  }
1103
2229
  /**
1104
2230
  * Schedule cancel: cancel all orders at a future time.
1105
2231
  * Max 10 triggers per day.
1106
2232
  */
1107
2233
  async scheduleCancel(timeMs) {
1108
- const action = {
1109
- type: "scheduleCancel",
1110
- time: timeMs,
1111
- };
2234
+ // Official python SDK schedule_cancel: the action is {"type": "scheduleCancel"} and
2235
+ // `if time is not None: schedule_cancel_action["time"] = time` — when unsetting, the
2236
+ // `time` key is absent from BOTH the signed (msgpack) action and the posted JSON.
2237
+ const action = { type: "scheduleCancel" };
2238
+ if (timeMs !== undefined)
2239
+ action.time = timeMs;
1112
2240
  return this._sendExchangeAction(action);
1113
2241
  }
1114
2242
  /**
1115
- * Set referral code. Silent — does not throw.
2243
+ * Set the account's referrer (one HL `setReferrer` action, signed + sent).
2244
+ * Called only from the explicit `wallet manage referral set` command — never on
2245
+ * adapter creation. Throws on a venue rejection (SSOT Rule #2).
1116
2246
  */
1117
- async autoSetReferrer(code) {
1118
- const referralCode = code || process.env.HL_REFERRAL_CODE || "HYPERCASH";
1119
- try {
1120
- const exchange = this.sdk.exchange;
1121
- if (typeof exchange.setReferrer === "function") {
1122
- await exchange.setReferrer(referralCode);
1123
- return;
1124
- }
2247
+ async setReferrer(code) {
2248
+ if (!code) {
2249
+ throw new PerpError("INVALID_PARAMS", "Hyperliquid referral code is empty", {
2250
+ remediation: "Pass a code: perp -e hyperliquid wallet manage referral set <code>",
2251
+ });
1125
2252
  }
1126
- catch {
1127
- // Already referred or method not available — both OK
2253
+ const res = await this._sendExchangeAction({ type: "setReferrer", code });
2254
+ const r = res;
2255
+ if (r && r.status === "err") {
2256
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid setReferrer rejected: ${String(r.response)}`, {
2257
+ exchange: "hyperliquid",
2258
+ remediation: "A Hyperliquid account can set a referrer only once; the venue message above says why it was rejected",
2259
+ });
1128
2260
  }
2261
+ return res;
1129
2262
  }
1130
2263
  /**
1131
2264
  * Get funding history for a symbol.
@@ -1189,8 +2322,11 @@ export class HyperliquidAdapter {
1189
2322
  /**
1190
2323
  * Query a specific order by OID.
1191
2324
  */
2325
+ /** info `orderStatus`: `oid` "Either u64 representing the order id or 16-byte hex string representing the client order id". */
1192
2326
  async queryOrder(orderId) {
1193
2327
  this.ensureAddress();
2328
+ if (typeof orderId === "string")
2329
+ HyperliquidAdapter.requireCloid(orderId);
1194
2330
  const baseUrl = this._testnet
1195
2331
  ? "https://api.hyperliquid-testnet.xyz"
1196
2332
  : "https://api.hyperliquid.xyz";
@@ -1206,14 +2342,8 @@ export class HyperliquidAdapter {
1206
2342
  * Action type: approveBuilderFee
1207
2343
  */
1208
2344
  async approveBuilderFee(builder, maxFeeRate) {
1209
- const action = {
1210
- type: "approveBuilderFee",
1211
- hyperliquidChain: this._testnet ? "Testnet" : "Mainnet",
1212
- signatureChainId: this._testnet ? "0x66eee" : "0xa4b1",
1213
- maxFeeRate,
1214
- builder,
1215
- };
1216
- return this._sendExchangeAction(action);
2345
+ // USER-signed (HyperliquidSignTransaction EIP-712), master only — never the L1 scheme.
2346
+ return this._signAndSendUserAction("approveBuilderFee", { maxFeeRate, builder: builder.toLowerCase() });
1217
2347
  }
1218
2348
  /**
1219
2349
  * Vault deposit/withdraw.
@@ -1231,15 +2361,12 @@ export class HyperliquidAdapter {
1231
2361
  * Delegate/undelegate tokens for staking.
1232
2362
  */
1233
2363
  async tokenDelegate(validator, wei, isUndelegate = false) {
1234
- const action = {
1235
- type: "tokenDelegate",
1236
- hyperliquidChain: this._testnet ? "Testnet" : "Mainnet",
1237
- signatureChainId: this._testnet ? "0x66eee" : "0xa4b1",
1238
- validator,
1239
- isUndelegate,
1240
- wei,
1241
- };
1242
- return this._sendExchangeAction(action);
2364
+ // USER-signed staking action (master only). `wei` is a uint64 sent as a JSON number,
2365
+ // so it must be an exact safe integer.
2366
+ if (!/^\d+$/.test(wei) || !Number.isSafeInteger(Number(wei)) || Number(wei) === 0) {
2367
+ throw new PerpError("INVALID_PARAMS", `Hyperliquid tokenDelegate wei must be a positive integer ≤ ${Number.MAX_SAFE_INTEGER} (got ${wei})`, { exchange: "hyperliquid" });
2368
+ }
2369
+ return this._signAndSendUserAction("tokenDelegate", { validator: validator.toLowerCase(), wei: Number(wei), isUndelegate });
1243
2370
  }
1244
2371
  /**
1245
2372
  * Claim staking rewards.
@@ -1326,7 +2453,7 @@ export class HyperliquidAdapter {
1326
2453
  return this._infoPost({ type: "delegations", user: this._address });
1327
2454
  }
1328
2455
  /**
1329
- * POST to /info endpoint.
2456
+ * POST to /info endpoint (all reads — no SDK). A non-2xx response throws.
1330
2457
  */
1331
2458
  async _infoPost(body) {
1332
2459
  const baseUrl = this._testnet
@@ -1337,6 +2464,14 @@ export class HyperliquidAdapter {
1337
2464
  headers: { "Content-Type": "application/json" },
1338
2465
  body: JSON.stringify(body),
1339
2466
  });
2467
+ if (!res.ok) {
2468
+ const text = await res.text();
2469
+ // HTTP 429 = the IP weight budget is spent (docs "Rate limits and user limits": "REST requests
2470
+ // share an aggregated weight limit of 1200 per minute") → typed RATE_LIMITED.
2471
+ if (res.status === 429)
2472
+ throw rateLimitedError("hyperliquid", res.headers.get("retry-after"), `info ${String(body.type)}: ${text}`);
2473
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid info ${String(body.type)} failed (${res.status}): ${text.slice(0, 200)}`, { exchange: "hyperliquid" });
2474
+ }
1340
2475
  return res.json();
1341
2476
  }
1342
2477
  /**
@@ -1346,17 +2481,8 @@ export class HyperliquidAdapter {
1346
2481
  async exchangeAction(action) {
1347
2482
  return this._signAndSendAction(action);
1348
2483
  }
1349
- /**
1350
- * Send a raw exchange action through the SDK.
1351
- * Used for methods not directly exposed by the SDK.
1352
- */
2484
+ /** Sign (L1 scheme, active signer tier) and send an exchange action. */
1353
2485
  async _sendExchangeAction(action) {
1354
- const exchange = this.sdk.exchange;
1355
- // Try using the SDK's internal postAction if available
1356
- if (typeof exchange.postAction === "function") {
1357
- return exchange.postAction(action);
1358
- }
1359
- // Fallback: sign and send directly (bypasses SDK)
1360
2486
  return this._signAndSendAction(action);
1361
2487
  }
1362
2488
  }