nansen-cli 1.34.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.
@@ -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
+ }
@@ -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
+ }