nansen-cli 1.35.0 → 1.36.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/CHANGELOG.md +80 -0
- package/README.md +34 -1
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +49 -1
- package/src/api.js +3 -1
- package/src/bridge.js +1102 -0
- package/src/cli.js +130 -7
- package/src/hl-action.js +528 -0
- package/src/hl-client.js +168 -0
- package/src/hl-env.js +37 -0
- package/src/keychain.js +6 -2
- package/src/limit-order.js +18 -4
- package/src/perp.js +835 -0
- package/src/rpc-urls.js +18 -11
- package/src/schema.json +415 -6
- package/src/trading.js +162 -17
- package/src/wallet-signing.js +87 -0
package/src/hl-action.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nansen CLI — Hyperliquid action builder (client-side, direct-to-HL).
|
|
3
|
+
*
|
|
4
|
+
* Ports the deterministic signing path of the hyperliquid-python-sdk
|
|
5
|
+
* (`utils/signing.py`) and nansen-api's `hyperliquid_exchange.py::prepare_*`
|
|
6
|
+
* into JS, so the CLI can build the exact L1 action + EIP-712 payload locally
|
|
7
|
+
* and submit straight to api.hyperliquid.xyz — no server round-trip to build it.
|
|
8
|
+
*
|
|
9
|
+
* The one primitive the CLI lacked is a msgpack encoder: an L1 action's hash is
|
|
10
|
+
* keccak256( msgpack(action) ‖ nonce(8B BE) ‖ vault-byte )
|
|
11
|
+
* which becomes the EIP-712 `connectionId`. Everything else (keccak, secp256k1,
|
|
12
|
+
* EIP-712 hashing) already exists in crypto.js / x402-evm.js.
|
|
13
|
+
*
|
|
14
|
+
* Correctness is pinned byte-for-byte against the live API `/perp/*` prepare
|
|
15
|
+
* endpoints via golden-vector tests — the msgpack + rounding + wire assembly
|
|
16
|
+
* either reproduces the known-good Python output exactly or the test fails.
|
|
17
|
+
*
|
|
18
|
+
* Note: the L1 actions hashed here carry NO floats — prices and sizes are
|
|
19
|
+
* stringified by floatToWire before msgpack, so the encoder only handles
|
|
20
|
+
* str/int/bool/map/array. Float64 support is included for completeness only.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { keccak256 } from './crypto.js';
|
|
24
|
+
import { hlNetwork } from './hl-env.js';
|
|
25
|
+
import { CommandError } from './api.js';
|
|
26
|
+
|
|
27
|
+
// Phantom-agent `source` for an L1 action: "a" on mainnet, "b" on testnet
|
|
28
|
+
// (signing.py). Every builder below takes the network as an argument defaulting
|
|
29
|
+
// to hlNetwork(), so a caller can pin it in a test while the CLI stays in step
|
|
30
|
+
// with whatever base URL it will actually submit to.
|
|
31
|
+
function phantomAgentSource(network) {
|
|
32
|
+
return network === 'Testnet' ? 'b' : 'a';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── msgpack encoder ──────────────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// Matches Python `msgpack.packb(action)` byte-for-byte for the value types we
|
|
38
|
+
// emit: map (JS object, insertion order preserved), array, str (utf-8), bool,
|
|
39
|
+
// and int. Widths are chosen smallest-first, unsigned preferred for
|
|
40
|
+
// non-negative ints — exactly what the reference implementation does.
|
|
41
|
+
|
|
42
|
+
function encodeInt(nRaw) {
|
|
43
|
+
const n = BigInt(nRaw);
|
|
44
|
+
if (n >= 0n) {
|
|
45
|
+
if (n <= 0x7fn) return Buffer.from([Number(n)]); // positive fixint
|
|
46
|
+
if (n <= 0xffn) return Buffer.from([0xcc, Number(n)]); // uint8
|
|
47
|
+
if (n <= 0xffffn) {
|
|
48
|
+
const b = Buffer.alloc(3);
|
|
49
|
+
b[0] = 0xcd;
|
|
50
|
+
b.writeUInt16BE(Number(n), 1);
|
|
51
|
+
return b;
|
|
52
|
+
}
|
|
53
|
+
if (n <= 0xffffffffn) {
|
|
54
|
+
const b = Buffer.alloc(5);
|
|
55
|
+
b[0] = 0xce;
|
|
56
|
+
b.writeUInt32BE(Number(n), 1);
|
|
57
|
+
return b;
|
|
58
|
+
}
|
|
59
|
+
const b = Buffer.alloc(9);
|
|
60
|
+
b[0] = 0xcf;
|
|
61
|
+
b.writeBigUInt64BE(n, 1);
|
|
62
|
+
return b;
|
|
63
|
+
}
|
|
64
|
+
if (n >= -0x20n) {
|
|
65
|
+
// negative fixint (0xe0..0xff) — single two's-complement byte
|
|
66
|
+
const b = Buffer.alloc(1);
|
|
67
|
+
b.writeInt8(Number(n), 0);
|
|
68
|
+
return b;
|
|
69
|
+
}
|
|
70
|
+
if (n >= -0x80n) return Buffer.from([0xd0, Number(n) & 0xff]); // int8
|
|
71
|
+
if (n >= -0x8000n) {
|
|
72
|
+
const b = Buffer.alloc(3);
|
|
73
|
+
b[0] = 0xd1;
|
|
74
|
+
b.writeInt16BE(Number(n), 1);
|
|
75
|
+
return b;
|
|
76
|
+
}
|
|
77
|
+
if (n >= -0x80000000n) {
|
|
78
|
+
const b = Buffer.alloc(5);
|
|
79
|
+
b[0] = 0xd2;
|
|
80
|
+
b.writeInt32BE(Number(n), 1);
|
|
81
|
+
return b;
|
|
82
|
+
}
|
|
83
|
+
const b = Buffer.alloc(9);
|
|
84
|
+
b[0] = 0xd3;
|
|
85
|
+
b.writeBigInt64BE(n, 1);
|
|
86
|
+
return b;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function encodeFloat(x) {
|
|
90
|
+
const b = Buffer.alloc(9);
|
|
91
|
+
b[0] = 0xcb; // float64
|
|
92
|
+
b.writeDoubleBE(x, 1);
|
|
93
|
+
return b;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function encodeStr(s) {
|
|
97
|
+
const utf8 = Buffer.from(s, 'utf8');
|
|
98
|
+
const len = utf8.length;
|
|
99
|
+
let head;
|
|
100
|
+
if (len <= 31) head = Buffer.from([0xa0 | len]); // fixstr
|
|
101
|
+
else if (len <= 0xff) head = Buffer.from([0xd9, len]); // str8
|
|
102
|
+
else if (len <= 0xffff) {
|
|
103
|
+
head = Buffer.alloc(3);
|
|
104
|
+
head[0] = 0xda; // str16
|
|
105
|
+
head.writeUInt16BE(len, 1);
|
|
106
|
+
} else {
|
|
107
|
+
head = Buffer.alloc(5);
|
|
108
|
+
head[0] = 0xdb; // str32
|
|
109
|
+
head.writeUInt32BE(len, 1);
|
|
110
|
+
}
|
|
111
|
+
return Buffer.concat([head, utf8]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function encodeArray(arr) {
|
|
115
|
+
const len = arr.length;
|
|
116
|
+
let head;
|
|
117
|
+
if (len <= 15) head = Buffer.from([0x90 | len]); // fixarray
|
|
118
|
+
else if (len <= 0xffff) {
|
|
119
|
+
head = Buffer.alloc(3);
|
|
120
|
+
head[0] = 0xdc; // array16
|
|
121
|
+
head.writeUInt16BE(len, 1);
|
|
122
|
+
} else {
|
|
123
|
+
head = Buffer.alloc(5);
|
|
124
|
+
head[0] = 0xdd; // array32
|
|
125
|
+
head.writeUInt32BE(len, 1);
|
|
126
|
+
}
|
|
127
|
+
return Buffer.concat([head, ...arr.map(encodeMsgpack)]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function encodeMap(obj) {
|
|
131
|
+
const keys = Object.keys(obj);
|
|
132
|
+
const len = keys.length;
|
|
133
|
+
let head;
|
|
134
|
+
if (len <= 15) head = Buffer.from([0x80 | len]); // fixmap
|
|
135
|
+
else if (len <= 0xffff) {
|
|
136
|
+
head = Buffer.alloc(3);
|
|
137
|
+
head[0] = 0xde; // map16
|
|
138
|
+
head.writeUInt16BE(len, 1);
|
|
139
|
+
} else {
|
|
140
|
+
head = Buffer.alloc(5);
|
|
141
|
+
head[0] = 0xdf; // map32
|
|
142
|
+
head.writeUInt32BE(len, 1);
|
|
143
|
+
}
|
|
144
|
+
const parts = [head];
|
|
145
|
+
for (const k of keys) {
|
|
146
|
+
parts.push(encodeStr(k));
|
|
147
|
+
parts.push(encodeMsgpack(obj[k]));
|
|
148
|
+
}
|
|
149
|
+
return Buffer.concat(parts);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Encode a JS value as msgpack. Integers are detected via Number.isInteger
|
|
153
|
+
// (all numeric action fields we emit — asset id, fee, order id, leverage — are
|
|
154
|
+
// integers; prices/sizes are already strings), so a bare number never takes the
|
|
155
|
+
// float64 path unless it genuinely has a fractional part.
|
|
156
|
+
export function encodeMsgpack(v) {
|
|
157
|
+
if (v === null || v === undefined) return Buffer.from([0xc0]); // nil
|
|
158
|
+
if (typeof v === 'boolean') return Buffer.from([v ? 0xc3 : 0xc2]);
|
|
159
|
+
if (typeof v === 'bigint') return encodeInt(v);
|
|
160
|
+
if (typeof v === 'number') return Number.isInteger(v) ? encodeInt(v) : encodeFloat(v);
|
|
161
|
+
if (typeof v === 'string') return encodeStr(v);
|
|
162
|
+
if (Array.isArray(v)) return encodeArray(v);
|
|
163
|
+
if (typeof v === 'object') return encodeMap(v);
|
|
164
|
+
throw new Error(`msgpack: unsupported type ${typeof v}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Number formatting (ports of signing.py) ──────────────────────────
|
|
168
|
+
|
|
169
|
+
// Port of `float_to_wire`: fixed 8-decimal render, verify it doesn't lose
|
|
170
|
+
// precision, then strip trailing zeros (Decimal.normalize + `:f`). Produces the
|
|
171
|
+
// canonical decimal string HL expects in order wires (e.g. 1924.7 -> "1924.7",
|
|
172
|
+
// 0.006 -> "0.006", 2000 -> "2000"). No scientific notation.
|
|
173
|
+
export function floatToWire(x) {
|
|
174
|
+
const rounded = x.toFixed(8);
|
|
175
|
+
if (Math.abs(parseFloat(rounded) - x) >= 1e-12) {
|
|
176
|
+
throw new Error(`floatToWire causes rounding: ${x}`);
|
|
177
|
+
}
|
|
178
|
+
let s = rounded;
|
|
179
|
+
if (s.indexOf('.') !== -1) {
|
|
180
|
+
s = s.replace(/0+$/, '').replace(/\.$/, '');
|
|
181
|
+
}
|
|
182
|
+
// Normalize a signed zero ("-0") to "0", matching the SDK.
|
|
183
|
+
if (s === '-0') s = '0';
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Round a positive-ish value to the nearest integer, ties-to-even (banker's).
|
|
188
|
+
function roundHalfEven(y) {
|
|
189
|
+
const floor = Math.floor(y);
|
|
190
|
+
const diff = y - floor;
|
|
191
|
+
if (diff === 0.5) return floor % 2 === 0 ? floor : floor + 1;
|
|
192
|
+
return Math.round(y);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Port of Python's round(x, ndigits): round-half-to-even. For negative ndigits
|
|
196
|
+
// we scale by an integer factor and multiply back (never divide by 0.1, which
|
|
197
|
+
// injects float dirt that later trips floatToWire's precision guard).
|
|
198
|
+
export function pyRound(x, ndigits) {
|
|
199
|
+
if (!Number.isFinite(x)) return x;
|
|
200
|
+
if (ndigits >= 0) {
|
|
201
|
+
const m = 10 ** ndigits;
|
|
202
|
+
const y = x * m;
|
|
203
|
+
const floor = Math.floor(y);
|
|
204
|
+
const halfUnits = 2 * floor + 1;
|
|
205
|
+
// toFixed observes which side of a decimal half the binary float is on.
|
|
206
|
+
// Preserve ties-to-even only when that half is itself exactly representable.
|
|
207
|
+
if (
|
|
208
|
+
y - floor === 0.5
|
|
209
|
+
&& Number.isSafeInteger(halfUnits)
|
|
210
|
+
&& BigInt(halfUnits) % (5n ** BigInt(ndigits)) === 0n
|
|
211
|
+
) {
|
|
212
|
+
return roundHalfEven(y) / m;
|
|
213
|
+
}
|
|
214
|
+
return parseFloat(x.toFixed(ndigits));
|
|
215
|
+
}
|
|
216
|
+
const m = 10 ** -ndigits; // integer
|
|
217
|
+
return roundHalfEven(x / m) * m;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 5-significant-figure round, ties-to-even — matches Python's f"{x:.5g}" (the
|
|
221
|
+
// first stage of HL's price rounding). toPrecision() rounds ties away from zero,
|
|
222
|
+
// so it can't be used here.
|
|
223
|
+
function roundSigFigs(x, sig) {
|
|
224
|
+
if (x === 0) return 0;
|
|
225
|
+
const digits = Math.floor(Math.log10(Math.abs(x))) + 1; // integer-part digits
|
|
226
|
+
return pyRound(x, sig - digits);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Port of `_round_size`: round size to the asset's szDecimals.
|
|
230
|
+
export function roundSize(size, szDecimals) {
|
|
231
|
+
return pyRound(size, szDecimals);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Port of `_round_price`: 5 significant figures, then round to (6 - szDecimals)
|
|
235
|
+
// decimal places (perps). f"{price:.5g}" == Number.toPrecision(5) reparsed.
|
|
236
|
+
export function roundPrice(price, szDecimals) {
|
|
237
|
+
const px = roundSigFigs(price, 5);
|
|
238
|
+
return pyRound(px, Math.max(0, 6 - szDecimals));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Order wire assembly (ports of signing.py) ────────────────────────
|
|
242
|
+
|
|
243
|
+
function orderTypeToWire(orderType) {
|
|
244
|
+
if ('limit' in orderType) return { limit: orderType.limit };
|
|
245
|
+
if ('trigger' in orderType) {
|
|
246
|
+
// Key order matches the SDK: isMarket, triggerPx, tpsl.
|
|
247
|
+
return {
|
|
248
|
+
trigger: {
|
|
249
|
+
isMarket: orderType.trigger.isMarket,
|
|
250
|
+
triggerPx: floatToWire(orderType.trigger.triggerPx),
|
|
251
|
+
tpsl: orderType.trigger.tpsl,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
throw new Error('Invalid order type');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Port of `order_request_to_order_wire`. Key order (a,b,p,s,r,t) is load-bearing
|
|
259
|
+
// — it's the msgpack map insertion order the hash depends on. No cloid ("c"):
|
|
260
|
+
// the CLI never sets one.
|
|
261
|
+
function orderRequestToOrderWire(order, asset) {
|
|
262
|
+
return {
|
|
263
|
+
a: asset,
|
|
264
|
+
b: order.isBuy,
|
|
265
|
+
p: floatToWire(order.limitPx),
|
|
266
|
+
s: floatToWire(order.sz),
|
|
267
|
+
r: order.reduceOnly,
|
|
268
|
+
t: orderTypeToWire(order.orderType),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Port of `order_wires_to_order_action`. builder ({b,f}) is appended last, only
|
|
273
|
+
// when present — matching the SDK, so a builderless action hashes identically.
|
|
274
|
+
function orderWiresToOrderAction(orderWires, builder, grouping = 'na') {
|
|
275
|
+
const action = { type: 'order', orders: orderWires, grouping };
|
|
276
|
+
if (builder) action.builder = builder;
|
|
277
|
+
return action;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Port of `_validate_tpsl`: a stop/take on the wrong side of entry would trigger
|
|
281
|
+
// immediately and self-close the position the moment it opens.
|
|
282
|
+
//
|
|
283
|
+
// Deliberately validated against the raw `--price`, not the slippage-adjusted IOC
|
|
284
|
+
// limit computed below, to match the reference implementation. A take-profit set
|
|
285
|
+
// just past the mark on a market order can therefore land inside the slippage
|
|
286
|
+
// band; that is accepted here rather than rejected.
|
|
287
|
+
function validateTpsl({ isBuy, price, takeProfit, stopLoss }) {
|
|
288
|
+
if (isBuy) {
|
|
289
|
+
if (stopLoss != null && stopLoss >= price) {
|
|
290
|
+
throw new Error(`Stop-loss for a long must be below the entry price (${price}). Got: ${stopLoss}`);
|
|
291
|
+
}
|
|
292
|
+
if (takeProfit != null && takeProfit <= price) {
|
|
293
|
+
throw new Error(`Take-profit for a long must be above the entry price (${price}). Got: ${takeProfit}`);
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
if (stopLoss != null && stopLoss <= price) {
|
|
297
|
+
throw new Error(`Stop-loss for a short must be above the entry price (${price}). Got: ${stopLoss}`);
|
|
298
|
+
}
|
|
299
|
+
if (takeProfit != null && takeProfit >= price) {
|
|
300
|
+
throw new Error(`Take-profit for a short must be below the entry price (${price}). Got: ${takeProfit}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function assertPositiveOrderValues(size, price, szDecimals) {
|
|
306
|
+
if (size <= 0) {
|
|
307
|
+
throw new CommandError(`Order size rounds to zero at ${szDecimals} decimals.`, 'ZERO_SIZE');
|
|
308
|
+
}
|
|
309
|
+
if (price <= 0) {
|
|
310
|
+
throw new CommandError('Order price rounds to zero.', 'ZERO_PRICE');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Action builders (ports of hyperliquid_exchange.py::prepare_*) ─────
|
|
315
|
+
//
|
|
316
|
+
// Each takes the asset metadata (assetId + szDecimals) the caller sourced from
|
|
317
|
+
// the proxy `GET /perp/meta` endpoint (Decision D4: reads stay on the proxy).
|
|
318
|
+
// `builder` is the {b: <addr lowercased>, f: <fee tenths-bp>} code, attached to
|
|
319
|
+
// order/close fills. Returns { action, size, price } where size/price are the
|
|
320
|
+
// rounded values actually encoded (so callers can report the real fill).
|
|
321
|
+
|
|
322
|
+
export function buildOrderAction(
|
|
323
|
+
// coin is resolved to assetId/szDecimals by the caller (proxy /perp/meta), so
|
|
324
|
+
// it isn't needed here — the asset metadata is passed in the second argument.
|
|
325
|
+
{ isBuy, size, price, orderType = 'limit', reduceOnly = false, tif = 'Gtc', slippage = 0.03, takeProfit = null, stopLoss = null, builder = null },
|
|
326
|
+
{ assetId, szDecimals },
|
|
327
|
+
) {
|
|
328
|
+
validateTpsl({ isBuy, price, takeProfit, stopLoss });
|
|
329
|
+
const roundedSize = roundSize(size, szDecimals);
|
|
330
|
+
|
|
331
|
+
let effectivePrice;
|
|
332
|
+
let ot;
|
|
333
|
+
if (orderType === 'market') {
|
|
334
|
+
const raw = isBuy ? price * (1 + slippage) : price * (1 - slippage);
|
|
335
|
+
effectivePrice = roundPrice(raw, szDecimals);
|
|
336
|
+
ot = { limit: { tif: 'Ioc' } };
|
|
337
|
+
} else {
|
|
338
|
+
effectivePrice = roundPrice(price, szDecimals);
|
|
339
|
+
ot = { limit: { tif } };
|
|
340
|
+
}
|
|
341
|
+
assertPositiveOrderValues(roundedSize, effectivePrice, szDecimals);
|
|
342
|
+
|
|
343
|
+
const wires = [
|
|
344
|
+
orderRequestToOrderWire({ isBuy, sz: roundedSize, limitPx: effectivePrice, orderType: ot, reduceOnly }, assetId),
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
let grouping = 'na';
|
|
348
|
+
if (takeProfit != null || stopLoss != null) {
|
|
349
|
+
grouping = 'normalTpsl';
|
|
350
|
+
if (takeProfit != null) {
|
|
351
|
+
const rtp = roundPrice(takeProfit, szDecimals);
|
|
352
|
+
// A trigger price that rounds to zero would encode triggerPx "0" and rest
|
|
353
|
+
// as a dead order that never fires — the parent position opens unprotected.
|
|
354
|
+
// Guard it like the parent leg rather than submitting a no-op.
|
|
355
|
+
if (rtp <= 0) {
|
|
356
|
+
throw new CommandError('Take-profit price rounds to zero.', 'ZERO_PRICE');
|
|
357
|
+
}
|
|
358
|
+
wires.push(
|
|
359
|
+
orderRequestToOrderWire(
|
|
360
|
+
{ isBuy: !isBuy, sz: roundedSize, limitPx: rtp, orderType: { trigger: { triggerPx: rtp, isMarket: true, tpsl: 'tp' } }, reduceOnly: true },
|
|
361
|
+
assetId,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (stopLoss != null) {
|
|
366
|
+
const rsl = roundPrice(stopLoss, szDecimals);
|
|
367
|
+
if (rsl <= 0) {
|
|
368
|
+
throw new CommandError('Stop-loss price rounds to zero.', 'ZERO_PRICE');
|
|
369
|
+
}
|
|
370
|
+
wires.push(
|
|
371
|
+
orderRequestToOrderWire(
|
|
372
|
+
{ isBuy: !isBuy, sz: roundedSize, limitPx: rsl, orderType: { trigger: { triggerPx: rsl, isMarket: true, tpsl: 'sl' } }, reduceOnly: true },
|
|
373
|
+
assetId,
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const action = orderWiresToOrderAction(wires, builder, grouping);
|
|
380
|
+
return { action, size: roundedSize, price: effectivePrice };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function buildCancelAction({ orderId }, { assetId }) {
|
|
384
|
+
return { action: { type: 'cancel', cancels: [{ a: assetId, o: orderId }] } };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function buildCloseAction({ size, price, isBuy, slippage = 0.03, builder = null }, { assetId, szDecimals }) {
|
|
388
|
+
const raw = isBuy ? price * (1 + slippage) : price * (1 - slippage);
|
|
389
|
+
const effectivePrice = roundPrice(raw, szDecimals);
|
|
390
|
+
const roundedSize = roundSize(size, szDecimals);
|
|
391
|
+
assertPositiveOrderValues(roundedSize, effectivePrice, szDecimals);
|
|
392
|
+
const wire = orderRequestToOrderWire(
|
|
393
|
+
{ isBuy, sz: roundedSize, limitPx: effectivePrice, orderType: { limit: { tif: 'Ioc' } }, reduceOnly: true },
|
|
394
|
+
assetId,
|
|
395
|
+
);
|
|
396
|
+
const action = orderWiresToOrderAction([wire], builder);
|
|
397
|
+
return { action, size: roundedSize, price: effectivePrice };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function buildLeverageAction({ leverage, isCross = true }, { assetId }) {
|
|
401
|
+
return { action: { type: 'updateLeverage', asset: assetId, isCross, leverage } };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── L1 hashing + phantom-agent EIP-712 (ports of signing.py) ─────────
|
|
405
|
+
|
|
406
|
+
// Port of `action_hash(action, vault_address, nonce, None)`. We never set
|
|
407
|
+
// expiresAfter, so the trailing expires-block is omitted (matches the API,
|
|
408
|
+
// which passes expires_after=None).
|
|
409
|
+
export function actionHash(action, vaultAddress, nonce) {
|
|
410
|
+
const packed = encodeMsgpack(action);
|
|
411
|
+
const nonceBuf = Buffer.alloc(8);
|
|
412
|
+
nonceBuf.writeBigUInt64BE(BigInt(nonce), 0);
|
|
413
|
+
const parts = [packed, nonceBuf];
|
|
414
|
+
if (vaultAddress == null) {
|
|
415
|
+
parts.push(Buffer.from([0x00]));
|
|
416
|
+
} else {
|
|
417
|
+
parts.push(Buffer.from([0x01]));
|
|
418
|
+
parts.push(Buffer.from(vaultAddress.replace(/^0x/, ''), 'hex'));
|
|
419
|
+
}
|
|
420
|
+
return keccak256(Buffer.concat(parts));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Port of construct_phantom_agent + l1_payload (source "a"/"b" by network,
|
|
424
|
+
// chainId 1337, Exchange domain). Returns the EIP-712 payload the CLI's
|
|
425
|
+
// signAgent() already knows how to sign.
|
|
426
|
+
export function l1Eip712(action, vaultAddress, nonce, network = hlNetwork()) {
|
|
427
|
+
const hash = actionHash(action, vaultAddress, nonce);
|
|
428
|
+
const connectionId = '0x' + hash.toString('hex');
|
|
429
|
+
return {
|
|
430
|
+
domain: {
|
|
431
|
+
name: 'Exchange',
|
|
432
|
+
version: '1',
|
|
433
|
+
chainId: 1337,
|
|
434
|
+
verifyingContract: '0x0000000000000000000000000000000000000000',
|
|
435
|
+
},
|
|
436
|
+
types: {
|
|
437
|
+
Agent: [
|
|
438
|
+
{ name: 'source', type: 'string' },
|
|
439
|
+
{ name: 'connectionId', type: 'bytes32' },
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
primaryType: 'Agent',
|
|
443
|
+
message: { source: phantomAgentSource(network), connectionId },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── User-signed actions (approveBuilderFee / usdClassTransfer) ───────
|
|
448
|
+
//
|
|
449
|
+
// These skip msgpack/action_hash entirely: the struct is EIP-712-hashed
|
|
450
|
+
// directly under the HyperliquidSignTransaction domain (signatureChainId
|
|
451
|
+
// 0x66eee). Ports of `user_signed_payload` + the two SIGN_TYPES tables.
|
|
452
|
+
|
|
453
|
+
export const APPROVE_BUILDER_FEE_SIGN_TYPES = [
|
|
454
|
+
{ name: 'hyperliquidChain', type: 'string' },
|
|
455
|
+
{ name: 'maxFeeRate', type: 'string' },
|
|
456
|
+
{ name: 'builder', type: 'address' },
|
|
457
|
+
{ name: 'nonce', type: 'uint64' },
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
export const USD_CLASS_TRANSFER_SIGN_TYPES = [
|
|
461
|
+
{ name: 'hyperliquidChain', type: 'string' },
|
|
462
|
+
{ name: 'amount', type: 'string' },
|
|
463
|
+
{ name: 'toPerp', type: 'bool' },
|
|
464
|
+
{ name: 'nonce', type: 'uint64' },
|
|
465
|
+
];
|
|
466
|
+
|
|
467
|
+
// Port of `user_signed_payload`. The message carries extra keys (type,
|
|
468
|
+
// signatureChainId) that aren't in signTypes; EIP-712 hashing pulls fields by
|
|
469
|
+
// name from the type list, so they're ignored in the hash but preserved for the
|
|
470
|
+
// HL submit body.
|
|
471
|
+
export function userSignedEip712(primaryType, signTypes, action) {
|
|
472
|
+
const chainId = parseInt(action.signatureChainId, 16);
|
|
473
|
+
return {
|
|
474
|
+
domain: {
|
|
475
|
+
name: 'HyperliquidSignTransaction',
|
|
476
|
+
version: '1',
|
|
477
|
+
chainId,
|
|
478
|
+
verifyingContract: '0x0000000000000000000000000000000000000000',
|
|
479
|
+
},
|
|
480
|
+
types: { [primaryType]: signTypes },
|
|
481
|
+
primaryType,
|
|
482
|
+
message: action,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Build an approveBuilderFee action (user-signed, master key). maxFeeRate is the
|
|
487
|
+
// HL percentage string (e.g. "0.008%"); builder is the lowercased address.
|
|
488
|
+
export function buildApproveBuilderFeeAction({ maxFeeRate, builder, nonce, network = hlNetwork() }) {
|
|
489
|
+
return {
|
|
490
|
+
action: {
|
|
491
|
+
type: 'approveBuilderFee',
|
|
492
|
+
hyperliquidChain: network,
|
|
493
|
+
signatureChainId: '0x66eee',
|
|
494
|
+
maxFeeRate,
|
|
495
|
+
builder,
|
|
496
|
+
nonce,
|
|
497
|
+
},
|
|
498
|
+
primaryType: 'HyperliquidTransaction:ApproveBuilderFee',
|
|
499
|
+
signTypes: APPROVE_BUILDER_FEE_SIGN_TYPES,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Build a usdClassTransfer action (Spot<->Perps, user-signed). amount is
|
|
504
|
+
// rendered like the SDK: up to 8 decimals, no trailing zeros / sci-notation.
|
|
505
|
+
export function buildUsdClassTransferAction({ amount, toPerp, nonce, network = hlNetwork() }) {
|
|
506
|
+
// toFixed() switches to exponential notation from 1e21 up ("1e+21"), which
|
|
507
|
+
// HL's amount parser rejects — and the failure would surface as an opaque
|
|
508
|
+
// rejection after signing. An amount that large is a mistake either way, so
|
|
509
|
+
// refuse here.
|
|
510
|
+
if (!Number.isFinite(amount) || amount <= 0 || amount >= 1e21) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Invalid usdClassTransfer amount: ${amount}. Must be a positive number below 1e21.`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const strAmount = amount.toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
|
|
516
|
+
return {
|
|
517
|
+
action: {
|
|
518
|
+
type: 'usdClassTransfer',
|
|
519
|
+
hyperliquidChain: network,
|
|
520
|
+
signatureChainId: '0x66eee',
|
|
521
|
+
amount: strAmount,
|
|
522
|
+
toPerp,
|
|
523
|
+
nonce,
|
|
524
|
+
},
|
|
525
|
+
primaryType: 'HyperliquidTransaction:UsdClassTransfer',
|
|
526
|
+
signTypes: USD_CLASS_TRANSFER_SIGN_TYPES,
|
|
527
|
+
};
|
|
528
|
+
}
|
package/src/hl-client.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nansen CLI — direct Hyperliquid exchange submission (client egress).
|
|
3
|
+
*
|
|
4
|
+
* This is the ONE direct-to-HL network call (Decision D4): a signed L1 or
|
|
5
|
+
* user-signed action goes straight from the user's machine to
|
|
6
|
+
* api.hyperliquid.xyz/exchange. Reads and market-data stay on the Nansen proxy
|
|
7
|
+
* (/api/v1/perp/*), so nothing else here talks to HL directly.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the contract of the backend proxy (perp_execute.py) that this
|
|
10
|
+
* replaces. HL replies with an envelope:
|
|
11
|
+
* { status: "ok" | "err", response: <object|string> }
|
|
12
|
+
* and signals failure in TWO ways, both of which must throw:
|
|
13
|
+
* 1. a top-level status of "err" (response carries the reason string), and
|
|
14
|
+
* 2. a status of "ok" that still carries per-action errors in
|
|
15
|
+
* response.data.statuses[].error — a rejected order that would otherwise
|
|
16
|
+
* masquerade as a fill. The proxy caught this via extract_action_errors;
|
|
17
|
+
* going direct, the CLI has to catch it itself.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { CommandError } from "./api.js";
|
|
21
|
+
// The base URL and network live in hl-env.js so hl-action.js can resolve the
|
|
22
|
+
// network without importing this module (it builds actions; this one submits
|
|
23
|
+
// them). Re-exported here because this is where callers expect to find them.
|
|
24
|
+
export {
|
|
25
|
+
HL_MAINNET_API_URL,
|
|
26
|
+
HL_TESTNET_API_URL,
|
|
27
|
+
hlApiUrl,
|
|
28
|
+
hlNetwork,
|
|
29
|
+
} from "./hl-env.js";
|
|
30
|
+
|
|
31
|
+
import { hlApiUrl } from "./hl-env.js";
|
|
32
|
+
|
|
33
|
+
// Port of perp_execute.py::extract_action_errors. HL returns top-level
|
|
34
|
+
// status "ok" even when individual actions are rejected:
|
|
35
|
+
// {"status":"ok","response":{"data":{"statuses":[{"error":"..."}]}}}
|
|
36
|
+
// Split every returned leg so a partial TP/SL cannot be reported as either a
|
|
37
|
+
// total success or a total failure.
|
|
38
|
+
export function extractActionErrors(responseBody, action) {
|
|
39
|
+
const result = { succeeded: [], failed: [] };
|
|
40
|
+
if (!responseBody || typeof responseBody !== "object") return result;
|
|
41
|
+
const data = responseBody.data;
|
|
42
|
+
if (!data || typeof data !== "object") return result;
|
|
43
|
+
const statuses = data.statuses;
|
|
44
|
+
if (!Array.isArray(statuses)) return result;
|
|
45
|
+
for (const [index, entry] of statuses.entries()) {
|
|
46
|
+
const tpsl = action?.orders?.[index]?.t?.trigger?.tpsl;
|
|
47
|
+
const leg = tpsl === "tp"
|
|
48
|
+
? "take-profit"
|
|
49
|
+
: tpsl === "sl"
|
|
50
|
+
? "stop-loss"
|
|
51
|
+
: action?.grouping === "normalTpsl" && index === 0
|
|
52
|
+
? "parent"
|
|
53
|
+
: `leg ${index + 1}`;
|
|
54
|
+
if (entry && typeof entry === "object" && "error" in entry) {
|
|
55
|
+
result.failed.push({ leg, error: String(entry.error) });
|
|
56
|
+
} else {
|
|
57
|
+
result.succeeded.push(leg);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// POST a signed action to HL's /exchange endpoint.
|
|
64
|
+
//
|
|
65
|
+
// `signature` is the {r, s, v} object signAgent() already produces; `nonce` is
|
|
66
|
+
// the same nonce the action was hashed with; `vaultAddress` is null for a normal
|
|
67
|
+
// wallet (omitted from the body when null, matching the SDK).
|
|
68
|
+
//
|
|
69
|
+
// Returns the parsed HL response object on success. Throws CommandError on a
|
|
70
|
+
// network failure, a non-JSON / HTTP-error response, a top-level "err", or a
|
|
71
|
+
// per-action error.
|
|
72
|
+
//
|
|
73
|
+
// Deliberately NOT retried: each submit carries a unique nonce and is not
|
|
74
|
+
// idempotent, so a retry after a request that may have reached HL risks a
|
|
75
|
+
// double-submit. A network error surfaces to the caller as-is.
|
|
76
|
+
export async function submitExchange(
|
|
77
|
+
{ action, nonce, signature, vaultAddress = null },
|
|
78
|
+
{ fetchImpl = fetch, baseUrl = hlApiUrl(), timeoutMs = 30000 } = {}
|
|
79
|
+
) {
|
|
80
|
+
const body = { action, nonce, signature };
|
|
81
|
+
// HL only expects vaultAddress when trading on behalf of a vault; omit the
|
|
82
|
+
// null so a normal-wallet action hashes/serializes like the SDK's.
|
|
83
|
+
if (vaultAddress != null) body.vaultAddress = vaultAddress;
|
|
84
|
+
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
87
|
+
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await fetchImpl(`${baseUrl}/exchange`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// A timeout on a POST is indeterminate: the request may have reached
|
|
98
|
+
// Hyperliquid and been applied even though the response never arrived.
|
|
99
|
+
// Say so rather than implying nothing was sent, and point at the reads that
|
|
100
|
+
// resolve it.
|
|
101
|
+
if (err.name === "AbortError") {
|
|
102
|
+
throw new CommandError(
|
|
103
|
+
`Timed out after ${timeoutMs}ms waiting for Hyperliquid. The request may still have been received — the action may or may not have been applied. Check "nansen perp orders" and "nansen perp positions" before retrying.`,
|
|
104
|
+
"HL_TIMEOUT_INDETERMINATE"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
throw new CommandError(
|
|
108
|
+
`Could not reach Hyperliquid: ${err.message}`,
|
|
109
|
+
"HL_NETWORK_ERROR"
|
|
110
|
+
);
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const text = await response.text();
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = JSON.parse(text);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new CommandError(
|
|
121
|
+
`Hyperliquid returned a non-JSON response (HTTP ${
|
|
122
|
+
response.status
|
|
123
|
+
}): ${text.slice(0, 200)}`,
|
|
124
|
+
"HL_BAD_RESPONSE"
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
const detail =
|
|
130
|
+
typeof data === "string"
|
|
131
|
+
? data
|
|
132
|
+
: data.response || data.error || JSON.stringify(data);
|
|
133
|
+
throw new CommandError(
|
|
134
|
+
`Hyperliquid error (HTTP ${response.status}): ${detail}`,
|
|
135
|
+
"HL_HTTP_ERROR"
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const status = data.status ?? "ok";
|
|
140
|
+
const responseBody = data.response;
|
|
141
|
+
|
|
142
|
+
if (status === "err") {
|
|
143
|
+
const reason =
|
|
144
|
+
typeof responseBody === "string"
|
|
145
|
+
? responseBody
|
|
146
|
+
: "Hyperliquid rejected the action";
|
|
147
|
+
throw new CommandError(
|
|
148
|
+
`Hyperliquid rejected the action: ${reason}`,
|
|
149
|
+
"HL_ACTION_REJECTED"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const actionResults = extractActionErrors(responseBody, action);
|
|
154
|
+
if (actionResults.failed.length > 0 && actionResults.succeeded.length > 0) {
|
|
155
|
+
throw new CommandError(
|
|
156
|
+
`Hyperliquid partially filled the action: succeeded ${actionResults.succeeded.join(", ")}; failed ${actionResults.failed.map(({ leg, error }) => `${leg}: ${error}`).join("; ")}`,
|
|
157
|
+
"PARTIAL_FILL"
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (actionResults.failed.length > 0) {
|
|
161
|
+
throw new CommandError(
|
|
162
|
+
`Hyperliquid rejected the action: ${actionResults.failed.map(({ error }) => error).join("; ")}`,
|
|
163
|
+
"HL_ACTION_REJECTED"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return data;
|
|
168
|
+
}
|