@phantom/perps-client 0.1.1
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/CHANGELOG.md +7 -0
- package/README.md +147 -0
- package/jest.config.js +10 -0
- package/package.json +38 -0
- package/src/PerpsClient.test.ts +216 -0
- package/src/PerpsClient.ts +336 -0
- package/src/actions.test.ts +298 -0
- package/src/actions.ts +182 -0
- package/src/api.ts +366 -0
- package/src/constants.ts +65 -0
- package/src/index.ts +16 -0
- package/src/types.ts +227 -0
- package/src/validate.ts +18 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PerpsClient — Hyperliquid perpetuals trading via Phantom backend.
|
|
3
|
+
*
|
|
4
|
+
* Intentionally decoupled from PhantomClient: takes a plain EVM address and
|
|
5
|
+
* a signTypedData callback so it can be used in any context (MCP server,
|
|
6
|
+
* tests with a mock signer, other wallet backends, etc.).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
PerpAccountBalance,
|
|
11
|
+
PerpPosition,
|
|
12
|
+
PerpOrder,
|
|
13
|
+
PerpMarket,
|
|
14
|
+
HistoricalOrder,
|
|
15
|
+
FundingActivity,
|
|
16
|
+
OpenPositionParams,
|
|
17
|
+
ClosePositionParams,
|
|
18
|
+
CancelOrderParams,
|
|
19
|
+
UpdateLeverageParams,
|
|
20
|
+
ActionResponse,
|
|
21
|
+
HlOrderAction,
|
|
22
|
+
HlCancelAction,
|
|
23
|
+
HlUpdateLeverageAction,
|
|
24
|
+
Eip712TypedData,
|
|
25
|
+
PerpsLogger,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
import { noopLogger } from "./types.js";
|
|
28
|
+
import { PerpsApi } from "./api.js";
|
|
29
|
+
import {
|
|
30
|
+
buildExchangeActionTypedData,
|
|
31
|
+
buildUsdClassTransferTypedData,
|
|
32
|
+
nextNonce,
|
|
33
|
+
splitSignature,
|
|
34
|
+
formatPrice,
|
|
35
|
+
formatSize,
|
|
36
|
+
resolveLimitPrice,
|
|
37
|
+
} from "./actions.js";
|
|
38
|
+
import { DEFAULT_API_BASE_URL, HYPERCORE_MAINNET_CHAIN_ID, MARKET_ORDER_SLIPPAGE } from "./constants.js";
|
|
39
|
+
import { assertPositiveDecimalString } from "./validate.js";
|
|
40
|
+
|
|
41
|
+
export interface PerpsClientOptions {
|
|
42
|
+
/** The wallet's EVM address (0x-prefixed, checksummed or lowercase) */
|
|
43
|
+
evmAddress: string;
|
|
44
|
+
/**
|
|
45
|
+
* Signs EIP-712 typed data and returns the raw hex signature (0x-prefixed, 65 bytes).
|
|
46
|
+
* In the MCP server this is bound to PhantomClient.ethereumSignTypedData().
|
|
47
|
+
*/
|
|
48
|
+
signTypedData: (typedData: Eip712TypedData) => Promise<string>;
|
|
49
|
+
apiBaseUrl?: string;
|
|
50
|
+
appId?: string;
|
|
51
|
+
/** Optional logger — if provided, all API calls and errors are logged */
|
|
52
|
+
logger?: PerpsLogger;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class PerpsClient {
|
|
56
|
+
private readonly evmAddress: string;
|
|
57
|
+
private readonly signTypedData: (typedData: Eip712TypedData) => Promise<string>;
|
|
58
|
+
private readonly api: PerpsApi;
|
|
59
|
+
private readonly logger: PerpsLogger;
|
|
60
|
+
|
|
61
|
+
constructor(opts: PerpsClientOptions) {
|
|
62
|
+
this.evmAddress = opts.evmAddress.toLowerCase();
|
|
63
|
+
this.signTypedData = opts.signTypedData;
|
|
64
|
+
this.logger = opts.logger ?? noopLogger;
|
|
65
|
+
this.logger.debug(`PerpsClient initialized evmAddress=${this.evmAddress} taker=${this.getUserCaip19()}`);
|
|
66
|
+
this.api = new PerpsApi({
|
|
67
|
+
baseUrl: opts.apiBaseUrl ?? DEFAULT_API_BASE_URL,
|
|
68
|
+
appId: opts.appId,
|
|
69
|
+
logger: this.logger,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Read (no signing) ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async getBalance(): Promise<PerpAccountBalance> {
|
|
76
|
+
return this.api.getAccountBalance(this.getUserCaip19());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getPositions(): Promise<PerpPosition[]> {
|
|
80
|
+
const { positions } = await this.api.getPositionsAndOpenOrders(this.getUserCaip19());
|
|
81
|
+
return positions;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async getOpenOrders(): Promise<PerpOrder[]> {
|
|
85
|
+
const { openOrders } = await this.api.getPositionsAndOpenOrders(this.getUserCaip19());
|
|
86
|
+
return openOrders;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Returns all available perp markets. Used by the get_perp_markets MCP tool. */
|
|
90
|
+
async getMarkets(): Promise<PerpMarket[]> {
|
|
91
|
+
return this.api.getAllMarkets();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getTradeHistory(): Promise<HistoricalOrder[]> {
|
|
95
|
+
return this.api.getTradeHistory(this.getUserCaip19());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async getFundingHistory(): Promise<FundingActivity[]> {
|
|
99
|
+
return this.api.getFundingHistory(this.getUserCaip19());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Write (EIP-712 sign → submit) ────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
async openPosition(params: OpenPositionParams): Promise<ActionResponse> {
|
|
105
|
+
this.logger.info(
|
|
106
|
+
`openPosition market=${params.market} direction=${params.direction} sizeUsd=${params.sizeUsd} leverage=${params.leverage} orderType=${params.orderType} taker=${this.evmAddress}`,
|
|
107
|
+
);
|
|
108
|
+
assertPositiveDecimalString(params.sizeUsd, "sizeUsd");
|
|
109
|
+
const market = await this.findMarket(params.market);
|
|
110
|
+
if (!market) {
|
|
111
|
+
throw new Error(`Market not found: ${params.market}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Set leverage before placing the order (required by Hyperliquid).
|
|
115
|
+
// Defaults to isolated margin — cross margin shares account balance across positions.
|
|
116
|
+
const leverageAction: HlUpdateLeverageAction = {
|
|
117
|
+
type: "updateLeverage",
|
|
118
|
+
asset: market.assetId,
|
|
119
|
+
isCross: params.marginType === "cross",
|
|
120
|
+
leverage: params.leverage,
|
|
121
|
+
};
|
|
122
|
+
const leverageNonce = nextNonce();
|
|
123
|
+
const leverageTypedData = buildExchangeActionTypedData(leverageAction, leverageNonce);
|
|
124
|
+
const leverageSig = await this.sign(leverageTypedData);
|
|
125
|
+
await this.api.postUpdateLeverage({
|
|
126
|
+
action: leverageAction,
|
|
127
|
+
nonce: leverageNonce,
|
|
128
|
+
signature: leverageSig,
|
|
129
|
+
taker: this.getUserCaip19(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const isBuy = params.direction === "long";
|
|
133
|
+
const price = parseFloat(market.price);
|
|
134
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
135
|
+
throw new Error(`Invalid market price for ${params.market}: ${market.price}`);
|
|
136
|
+
}
|
|
137
|
+
const rawSize = parseFloat(params.sizeUsd) / price;
|
|
138
|
+
if (!Number.isFinite(rawSize) || rawSize <= 0) {
|
|
139
|
+
throw new Error(`Computed order size is invalid (sizeUsd=${params.sizeUsd}, price=${market.price})`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const limitPx = resolveLimitPrice(params.orderType, params.limitPrice, price, isBuy, market.szDecimals);
|
|
143
|
+
|
|
144
|
+
// Round down so the required margin never exceeds the available balance.
|
|
145
|
+
// Rounding up (ceil) can cause "insufficient margin" rejections from Hyperliquid.
|
|
146
|
+
const factor = Math.pow(10, market.szDecimals);
|
|
147
|
+
const sz = (Math.floor(rawSize * factor) / factor).toFixed(market.szDecimals);
|
|
148
|
+
if (parseFloat(sz) <= 0) {
|
|
149
|
+
throw new Error(`Order size rounds to zero (sizeUsd=${params.sizeUsd}, price=${market.price})`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const action: HlOrderAction = {
|
|
153
|
+
type: "order",
|
|
154
|
+
orders: [
|
|
155
|
+
{
|
|
156
|
+
a: market.assetId,
|
|
157
|
+
b: isBuy,
|
|
158
|
+
p: limitPx,
|
|
159
|
+
s: sz,
|
|
160
|
+
r: params.reduceOnly ?? false,
|
|
161
|
+
t: params.orderType === "limit" ? { limit: { tif: "Gtc" } } : { limit: { tif: "Ioc" } },
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
grouping: "na",
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const nonce = nextNonce();
|
|
168
|
+
this.logger.debug(`openPosition placing order market=${params.market} sz=${sz} limitPx=${limitPx} nonce=${nonce}`);
|
|
169
|
+
const typedData = buildExchangeActionTypedData(action, nonce);
|
|
170
|
+
const sig = await this.sign(typedData);
|
|
171
|
+
const result = await this.api.postPlaceOrder({ action, nonce, signature: sig, taker: this.getUserCaip19() });
|
|
172
|
+
this.logger.info(`openPosition result status=${result.status}`);
|
|
173
|
+
return { status: result.status, data: result };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async closePosition(params: ClosePositionParams): Promise<ActionResponse> {
|
|
177
|
+
this.logger.info(
|
|
178
|
+
`closePosition market=${params.market} sizePercent=${params.sizePercent ?? 100} taker=${this.evmAddress}`,
|
|
179
|
+
);
|
|
180
|
+
const [market, positions] = await Promise.all([this.findMarket(params.market), this.getPositions()]);
|
|
181
|
+
|
|
182
|
+
if (!market) {
|
|
183
|
+
throw new Error(`Market not found: ${params.market}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const position = positions.find(p => p.coin.trim().toUpperCase() === market.symbol.trim().toUpperCase());
|
|
187
|
+
if (!position) {
|
|
188
|
+
throw new Error(`No open position for market: ${params.market}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sizePercent = (params.sizePercent ?? 100) / 100;
|
|
192
|
+
const positionSize = Math.abs(parseFloat(position.size));
|
|
193
|
+
if (!Number.isFinite(positionSize) || positionSize <= 0) {
|
|
194
|
+
throw new Error(`Invalid position size for ${params.market}: ${position.size}`);
|
|
195
|
+
}
|
|
196
|
+
const sizeToClose = positionSize * sizePercent;
|
|
197
|
+
const sz = formatSize(sizeToClose, market.szDecimals);
|
|
198
|
+
if (parseFloat(sz) <= 0) {
|
|
199
|
+
throw new Error(`Close size rounds to zero (size=${position.size}, sizePercent=${params.sizePercent ?? 100})`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const isBuy = position.direction === "short"; // close long = sell, close short = buy
|
|
203
|
+
const price = parseFloat(market.price);
|
|
204
|
+
const limitPx = formatPrice(
|
|
205
|
+
price * (isBuy ? 1 + MARKET_ORDER_SLIPPAGE : 1 - MARKET_ORDER_SLIPPAGE),
|
|
206
|
+
market.szDecimals,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const action: HlOrderAction = {
|
|
210
|
+
type: "order",
|
|
211
|
+
orders: [{ a: market.assetId, b: isBuy, p: limitPx, s: sz, r: true, t: { limit: { tif: "Ioc" } } }],
|
|
212
|
+
grouping: "na",
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const nonce = nextNonce();
|
|
216
|
+
this.logger.debug(
|
|
217
|
+
`closePosition placing order market=${params.market} direction=${position.direction} sz=${sz} limitPx=${limitPx} nonce=${nonce}`,
|
|
218
|
+
);
|
|
219
|
+
const typedData = buildExchangeActionTypedData(action, nonce);
|
|
220
|
+
const sig = await this.sign(typedData);
|
|
221
|
+
const result = await this.api.postPlaceOrder({ action, nonce, signature: sig, taker: this.getUserCaip19() });
|
|
222
|
+
this.logger.info(`closePosition result status=${result.status}`);
|
|
223
|
+
return { status: result.status, data: result };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async cancelOrder(params: CancelOrderParams): Promise<ActionResponse> {
|
|
227
|
+
this.logger.info(`cancelOrder market=${params.market} orderId=${params.orderId} taker=${this.evmAddress}`);
|
|
228
|
+
const market = await this.findMarket(params.market);
|
|
229
|
+
if (!market) {
|
|
230
|
+
throw new Error(`Market not found: ${params.market}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const action: HlCancelAction = {
|
|
234
|
+
type: "cancel",
|
|
235
|
+
cancels: [{ a: market.assetId, o: params.orderId }],
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const nonce = nextNonce();
|
|
239
|
+
const typedData = buildExchangeActionTypedData(action, nonce);
|
|
240
|
+
const sig = await this.sign(typedData);
|
|
241
|
+
const result = await this.api.postCancelOrder({ action, nonce, signature: sig, taker: this.getUserCaip19() });
|
|
242
|
+
this.logger.info(`cancelOrder result status=${result.status}`);
|
|
243
|
+
return { status: "ok", data: result };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async updateLeverage(params: UpdateLeverageParams): Promise<ActionResponse> {
|
|
247
|
+
this.logger.info(
|
|
248
|
+
`updateLeverage market=${params.market} leverage=${params.leverage} marginType=${params.marginType} taker=${this.evmAddress}`,
|
|
249
|
+
);
|
|
250
|
+
const market = await this.findMarket(params.market);
|
|
251
|
+
if (!market) {
|
|
252
|
+
throw new Error(`Market not found: ${params.market}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const action: HlUpdateLeverageAction = {
|
|
256
|
+
type: "updateLeverage",
|
|
257
|
+
asset: market.assetId,
|
|
258
|
+
isCross: params.marginType === "cross",
|
|
259
|
+
leverage: params.leverage,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const nonce = nextNonce();
|
|
263
|
+
const typedData = buildExchangeActionTypedData(action, nonce);
|
|
264
|
+
const sig = await this.sign(typedData);
|
|
265
|
+
const result = await this.api.postUpdateLeverage({ action, nonce, signature: sig, taker: this.getUserCaip19() });
|
|
266
|
+
return { status: "ok", data: result };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Moves USDC from the Hyperliquid spot account to the perps account.
|
|
271
|
+
* Both accounts are on Hypercore — this is NOT a cross-chain bridge.
|
|
272
|
+
*/
|
|
273
|
+
async deposit(amountUsdc: string): Promise<ActionResponse> {
|
|
274
|
+
this.logger.info(`deposit amountUsdc=${amountUsdc} taker=${this.evmAddress}`);
|
|
275
|
+
assertPositiveDecimalString(amountUsdc, "amountUsdc");
|
|
276
|
+
const nonce = nextNonce();
|
|
277
|
+
const action = {
|
|
278
|
+
type: "usdClassTransfer" as const,
|
|
279
|
+
hyperliquidChain: "Mainnet" as const,
|
|
280
|
+
signatureChainId: "0xa4b1" as const,
|
|
281
|
+
amount: amountUsdc,
|
|
282
|
+
toPerp: true,
|
|
283
|
+
nonce,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const typedData = buildUsdClassTransferTypedData(action);
|
|
287
|
+
const sig = await this.sign(typedData);
|
|
288
|
+
const result = await this.api.postTransferUsdcSpotPerp({ action, nonce, signature: sig });
|
|
289
|
+
return { status: "ok", data: result };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Moves USDC from the perps account back to the Hyperliquid spot account.
|
|
294
|
+
*/
|
|
295
|
+
async withdraw(amountUsdc: string): Promise<ActionResponse> {
|
|
296
|
+
this.logger.info(`withdraw amountUsdc=${amountUsdc} taker=${this.evmAddress}`);
|
|
297
|
+
assertPositiveDecimalString(amountUsdc, "amountUsdc");
|
|
298
|
+
const nonce = nextNonce();
|
|
299
|
+
const action = {
|
|
300
|
+
type: "usdClassTransfer" as const,
|
|
301
|
+
hyperliquidChain: "Mainnet" as const,
|
|
302
|
+
signatureChainId: "0xa4b1" as const,
|
|
303
|
+
amount: amountUsdc,
|
|
304
|
+
toPerp: false,
|
|
305
|
+
nonce,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const typedData = buildUsdClassTransferTypedData(action);
|
|
309
|
+
const sig = await this.sign(typedData);
|
|
310
|
+
const result = await this.api.postTransferUsdcSpotPerp({ action, nonce, signature: sig });
|
|
311
|
+
return { status: "ok", data: result };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Fetches a single market by symbol (e.g. "BTC") using its CAIP-19 token address.
|
|
318
|
+
* More efficient than fetching all markets when only one is needed.
|
|
319
|
+
*/
|
|
320
|
+
private async findMarket(symbol: string): Promise<PerpMarket> {
|
|
321
|
+
const caip19 = `${HYPERCORE_MAINNET_CHAIN_ID}/address:${symbol.toUpperCase()}`;
|
|
322
|
+
const markets = await this.api.getMarkets([caip19]);
|
|
323
|
+
const market = markets.find(m => m.symbol.toUpperCase() === symbol.toUpperCase());
|
|
324
|
+
if (!market) throw new Error(`Market not found: ${symbol}`);
|
|
325
|
+
return market;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private getUserCaip19(): string {
|
|
329
|
+
return `${HYPERCORE_MAINNET_CHAIN_ID}/address:${this.evmAddress}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async sign(typedData: Eip712TypedData): Promise<ReturnType<typeof splitSignature>> {
|
|
333
|
+
const signature = await this.signTypedData(typedData);
|
|
334
|
+
return splitSignature(signature);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
splitSignature,
|
|
3
|
+
nextNonce,
|
|
4
|
+
formatPrice,
|
|
5
|
+
formatSize,
|
|
6
|
+
resolveLimitPrice,
|
|
7
|
+
buildExchangeActionTypedData,
|
|
8
|
+
buildUsdClassTransferTypedData,
|
|
9
|
+
} from "./actions";
|
|
10
|
+
import type { HlOrderAction, HlCancelAction, HlUpdateLeverageAction, HlUsdClassTransferAction } from "./types";
|
|
11
|
+
|
|
12
|
+
// ── splitSignature ───────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe("splitSignature", () => {
|
|
15
|
+
it("splits a 65-byte hex signature into r, s, v", () => {
|
|
16
|
+
const r = "a".repeat(64);
|
|
17
|
+
const s = "b".repeat(64);
|
|
18
|
+
const v = "1b"; // 27
|
|
19
|
+
const result = splitSignature(`0x${r}${s}${v}`);
|
|
20
|
+
expect(result.r).toBe(`0x${r}`);
|
|
21
|
+
expect(result.s).toBe(`0x${s}`);
|
|
22
|
+
expect(result.v).toBe(27);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("handles v = 28 (0x1c) in hex format", () => {
|
|
26
|
+
const sig = `0x${"0".repeat(64)}${"0".repeat(64)}1c`;
|
|
27
|
+
expect(splitSignature(sig).v).toBe(28);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("decodes a base64url-encoded 65-byte signature (KMS format)", () => {
|
|
31
|
+
// Build known 65 bytes: r = 0xaa*32, s = 0xbb*32, v = 0x1b (27)
|
|
32
|
+
const bytes = Buffer.alloc(65);
|
|
33
|
+
bytes.fill(0xaa, 0, 32);
|
|
34
|
+
bytes.fill(0xbb, 32, 64);
|
|
35
|
+
bytes[64] = 0x1b;
|
|
36
|
+
const base64url = bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
37
|
+
// Wrap with 0x prefix as PhantomClient returns it
|
|
38
|
+
const sig = `0x${base64url}`;
|
|
39
|
+
|
|
40
|
+
const result = splitSignature(sig);
|
|
41
|
+
|
|
42
|
+
expect(result.r).toBe("0x" + "aa".repeat(32));
|
|
43
|
+
expect(result.s).toBe("0x" + "bb".repeat(32));
|
|
44
|
+
expect(result.v).toBe(27);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("throws when base64-decoded bytes are too short", () => {
|
|
48
|
+
const short = Buffer.alloc(10).toString("base64url");
|
|
49
|
+
expect(() => splitSignature(`0x${short}`)).toThrow("Signature too short");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── nextNonce ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("nextNonce", () => {
|
|
56
|
+
it("returns a positive integer", () => {
|
|
57
|
+
const n = nextNonce();
|
|
58
|
+
expect(Number.isInteger(n)).toBe(true);
|
|
59
|
+
expect(n).toBeGreaterThan(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns strictly increasing values on successive calls", () => {
|
|
63
|
+
const a = nextNonce();
|
|
64
|
+
const b = nextNonce();
|
|
65
|
+
const c = nextNonce();
|
|
66
|
+
expect(b).toBeGreaterThan(a);
|
|
67
|
+
expect(c).toBeGreaterThan(b);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── formatPrice ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("formatPrice", () => {
|
|
74
|
+
// Hyperliquid rule: decimals = max(0, 6 - szDecimals - floor(log10(price)))
|
|
75
|
+
|
|
76
|
+
it("BTC ~$70k (szDecimals=5) → 0 decimal places", () => {
|
|
77
|
+
// 6 - 5 - floor(log10(70000)) = 6 - 5 - 4 = -3 → max(0,-3) = 0
|
|
78
|
+
expect(formatPrice(70000, 5)).toBe("70000");
|
|
79
|
+
expect(formatPrice(77493.9, 5)).toBe("77494");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("SOL ~$100 (szDecimals=2) → 2 decimal places", () => {
|
|
83
|
+
// 6 - 2 - floor(log10(100)) = 6 - 2 - 2 = 2
|
|
84
|
+
expect(formatPrice(100.567, 2)).toBe("100.57");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("small token ~$0.001 (szDecimals=0) → 8 decimal places", () => {
|
|
88
|
+
// 6 - 0 - floor(log10(0.001)) = 6 - 0 - (-3) = 9 → 9 decimals
|
|
89
|
+
const result = formatPrice(0.001234, 0);
|
|
90
|
+
expect(parseFloat(result)).toBeCloseTo(0.001234, 6);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns a string", () => {
|
|
94
|
+
expect(typeof formatPrice(50000, 5)).toBe("string");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("zero price — epsilon trick prevents -Infinity log; returns many decimal places", () => {
|
|
98
|
+
// log10(0 + 1e-9) = -9 → decimals = max(0, 6 - szDecimals + 9)
|
|
99
|
+
const result = formatPrice(0, 5);
|
|
100
|
+
expect(parseFloat(result)).toBe(0);
|
|
101
|
+
expect(Number.isFinite(parseFloat(result))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("very large price (1e8, szDecimals=5) → 0 decimal places", () => {
|
|
105
|
+
// 6 - 5 - floor(log10(1e8)) = 6 - 5 - 8 = -7 → max(0,-7) = 0
|
|
106
|
+
expect(formatPrice(1e8, 5)).toBe("100000000");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns a finite string for very small positive price", () => {
|
|
110
|
+
const result = formatPrice(1e-10, 0);
|
|
111
|
+
expect(Number.isFinite(parseFloat(result))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── formatSize ───────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe("formatSize", () => {
|
|
118
|
+
it("formats to the given number of decimal places", () => {
|
|
119
|
+
expect(formatSize(1.23456, 3)).toBe("1.235");
|
|
120
|
+
expect(formatSize(0.1, 1)).toBe("0.1");
|
|
121
|
+
expect(formatSize(10, 0)).toBe("10");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("zero size returns all-zero string", () => {
|
|
125
|
+
expect(formatSize(0, 5)).toBe("0.00000");
|
|
126
|
+
expect(formatSize(0, 0)).toBe("0");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rounds fractional size to the specified decimal places", () => {
|
|
130
|
+
// 0.123456789 rounded to 5 places → "0.12346"
|
|
131
|
+
expect(formatSize(0.123456789, 5)).toBe("0.12346");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("very small size that rounds to zero at given precision", () => {
|
|
135
|
+
// 0.000001 with 5 decimals → "0.00000"
|
|
136
|
+
expect(formatSize(0.000001, 5)).toBe("0.00000");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── resolveLimitPrice ────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("resolveLimitPrice", () => {
|
|
143
|
+
const SZ_DECIMALS = 5;
|
|
144
|
+
const MARKET_PRICE = 50000;
|
|
145
|
+
|
|
146
|
+
describe("market orders", () => {
|
|
147
|
+
it("buy: applies +10% slippage to market price", () => {
|
|
148
|
+
const result = resolveLimitPrice("market", undefined, MARKET_PRICE, true, SZ_DECIMALS);
|
|
149
|
+
expect(result).toBe(formatPrice(MARKET_PRICE * 1.1, SZ_DECIMALS));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("sell: applies -10% slippage to market price", () => {
|
|
153
|
+
const result = resolveLimitPrice("market", undefined, MARKET_PRICE, false, SZ_DECIMALS);
|
|
154
|
+
expect(result).toBe(formatPrice(MARKET_PRICE * 0.9, SZ_DECIMALS));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("ignores any limitPrice passed for a market order", () => {
|
|
158
|
+
const result = resolveLimitPrice("market", "99999", MARKET_PRICE, true, SZ_DECIMALS);
|
|
159
|
+
expect(result).toBe(formatPrice(MARKET_PRICE * 1.1, SZ_DECIMALS));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("limit orders", () => {
|
|
164
|
+
it("returns formatPrice of the parsed limitPrice", () => {
|
|
165
|
+
const result = resolveLimitPrice("limit", "48000", MARKET_PRICE, true, SZ_DECIMALS);
|
|
166
|
+
expect(result).toBe(formatPrice(48000, SZ_DECIMALS));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("throws when limitPrice is absent", () => {
|
|
170
|
+
expect(() => resolveLimitPrice("limit", undefined, MARKET_PRICE, true, SZ_DECIMALS)).toThrow(
|
|
171
|
+
"limitPrice is required for limit orders",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("throws when limitPrice is an empty string", () => {
|
|
176
|
+
expect(() => resolveLimitPrice("limit", "", MARKET_PRICE, true, SZ_DECIMALS)).toThrow(
|
|
177
|
+
"limitPrice is required for limit orders",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it.each([["0"], ["-1000"], ["abc"], ["NaN"], ["Infinity"]])(
|
|
182
|
+
"throws for non-positive or non-finite limitPrice=%j",
|
|
183
|
+
bad => {
|
|
184
|
+
expect(() => resolveLimitPrice("limit", bad, MARKET_PRICE, true, SZ_DECIMALS)).toThrow(
|
|
185
|
+
"limitPrice must be a finite positive number",
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── buildExchangeActionTypedData ─────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe("buildExchangeActionTypedData", () => {
|
|
195
|
+
const orderAction: HlOrderAction = {
|
|
196
|
+
type: "order",
|
|
197
|
+
orders: [{ a: 0, b: true, p: "50000", s: "0.001", r: false, t: { limit: { tif: "Ioc" } } }],
|
|
198
|
+
grouping: "na",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
it("uses the Exchange domain (chainId 1337)", () => {
|
|
202
|
+
const td = buildExchangeActionTypedData(orderAction, 1000);
|
|
203
|
+
expect(td.domain.name).toBe("Exchange");
|
|
204
|
+
expect(td.domain.chainId).toBe(1337);
|
|
205
|
+
expect(td.domain.verifyingContract).toBe("0x0000000000000000000000000000000000000000");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("uses Agent as primaryType", () => {
|
|
209
|
+
const td = buildExchangeActionTypedData(orderAction, 1000);
|
|
210
|
+
expect(td.primaryType).toBe("Agent");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("message.source is 'a' for mainnet", () => {
|
|
214
|
+
const td = buildExchangeActionTypedData(orderAction, 1000, false);
|
|
215
|
+
expect(td.message.source).toBe("a");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("message.source is 'b' for testnet", () => {
|
|
219
|
+
const td = buildExchangeActionTypedData(orderAction, 1000, true);
|
|
220
|
+
expect(td.message.source).toBe("b");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("message.connectionId is a 0x-prefixed hex string", () => {
|
|
224
|
+
const td = buildExchangeActionTypedData(orderAction, 1000);
|
|
225
|
+
expect(typeof td.message.connectionId).toBe("string");
|
|
226
|
+
expect(td.message.connectionId as string).toMatch(/^0x[0-9a-f]{64}$/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("produces a different connectionId for a different nonce", () => {
|
|
230
|
+
const td1 = buildExchangeActionTypedData(orderAction, 1000);
|
|
231
|
+
const td2 = buildExchangeActionTypedData(orderAction, 2000);
|
|
232
|
+
expect(td1.message.connectionId).not.toBe(td2.message.connectionId);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("works for CancelAction", () => {
|
|
236
|
+
const cancel: HlCancelAction = { type: "cancel", cancels: [{ a: 1, o: 42 }] };
|
|
237
|
+
const td = buildExchangeActionTypedData(cancel, 1000);
|
|
238
|
+
expect(td.primaryType).toBe("Agent");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("works for UpdateLeverageAction", () => {
|
|
242
|
+
const leverage: HlUpdateLeverageAction = { type: "updateLeverage", asset: 0, isCross: true, leverage: 10 };
|
|
243
|
+
const td = buildExchangeActionTypedData(leverage, 1000);
|
|
244
|
+
expect(td.primaryType).toBe("Agent");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── buildUsdClassTransferTypedData ───────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe("buildUsdClassTransferTypedData", () => {
|
|
251
|
+
const action: HlUsdClassTransferAction = {
|
|
252
|
+
type: "usdClassTransfer",
|
|
253
|
+
hyperliquidChain: "Mainnet",
|
|
254
|
+
signatureChainId: "0xa4b1",
|
|
255
|
+
amount: "100",
|
|
256
|
+
toPerp: true,
|
|
257
|
+
nonce: 12345,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
it("uses HyperliquidSignTransaction domain and derives chainId from signatureChainId", () => {
|
|
261
|
+
const td = buildUsdClassTransferTypedData(action);
|
|
262
|
+
expect(td.domain.name).toBe("HyperliquidSignTransaction");
|
|
263
|
+
expect(td.domain.chainId).toBe(42161); // 0xa4b1 = 42161
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("uses chainId 421614 when signatureChainId is testnet (0x66eee)", () => {
|
|
267
|
+
const testnetAction = { ...action, signatureChainId: "0x66eee" as const };
|
|
268
|
+
const td = buildUsdClassTransferTypedData(testnetAction);
|
|
269
|
+
expect(td.domain.chainId).toBe(421614);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("primaryType is HyperliquidTransaction:UsdClassTransfer", () => {
|
|
273
|
+
const td = buildUsdClassTransferTypedData(action);
|
|
274
|
+
expect(td.primaryType).toBe("HyperliquidTransaction:UsdClassTransfer");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("types include the UsdClassTransfer fields", () => {
|
|
278
|
+
const td = buildUsdClassTransferTypedData(action);
|
|
279
|
+
const fields = td.types["HyperliquidTransaction:UsdClassTransfer"];
|
|
280
|
+
const names = fields.map((f: { name: string }) => f.name);
|
|
281
|
+
expect(names).toContain("hyperliquidChain");
|
|
282
|
+
expect(names).toContain("amount");
|
|
283
|
+
expect(names).toContain("toPerp");
|
|
284
|
+
expect(names).toContain("nonce");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("message contains only the USD_CLASS_TRANSFER_TYPE fields", () => {
|
|
288
|
+
const td = buildUsdClassTransferTypedData(action);
|
|
289
|
+
expect(td.message).toEqual({
|
|
290
|
+
hyperliquidChain: "Mainnet",
|
|
291
|
+
amount: "100",
|
|
292
|
+
toPerp: true,
|
|
293
|
+
nonce: 12345,
|
|
294
|
+
});
|
|
295
|
+
expect(td.message).not.toHaveProperty("type");
|
|
296
|
+
expect(td.message).not.toHaveProperty("signatureChainId");
|
|
297
|
+
});
|
|
298
|
+
});
|