Skip to content

Commit 3066c33

Browse files
committed
feat: add Hyperliquid prediction market (HIP-4 outcome markets)
1 parent c5fd381 commit 3066c33

12 files changed

Lines changed: 1598 additions & 0 deletions

File tree

changelog.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,41 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [2.38.0] - 2026-05-08
6+
7+
### Feat: Hyperliquid prediction market (HIP-4 Outcome Markets)
8+
9+
Added Hyperliquid as the 12th supported exchange. Hyperliquid's HIP-4
10+
outcome markets are binary contracts that trade on the same HyperCore
11+
matching engine as their perps and spot, settling in USDH.
12+
13+
**Read-only (no credentials required):**
14+
- `fetchMarkets` / `fetchEvents` -- discovers outcomes via `outcomeMeta`
15+
- `fetchOrderBook` -- L2 book via `#coin` notation
16+
- `fetchOHLCV` -- candle snapshots (1m through 1M intervals)
17+
- `fetchTrades` -- recent trades
18+
19+
**User data (wallet address required):**
20+
- `fetchBalance` / `fetchPositions` / `fetchOpenOrders` / `fetchMyTrades`
21+
22+
**Trading (private key required):**
23+
- `buildOrder` / `submitOrder` / `createOrder` / `cancelOrder`
24+
- Full EIP-712 phantom agent signing (msgpack + keccak256 action hash)
25+
- Outcome market orders use `grouping: "na"` per HIP-4 spec
26+
27+
**Environment variables:**
28+
- `HYPERLIQUID_WALLET_ADDRESS` -- wallet address for read-only user data
29+
- `HYPERLIQUID_PRIVATE_KEY` -- EVM private key for trading (EIP-712)
30+
31+
**New dependency:** `msgpackr` for Hyperliquid action hash serialization.
32+
33+
### Files
34+
35+
- `core/src/exchanges/hyperliquid/` -- 7 files (config, utils, errors,
36+
fetcher, normalizer, auth, index)
37+
- `core/src/server/exchange-factory.ts` -- added `case "hyperliquid"`
38+
- `core/src/index.ts` -- added exports
39+
540
## [2.37.14] - 2026-05-08
641

742
### Feat: Polymarket deposit wallet support (signatureType 3)

