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.
- package/README.md +278 -31
- package/dist/_stderr-filter.js +0 -1
- package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
- package/dist/agent-wallet/lighter-keystore.js +92 -10
- package/dist/agent-wallet/passphrase.d.ts +1 -1
- package/dist/agent-wallet/passphrase.js +16 -3
- package/dist/agent-wallet/store.d.ts +37 -4
- package/dist/agent-wallet/store.js +132 -0
- package/dist/agent-wallet/venue-keystore.d.ts +14 -0
- package/dist/agent-wallet/venue-keystore.js +121 -0
- package/dist/api/public/hyperliquid.js +4 -4
- package/dist/api/public/lighter.js +5 -5
- package/dist/api/public/pacifica.d.ts +0 -1
- package/dist/api/public/pacifica.js +4 -13
- package/dist/api/public/urls.d.ts +9 -1
- package/dist/api/public/urls.js +35 -13
- package/dist/arb/file-lock.d.ts +11 -0
- package/dist/arb/file-lock.js +74 -0
- package/dist/arb/index.d.ts +2 -2
- package/dist/arb/index.js +2 -2
- package/dist/arb/pending-entry.d.ts +32 -0
- package/dist/arb/pending-entry.js +77 -0
- package/dist/arb/sizing.d.ts +6 -13
- package/dist/arb/sizing.js +12 -51
- package/dist/arb/state.d.ts +45 -12
- package/dist/arb/state.js +214 -42
- package/dist/arb/utils.d.ts +20 -6
- package/dist/arb/utils.js +43 -39
- package/dist/bot/conditions.d.ts +4 -4
- package/dist/bot/conditions.js +11 -6
- package/dist/bot/config.d.ts +17 -1
- package/dist/bot/config.js +48 -12
- package/dist/bot/engine.d.ts +61 -2
- package/dist/bot/engine.js +610 -144
- package/dist/bot/fill-size.d.ts +7 -0
- package/dist/bot/fill-size.js +18 -0
- package/dist/bot/index.d.ts +1 -1
- package/dist/bot/index.js +1 -1
- package/dist/bot/perp-order.d.ts +99 -0
- package/dist/bot/perp-order.js +299 -0
- package/dist/bot/presets.d.ts +7 -0
- package/dist/bot/presets.js +17 -0
- package/dist/bot/quarantine.d.ts +75 -0
- package/dist/bot/quarantine.js +190 -0
- package/dist/bot/quote-rules.d.ts +23 -0
- package/dist/bot/quote-rules.js +69 -0
- package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
- package/dist/bot/strategies/avellaneda-mm.js +72 -70
- package/dist/bot/strategies/basis-arb.js +2 -0
- package/dist/bot/strategies/dca-strategy.d.ts +2 -0
- package/dist/bot/strategies/dca-strategy.js +5 -0
- package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
- package/dist/bot/strategies/funding-arb-strategy.js +571 -380
- package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
- package/dist/bot/strategies/funding-arb-utils.js +159 -195
- package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
- package/dist/bot/strategies/funding-arb-v2.js +258 -47
- package/dist/bot/strategies/funding-auto.js +215 -194
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
- package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
- package/dist/bot/strategies/spot-perp-executor.js +319 -120
- package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-state.js +70 -0
- package/dist/bot/strategy-types.d.ts +6 -0
- package/dist/bot/tui/BotDashboard.d.ts +7 -2
- package/dist/bot/tui/BotDashboard.js +6 -3
- package/dist/bridge-engine.d.ts +120 -19
- package/dist/bridge-engine.js +710 -422
- package/dist/bridge-signers.d.ts +54 -0
- package/dist/bridge-signers.js +161 -0
- package/dist/cache.d.ts +4 -1
- package/dist/cache.js +11 -0
- package/dist/cli-flags.d.ts +50 -0
- package/dist/cli-flags.js +104 -0
- package/dist/cli-program.d.ts +35 -0
- package/dist/cli-program.js +286 -0
- package/dist/cli-spec.js +1 -1
- package/dist/client-id-tracker.js +11 -2
- package/dist/commands/account.d.ts +7 -1
- package/dist/commands/account.js +74 -84
- package/dist/commands/agent-import.d.ts +114 -0
- package/dist/commands/agent-import.js +794 -0
- package/dist/commands/agent.d.ts +232 -2
- package/dist/commands/agent.js +1858 -955
- package/dist/commands/alerts.d.ts +1 -0
- package/dist/commands/alerts.js +31 -28
- package/dist/commands/arb/index.d.ts +60 -1
- package/dist/commands/arb/index.js +381 -384
- package/dist/commands/arb-auto.d.ts +132 -4
- package/dist/commands/arb-auto.js +1474 -967
- package/dist/commands/arb.d.ts +3 -1
- package/dist/commands/arb.js +62 -30
- package/dist/commands/bot.js +127 -47
- package/dist/commands/bridge.d.ts +2 -1
- package/dist/commands/bridge.js +339 -192
- package/dist/commands/builder.d.ts +55 -0
- package/dist/commands/builder.js +387 -0
- package/dist/commands/funds-onchain.d.ts +32 -0
- package/dist/commands/funds-onchain.js +286 -0
- package/dist/commands/funds.d.ts +25 -0
- package/dist/commands/funds.js +939 -245
- package/dist/commands/history.js +72 -45
- package/dist/commands/init.d.ts +107 -15
- package/dist/commands/init.js +1116 -249
- package/dist/commands/jobs.d.ts +2 -1
- package/dist/commands/jobs.js +77 -15
- package/dist/commands/manage.js +136 -91
- package/dist/commands/market.d.ts +4 -1
- package/dist/commands/market.js +35 -32
- package/dist/commands/outcome.d.ts +10 -0
- package/dist/commands/outcome.js +396 -0
- package/dist/commands/plan.js +52 -12
- package/dist/commands/portfolio.d.ts +1 -0
- package/dist/commands/portfolio.js +60 -72
- package/dist/commands/rebalance.d.ts +2 -1
- package/dist/commands/rebalance.js +33 -43
- package/dist/commands/risk.js +46 -19
- package/dist/commands/settings.d.ts +1 -0
- package/dist/commands/settings.js +246 -51
- package/dist/commands/trade.d.ts +50 -0
- package/dist/commands/trade.js +1943 -695
- package/dist/commands/wallet.d.ts +30 -1
- package/dist/commands/wallet.js +316 -337
- package/dist/config.d.ts +60 -9
- package/dist/config.js +252 -82
- package/dist/constants.d.ts +29 -4
- package/dist/constants.js +62 -20
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.js +9 -6
- package/dist/dashboard/ui.js +6 -4
- package/dist/dashboard/ws-feeds.js +20 -8
- package/dist/dex-asset-map.js +5 -3
- package/dist/errors.d.ts +122 -0
- package/dist/errors.js +194 -0
- package/dist/event-stream.js +75 -19
- package/dist/evm-master-tx.d.ts +59 -0
- package/dist/evm-master-tx.js +154 -0
- package/dist/exchanges/adapter-network.d.ts +10 -0
- package/dist/exchanges/adapter-network.js +10 -0
- package/dist/exchanges/arcus/adapter.d.ts +196 -0
- package/dist/exchanges/arcus/adapter.js +1092 -0
- package/dist/exchanges/arcus/agent.d.ts +55 -0
- package/dist/exchanges/arcus/agent.js +188 -0
- package/dist/exchanges/arcus/client.d.ts +63 -0
- package/dist/exchanges/arcus/client.js +285 -0
- package/dist/exchanges/arcus/public.d.ts +65 -0
- package/dist/exchanges/arcus/public.js +138 -0
- package/dist/exchanges/arcus/signing.d.ts +176 -0
- package/dist/exchanges/arcus/signing.js +288 -0
- package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
- package/dist/exchanges/arcus/spot-chain.js +241 -0
- package/dist/exchanges/arcus/spot-router.d.ts +177 -0
- package/dist/exchanges/arcus/spot-router.js +183 -0
- package/dist/exchanges/arcus/spot.d.ts +109 -0
- package/dist/exchanges/arcus/spot.js +645 -0
- package/dist/exchanges/aster-funds.d.ts +127 -0
- package/dist/exchanges/aster-funds.js +252 -0
- package/dist/exchanges/aster-nonce.d.ts +2 -0
- package/dist/exchanges/aster-nonce.js +107 -0
- package/dist/exchanges/aster-spot.d.ts +117 -0
- package/dist/exchanges/aster-spot.js +686 -0
- package/dist/exchanges/aster-typed-data.d.ts +133 -7
- package/dist/exchanges/aster-typed-data.js +121 -7
- package/dist/exchanges/aster.d.ts +219 -6
- package/dist/exchanges/aster.js +1023 -153
- package/dist/exchanges/builder-fee.d.ts +254 -0
- package/dist/exchanges/builder-fee.js +421 -0
- package/dist/exchanges/canonical-symbols.d.ts +32 -0
- package/dist/exchanges/canonical-symbols.js +96 -0
- package/dist/exchanges/capabilities.d.ts +7 -1
- package/dist/exchanges/capabilities.js +7 -0
- package/dist/exchanges/hl-signing.d.ts +165 -0
- package/dist/exchanges/hl-signing.js +158 -0
- package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
- package/dist/exchanges/hyperliquid-outcome.js +646 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
- package/dist/exchanges/hyperliquid-spot.js +308 -117
- package/dist/exchanges/hyperliquid.d.ts +462 -57
- package/dist/exchanges/hyperliquid.js +1575 -449
- package/dist/exchanges/index.d.ts +6 -0
- package/dist/exchanges/index.js +4 -0
- package/dist/exchanges/interface.d.ts +63 -10
- package/dist/exchanges/interface.js +18 -3
- package/dist/exchanges/ioc-outcome.d.ts +44 -0
- package/dist/exchanges/ioc-outcome.js +57 -0
- package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
- package/dist/exchanges/legacy-trading-v2.js +940 -0
- package/dist/exchanges/lighter-public.d.ts +43 -0
- package/dist/exchanges/lighter-public.js +276 -0
- package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
- package/dist/exchanges/lighter-rh-deposit.js +185 -0
- package/dist/exchanges/lighter-signer.d.ts +123 -0
- package/dist/exchanges/lighter-signer.js +215 -0
- package/dist/exchanges/lighter-spot.d.ts +91 -13
- package/dist/exchanges/lighter-spot.js +595 -233
- package/dist/exchanges/lighter.d.ts +611 -39
- package/dist/exchanges/lighter.js +1890 -649
- package/dist/exchanges/ondo/adapter.d.ts +166 -0
- package/dist/exchanges/ondo/adapter.js +835 -0
- package/dist/exchanges/ondo/agent.d.ts +31 -0
- package/dist/exchanges/ondo/agent.js +101 -0
- package/dist/exchanges/ondo/client.d.ts +60 -0
- package/dist/exchanges/ondo/client.js +297 -0
- package/dist/exchanges/ondo/funds.d.ts +122 -0
- package/dist/exchanges/ondo/funds.js +290 -0
- package/dist/exchanges/ondo/public.d.ts +95 -0
- package/dist/exchanges/ondo/public.js +214 -0
- package/dist/exchanges/ondo/spot.d.ts +127 -0
- package/dist/exchanges/ondo/spot.js +392 -0
- package/dist/exchanges/outcome-interface.d.ts +140 -0
- package/dist/exchanges/outcome-interface.js +16 -0
- package/dist/exchanges/pacifica-spot.d.ts +83 -0
- package/dist/exchanges/pacifica-spot.js +324 -0
- package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
- package/dist/exchanges/pacifica-typed-data.js +57 -9
- package/dist/exchanges/pacifica.d.ts +288 -4
- package/dist/exchanges/pacifica.js +1013 -76
- package/dist/exchanges/precision.d.ts +47 -0
- package/dist/exchanges/precision.js +209 -0
- package/dist/exchanges/qfex/adapter.d.ts +341 -0
- package/dist/exchanges/qfex/adapter.js +1383 -0
- package/dist/exchanges/qfex/agent.d.ts +58 -0
- package/dist/exchanges/qfex/agent.js +188 -0
- package/dist/exchanges/qfex/client.d.ts +54 -0
- package/dist/exchanges/qfex/client.js +138 -0
- package/dist/exchanges/qfex/errors.d.ts +70 -0
- package/dist/exchanges/qfex/errors.js +236 -0
- package/dist/exchanges/qfex/public.d.ts +122 -0
- package/dist/exchanges/qfex/public.js +268 -0
- package/dist/exchanges/qfex/signing.d.ts +160 -0
- package/dist/exchanges/qfex/signing.js +283 -0
- package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
- package/dist/exchanges/qfex/ws-transport.js +798 -0
- package/dist/exchanges/rate-limiter.d.ts +12 -0
- package/dist/exchanges/rate-limiter.js +64 -0
- package/dist/exchanges/registry.d.ts +14 -2
- package/dist/exchanges/registry.js +20 -32
- package/dist/exchanges/slippage.d.ts +37 -0
- package/dist/exchanges/slippage.js +70 -0
- package/dist/exchanges/spot-factory.d.ts +166 -0
- package/dist/exchanges/spot-factory.js +431 -0
- package/dist/exchanges/spot-interface.d.ts +98 -4
- package/dist/exchanges/spot-interface.js +27 -1
- package/dist/exchanges/spot-result.d.ts +79 -0
- package/dist/exchanges/spot-result.js +273 -0
- package/dist/exchanges/trading-v2.d.ts +117 -0
- package/dist/exchanges/trading-v2.js +301 -0
- package/dist/exchanges/venue-types.d.ts +231 -0
- package/dist/exchanges/venue-types.js +87 -0
- package/dist/exchanges/venue.d.ts +211 -0
- package/dist/exchanges/venue.js +132 -0
- package/dist/exchanges/venues/arcus.d.ts +8 -0
- package/dist/exchanges/venues/arcus.js +109 -0
- package/dist/exchanges/venues/aster.d.ts +49 -0
- package/dist/exchanges/venues/aster.js +176 -0
- package/dist/exchanges/venues/create-helpers.d.ts +18 -0
- package/dist/exchanges/venues/create-helpers.js +199 -0
- package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
- package/dist/exchanges/venues/hyperliquid.js +126 -0
- package/dist/exchanges/venues/index.d.ts +16 -0
- package/dist/exchanges/venues/index.js +26 -0
- package/dist/exchanges/venues/lighter-family.d.ts +92 -0
- package/dist/exchanges/venues/lighter-family.js +166 -0
- package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
- package/dist/exchanges/venues/lighter-rh.js +58 -0
- package/dist/exchanges/venues/lighter.d.ts +6 -0
- package/dist/exchanges/venues/lighter.js +55 -0
- package/dist/exchanges/venues/ondo.d.ts +34 -0
- package/dist/exchanges/venues/ondo.js +140 -0
- package/dist/exchanges/venues/pacifica.d.ts +8 -0
- package/dist/exchanges/venues/pacifica.js +95 -0
- package/dist/exchanges/venues/qfex.d.ts +28 -0
- package/dist/exchanges/venues/qfex.js +132 -0
- package/dist/execution-log.js +10 -2
- package/dist/funding/legacy-public.d.ts +31 -0
- package/dist/funding/legacy-public.js +369 -0
- package/dist/funding/normalize.d.ts +28 -16
- package/dist/funding/normalize.js +61 -29
- package/dist/funding/rates.d.ts +46 -11
- package/dist/funding/rates.js +228 -277
- package/dist/guardrail/perp-guardrail.js +0 -0
- package/dist/index.js +157 -682
- package/dist/jobs.d.ts +84 -6
- package/dist/jobs.js +239 -41
- package/dist/landing.d.ts +14 -0
- package/dist/landing.js +36 -0
- package/dist/mcp-server.d.ts +14 -4
- package/dist/mcp-server.js +820 -402
- package/dist/mcp-trade-guard.d.ts +16 -0
- package/dist/mcp-trade-guard.js +59 -0
- package/dist/pacifica/client.d.ts +41 -10
- package/dist/pacifica/client.js +167 -63
- package/dist/pacifica/constants.d.ts +0 -1
- package/dist/pacifica/constants.js +0 -5
- package/dist/pacifica/deposit.d.ts +30 -3
- package/dist/pacifica/deposit.js +119 -2
- package/dist/pacifica/faucet.d.ts +29 -0
- package/dist/pacifica/faucet.js +100 -0
- package/dist/pacifica/sign-tx.d.ts +15 -0
- package/dist/pacifica/sign-tx.js +41 -0
- package/dist/pacifica/types/account.d.ts +28 -4
- package/dist/pacifica/types/lake.d.ts +20 -5
- package/dist/pacifica/types/lake.js +4 -0
- package/dist/pacifica/types/market.d.ts +5 -0
- package/dist/pacifica/types/order.d.ts +4 -1
- package/dist/pacifica/types/position.d.ts +9 -1
- package/dist/plan-executor.d.ts +10 -5
- package/dist/plan-executor.js +165 -54
- package/dist/position-history.js +13 -0
- package/dist/rebalance.d.ts +26 -2
- package/dist/rebalance.js +86 -7
- package/dist/relay-swap.d.ts +232 -0
- package/dist/relay-swap.js +630 -0
- package/dist/risk.js +5 -1
- package/dist/secret-env.d.ts +15 -0
- package/dist/secret-env.js +36 -0
- package/dist/settings.d.ts +169 -6
- package/dist/settings.js +219 -38
- package/dist/signer/ows-evm.d.ts +13 -0
- package/dist/signer/ows-evm.js +22 -0
- package/dist/smart-order.d.ts +9 -0
- package/dist/smart-order.js +53 -8
- package/dist/strategies/dca.d.ts +6 -2
- package/dist/strategies/dca.js +66 -26
- package/dist/strategies/funding-arb.d.ts +11 -1
- package/dist/strategies/funding-arb.js +202 -89
- package/dist/strategies/grid.d.ts +31 -0
- package/dist/strategies/grid.js +89 -11
- package/dist/strategies/pending-stop.d.ts +8 -0
- package/dist/strategies/pending-stop.js +12 -0
- package/dist/strategies/split-order.d.ts +45 -17
- package/dist/strategies/split-order.js +157 -91
- package/dist/strategies/trailing-stop.d.ts +2 -1
- package/dist/strategies/trailing-stop.js +52 -6
- package/dist/strategies/twap.d.ts +6 -1
- package/dist/strategies/twap.js +39 -11
- package/dist/trade-validator.d.ts +22 -0
- package/dist/trade-validator.js +119 -15
- package/dist/utils/numeric.d.ts +46 -0
- package/dist/utils/numeric.js +79 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.js +96 -19
- package/dist/withdraw-gate.d.ts +49 -0
- package/dist/withdraw-gate.js +131 -0
- package/package.json +15 -6
- package/skills/perp-cli/SKILL.md +338 -134
- package/skills/perp-cli/references/agent-operations.md +181 -92
- package/skills/perp-cli/references/commands.md +475 -132
- package/skills/perp-cli/references/strategies.md +44 -38
- package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
- package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
- package/skills/perp-cli/scripts/preflight.sh +21 -14
- package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
- package/skills/perp-cli/scripts/validate-arb.sh +33 -16
- package/vendor/lighter-signer/SHA256SUMS +2 -0
- package/vendor/lighter-signer/VERSION +8 -0
- package/vendor/lighter-signer/lighter-signer.wasm +0 -0
- package/vendor/lighter-signer/wasm_exec.js +575 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
- 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
|
|
10
|
-
* portfolio mode
|
|
11
|
-
*
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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).
|
|
159
|
-
// accounts are NOT unified
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
this.
|
|
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(
|
|
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
|
-
|
|
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
|
|
663
|
+
return hit;
|
|
251
664
|
}
|
|
252
|
-
/**
|
|
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
|
-
|
|
674
|
+
if (this._assetMapError)
|
|
675
|
+
throw this._assetMapError;
|
|
259
676
|
if (this._szDecimalsMap.size === 0) {
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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.
|
|
298
|
-
this.
|
|
724
|
+
this._infoPost({ type: "metaAndAssetCtxs" }),
|
|
725
|
+
this._infoPost({ type: "allMids" }),
|
|
299
726
|
]);
|
|
300
|
-
|
|
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
|
-
|
|
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) :
|
|
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
|
|
772
|
+
indexPrice: ctx.oraclePx != null ? String(ctx.oraclePx) : null,
|
|
320
773
|
fundingRate: ctx.funding != null ? String(ctx.funding) : null,
|
|
321
|
-
volume24h: String(ctx.dayNtlVlm
|
|
322
|
-
openInterest: String(ctx.openInterest
|
|
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:
|
|
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.
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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.
|
|
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
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* - "
|
|
387
|
-
*
|
|
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
|
|
401
|
-
return
|
|
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 "
|
|
418
|
-
// "dexAbstraction" is HL's
|
|
419
|
-
//
|
|
420
|
-
//
|
|
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 "
|
|
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
|
-
/**
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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 =
|
|
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 +=
|
|
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":
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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:
|
|
458
|
-
//
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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((
|
|
982
|
+
return PORTFOLIO_COLLATERAL.some((p) => c === p || c.startsWith(`${p}-`));
|
|
480
983
|
});
|
|
481
|
-
if (
|
|
482
|
-
const summary =
|
|
483
|
-
process.stderr.write(`[hl] portfolio mode: non
|
|
484
|
-
`Equity reflects
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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:
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
581
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
597
|
-
this.
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
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
|
|
611
|
-
const
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
const
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}
|
|
738
|
-
|
|
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
|
-
|
|
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
|
|
1467
|
+
throw new HlErrResponse(typeof result.response === "string" ? result.response : JSON.stringify(result));
|
|
748
1468
|
}
|
|
749
1469
|
return result;
|
|
750
1470
|
}
|
|
751
|
-
/**
|
|
752
|
-
|
|
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
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
//
|
|
781
|
-
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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 =
|
|
945
|
-
|
|
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(
|
|
1889
|
+
p: trimZeros(roundedLimit),
|
|
957
1890
|
s: trimZeros(roundedSize),
|
|
958
1891
|
r: opts?.reduceOnly ?? true,
|
|
959
1892
|
t: {
|
|
960
1893
|
trigger: {
|
|
961
|
-
isMarket
|
|
1894
|
+
isMarket,
|
|
962
1895
|
triggerPx: trimZeros(roundedTrigger),
|
|
963
1896
|
tpsl,
|
|
964
1897
|
},
|
|
965
1898
|
},
|
|
966
1899
|
};
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
1016
|
-
*
|
|
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
|
-
|
|
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
|
|
1024
|
-
*
|
|
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,
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
*
|
|
1046
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1095
|
-
s: trimZeros(
|
|
1096
|
-
r: opts
|
|
1097
|
-
t: { limit: { tif:
|
|
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
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
|
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
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
1127
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
}
|