core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"express": "^5.2.1",
6060
"isows": "^1.0.6",
6161
"jest": "^30.2.0",
62+
"msgpackr": "^2.0.1",
6263
"polymarket-us": "0.1.1",
6364
"tsx": "^4.21.0",
6465
"ws": "^8.18.0"
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { Wallet, utils } from 'ethers';
2+
import { Packr } from 'msgpackr';
3+
import { ExchangeCredentials } from '../../BaseExchange';
4+
import { AuthenticationError } from '../../errors';
5+
import { EXCHANGE_CHAIN_ID } from './config';
6+
7+
// Standard msgpack encoder — variableMapSize ensures fixmap/fixarray encoding
8+
// which matches the Python/Rust msgpack libraries that Hyperliquid's server uses.
9+
const packr = new Packr({ useRecords: false, variableMapSize: true });
10+
11+
// ----------------------------------------------------------------------------
12+
// EIP-712 domain and types for Hyperliquid L1 action signing
13+
// ----------------------------------------------------------------------------
14+
15+
const EIP712_DOMAIN = {
16+
name: 'Exchange',
17+
version: '1',
18+
chainId: EXCHANGE_CHAIN_ID,
19+
verifyingContract: '0x0000000000000000000000000000000000000000',
20+
};
21+
22+
const AGENT_TYPES = {
23+
Agent: [
24+
{ name: 'source', type: 'string' },
25+
{ name: 'connectionId', type: 'bytes32' },
26+
],
27+
};
28+
29+
// ----------------------------------------------------------------------------
30+
// Signature type
31+
// ----------------------------------------------------------------------------
32+
33+
export interface HyperliquidSignature {
34+
r: string;
35+
s: string;
36+
v: number;
37+
}
38+
39+
// ----------------------------------------------------------------------------
40+
// msgpack helpers -- Hyperliquid requires int64 encoding for large integers
41+
// ----------------------------------------------------------------------------
42+
43+
function convertLargeInts(obj: unknown): unknown {
44+
if (typeof obj === 'number' && Number.isInteger(obj) &&
45+
(obj >= 0x100000000 || obj < -0x80000000)) {
46+
return BigInt(obj);
47+
}
48+
if (Array.isArray(obj)) {
49+
return obj.map(convertLargeInts);
50+
}
51+
if (typeof obj === 'object' && obj !== null) {
52+
const result: Record<string, unknown> = {};
53+
for (const [key, val] of Object.entries(obj)) {
54+
if (val !== undefined) {
55+
result[key] = convertLargeInts(val);
56+
}
57+
}
58+
return result;
59+
}
60+
return obj;
61+
}
62+
63+
// ----------------------------------------------------------------------------
64+
// Action hash -- constructs the connectionId for the phantom agent
65+
// ----------------------------------------------------------------------------
66+
67+
function computeActionHash(
68+
action: Record<string, unknown>,
69+
vaultAddress: string | null,
70+
nonce: number,
71+
): string {
72+
// 1. msgpack-encode the action (large ints as int64)
73+
const actionBytes = packr.pack(convertLargeInts(action));
74+
75+
// 2. nonce as 8 bytes big-endian
76+
const nonceBytes = Buffer.alloc(8);
77+
nonceBytes.writeBigUInt64BE(BigInt(nonce));
78+
79+
// 3. vault address marker
80+
const parts: Buffer[] = [Buffer.from(actionBytes), nonceBytes];
81+
82+
if (vaultAddress) {
83+
parts.push(Buffer.from([0x01]));
84+
parts.push(Buffer.from(vaultAddress.replace('0x', ''), 'hex'));
85+
} else {
86+
parts.push(Buffer.from([0x00]));
87+
}
88+
89+
// 4. keccak256 of concatenated bytes
90+
return utils.keccak256(Buffer.concat(parts));
91+
}
92+
93+
// ----------------------------------------------------------------------------
94+
// Price/size formatting -- must match Hyperliquid's wire format
95+
// ----------------------------------------------------------------------------
96+
97+
export function floatToWire(x: number): string {
98+
const rounded = x.toFixed(8);
99+
if (Math.abs(parseFloat(rounded) - x) >= 1e-12) {
100+
throw new Error(`floatToWire causes rounding: ${x}`);
101+
}
102+
return parseFloat(rounded).toString();
103+
}
104+
105+
// ----------------------------------------------------------------------------
106+
// Auth class
107+
// ----------------------------------------------------------------------------
108+
109+
export class HyperliquidAuth {
110+
private readonly wallet: Wallet;
111+
private readonly isMainnet: boolean;
112+
113+
constructor(credentials: ExchangeCredentials, testnet: boolean) {
114+
if (!credentials.privateKey) {
115+
throw new AuthenticationError(
116+
'Hyperliquid trading requires a privateKey for EIP-712 signing.',
117+
'Hyperliquid',
118+
);
119+
}
120+
121+
let privateKey = credentials.privateKey;
122+
if (privateKey.includes('\\n')) {
123+
privateKey = privateKey.replace(/\\n/g, '\n');
124+
}
125+
126+
const stripped = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey;
127+
if (!/^[0-9a-fA-F]{64}$/.test(stripped)) {
128+
throw new AuthenticationError(
129+
'Invalid private key format. Hyperliquid requires a 32-byte hex EVM private key (e.g. 0xabc123...).',
130+
'Hyperliquid',
131+
);
132+
}
133+
134+
this.wallet = new Wallet(privateKey);
135+
this.isMainnet = !testnet;
136+
}
137+
138+
getAddress(): string {
139+
return this.wallet.address;
140+
}
141+
142+
/**
143+
* Sign an L1 action using the phantom agent EIP-712 scheme.
144+
*
145+
* Flow:
146+
* 1. msgpack-encode the action
147+
* 2. Append nonce (8 bytes BE) + vault marker
148+
* 3. keccak256 -> connectionId
149+
* 4. EIP-712 sign {source, connectionId} with domain "Exchange"
150+
*/
151+
async signL1Action(
152+
action: Record<string, unknown>,
153+
vaultAddress: string | null = null,
154+
nonce: number = Date.now(),
155+
): Promise<{ signature: HyperliquidSignature; nonce: number }> {
156+
const connectionId = computeActionHash(action, vaultAddress, nonce);
157+
158+
const message = {
159+
source: this.isMainnet ? 'a' : 'b',
160+
connectionId,
161+
};
162+
163+
// ethers v5 uses _signTypedData (underscore prefix)
164+
const sig = await this.wallet._signTypedData(EIP712_DOMAIN, AGENT_TYPES, message);
165+
const split = utils.splitSignature(sig);
166+
167+
return {
168+
signature: {
169+
r: split.r,
170+
s: split.s,
171+
v: split.v,
172+
},
173+
nonce,
174+
};
175+
}
176+
177+
/**
178+
* Build and sign a complete exchange request body.
179+
*/
180+
async signExchangeRequest(
181+
action: Record<string, unknown>,
182+
vaultAddress: string | null = null,
183+
): Promise<Record<string, unknown>> {
184+
const nonce = Date.now();
185+
const { signature } = await this.signL1Action(action, vaultAddress, nonce);
186+
187+
return {
188+
action,
189+
nonce,
190+
signature,
191+
vaultAddress,
192+
};
193+
}
194+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const DEFAULT_HYPERLIQUID_BASE_URL = 'https://api.hyperliquid.xyz';
2+
export const HYPERLIQUID_TESTNET_BASE_URL = 'https://api.hyperliquid-testnet.xyz';
3+
4+
export const HYPERLIQUID_WS_URL = 'wss://api.hyperliquid.xyz/ws';
5+
export const HYPERLIQUID_TESTNET_WS_URL = 'wss://api.hyperliquid-testnet.xyz/ws';
6+
7+
// HIP-4 Outcome Markets asset ID encoding
8+
// Asset ID = 100_000_000 + (10 * outcome_id) + side
9+
// side: 0 = Yes, 1 = No
10+
export const OUTCOME_ASSET_BASE = 100_000_000;
11+
export const OUTCOME_MULTIPLIER = 10;
12+
export const SIDE_YES = 0;
13+
export const SIDE_NO = 1;
14+
15+
// EIP-712 signing constants
16+
export const EXCHANGE_DOMAIN = 'Exchange';
17+
export const EXCHANGE_CHAIN_ID = 1337;
18+
19+
// Minimum order value in USDH
20+
export const MIN_ORDER_VALUE = 10;
21+
22+
export interface HyperliquidApiConfig {
23+
baseUrl: string;
24+
wsUrl: string;
25+
testnet: boolean;
26+
}
27+
28+
export function getHyperliquidConfig(
29+
baseUrlOverride?: string,
30+
testnet?: boolean,
31+
): HyperliquidApiConfig {
32+
const isTestnet = testnet ?? false;
33+
return {
34+
baseUrl: baseUrlOverride ?? (isTestnet ? HYPERLIQUID_TESTNET_BASE_URL : DEFAULT_HYPERLIQUID_BASE_URL),
35+
wsUrl: isTestnet ? HYPERLIQUID_TESTNET_WS_URL : HYPERLIQUID_WS_URL,
36+
testnet: isTestnet,
37+
};
38+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import axios from 'axios';
2+
import { ErrorMapper } from '../../utils/error-mapper';
3+
import {
4+
AuthenticationError,
5+
BadRequest,
6+
InsufficientFunds,
7+
InvalidOrder,
8+
RateLimitExceeded,
9+
} from '../../errors';
10+
11+
/**
12+
* Maps Hyperliquid API errors to PMXT unified error classes.
13+
*
14+
* Hyperliquid returns errors as plain strings or JSON objects in
15+
* the response body. Common patterns:
16+
* - "User has no account" -> AuthenticationError
17+
* - "Insufficient margin" -> InsufficientFunds
18+
* - "Invalid order" -> InvalidOrder
19+
*/
20+
export class HyperliquidErrorMapper extends ErrorMapper {
21+
constructor() {
22+
super('Hyperliquid');
23+
}
24+
25+
protected extractErrorMessage(error: any): string {
26+
if (axios.isAxiosError(error) && error.response?.data) {
27+
const data = error.response.data;
28+
if (typeof data === 'string') {
29+
return `[${error.response.status}] ${data}`;
30+
}
31+
if (data.status === 'err' && data.response) {
32+
return `[${error.response.status}] ${data.response}`;
33+
}
34+
}
35+
return super.extractErrorMessage(error);
36+
}
37+
38+
protected mapBadRequestError(message: string, data: any): BadRequest {
39+
const lowerMessage = message.toLowerCase();
40+
const responseStr = typeof data === 'object' && data?.response
41+
? String(data.response).toLowerCase()
42+
: lowerMessage;
43+
44+
if (responseStr.includes('insufficient margin') || responseStr.includes('not enough')) {
45+
return new InsufficientFunds(message, this.exchangeName);
46+
}
47+
48+
if (responseStr.includes('invalid order') || responseStr.includes('price out of range')) {
49+
return new InvalidOrder(message, this.exchangeName);
50+
}
51+
52+
if (responseStr.includes('no account') || responseStr.includes('not authorized')) {
53+
return new AuthenticationError(message, this.exchangeName);
54+
}
55+
56+
return super.mapBadRequestError(message, data);
57+
}
58+
59+
mapError(error: any): ReturnType<ErrorMapper['mapError']> {
60+
if (axios.isAxiosError(error) && error.response?.status === 429) {
61+
const retryAfter = error.response.headers?.['retry-after'];
62+
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : undefined;
63+
return new RateLimitExceeded(
64+
this.extractErrorMessage(error),
65+
retryAfterSeconds,
66+
this.exchangeName,
67+
);
68+
}
69+
70+
return super.mapError(error);
71+
}
72+
}
73+
74+
export const hyperliquidErrorMapper = new HyperliquidErrorMapper();

0 commit comments

Comments
 (0)