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/src/perp.js ADDED
@@ -0,0 +1,835 @@
1
+ /**
2
+ * Nansen CLI — Hyperliquid perpetual trading commands.
3
+ *
4
+ * Mutating commands (order/cancel/close/leverage/transfer) build the HL action
5
+ * locally (hl-action.js), screen the signing wallet against the live SDN list,
6
+ * sign with existing EIP-712 infrastructure, and submit straight to
7
+ * api.hyperliquid.xyz (hl-client.js) — the Nansen API is out of the order path.
8
+ * Reads (positions/orders/account/meta) and the builder-fee status still go
9
+ * through the proxy /api/v1/perp/* endpoints (Decision D4).
10
+ */
11
+
12
+ import { CommandError } from './api.js';
13
+ import { signSecp256k1 } from './crypto.js';
14
+ import {
15
+ buildApproveBuilderFeeAction,
16
+ buildCancelAction,
17
+ buildCloseAction,
18
+ buildLeverageAction,
19
+ buildOrderAction,
20
+ buildUsdClassTransferAction,
21
+ l1Eip712,
22
+ userSignedEip712,
23
+ } from './hl-action.js';
24
+ import { submitExchange } from './hl-client.js';
25
+ import { resolveEvmWallet, resolvePrivateKey } from './wallet-signing.js';
26
+ import { hashTypedData } from './x402-evm.js';
27
+
28
+ // ── EIP-712 signing ──────────────────────────────────────────────────
29
+
30
+ function signAgent(eip712, privateKeyHex) {
31
+ const { domain, types, primaryType, message } = eip712;
32
+ const fields = (types[primaryType] || []).map(f => ({ name: f.name, type: f.type }));
33
+ const msgHash = hashTypedData(domain, primaryType, fields, message);
34
+ const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
35
+ return {
36
+ r: '0x' + r.toString('hex'),
37
+ s: '0x' + s.toString('hex'),
38
+ v: 27 + v,
39
+ };
40
+ }
41
+
42
+ // ── Proxy read helpers (Decision D4: reads stay on the API) ───────────
43
+
44
+ // cache: false on every read. --cache is meant for research endpoints; here a
45
+ // stale response either misreports live money to the user (positions, orders,
46
+ // account) or feeds a signing decision (close sizes its order from positions,
47
+ // and asset ids/szDecimals come from meta), so a hit inside the 5-minute TTL
48
+ // would sign against data that has moved.
49
+ async function perpRead(apiInstance, endpoint, params) {
50
+ const qs = new URLSearchParams(params).toString();
51
+ // Only append the query string when there is one; a paramless read like `meta`
52
+ // would otherwise resolve to `/api/v1/perp/meta?` with a bare trailing `?`.
53
+ const path = qs ? `/api/v1/perp/${endpoint}?${qs}` : `/api/v1/perp/${endpoint}`;
54
+ return apiInstance.request(path, {}, { method: 'GET', cache: false });
55
+ }
56
+
57
+ // Resolve an asset's id + szDecimals (+ maxLeverage) from the proxy /perp/meta.
58
+ // Fail OPEN to null: a meta outage must not preempt a clearer earlier error
59
+ // (missing wallet, wrong password) — callers that need it to build an action
60
+ // re-check for null and abort at that point (see requireAsset).
61
+ async function fetchAssetMeta(apiInstance, coin) {
62
+ try {
63
+ const meta = await perpRead(apiInstance, 'meta', {});
64
+ const asset = (meta.assets || []).find(a => String(a.name).toUpperCase() === coin);
65
+ if (asset && Number.isInteger(asset.asset_id) && Number.isInteger(asset.sz_decimals)) {
66
+ return { assetId: asset.asset_id, szDecimals: asset.sz_decimals, maxLeverage: asset.max_leverage };
67
+ }
68
+ } catch {
69
+ // meta lookup failed — treat as unavailable; caller decides whether to abort.
70
+ }
71
+ return null;
72
+ }
73
+
74
+ // An action builder needs the asset metadata; without it we cannot construct a
75
+ // correct wire, so abort (fail closed) rather than guessing. The message
76
+ // deliberately omits any upstream error text so it can't be confused with the
77
+ // advisory pre-checks that fall open on the same outage.
78
+ function requireAsset(assetMeta, coin) {
79
+ if (!assetMeta) {
80
+ throw new CommandError(
81
+ `Could not load Hyperliquid asset metadata for ${coin}; not trading.`,
82
+ 'META_UNAVAILABLE',
83
+ );
84
+ }
85
+ return assetMeta;
86
+ }
87
+
88
+ // Hard ceiling on the builder fee this CLI will attach to an order or sign an
89
+ // approval for, in tenths of a basis point. Nansen's published rate is 80
90
+ // (0.08%).
91
+ //
92
+ // The rate arrives from the API and approveBuilderFee authorises a *maximum* on
93
+ // HL, so an unbounded value would be signed as given — the only threat model is
94
+ // a compromised or misconfigured API, which makes this defence in depth rather
95
+ // than a live risk. It also catches a units slip: a rate mistakenly expressed in
96
+ // basis points or percent reads as wildly out of range here.
97
+ //
98
+ // Deliberately equal to the published rate, not a loose multiple: if Nansen's
99
+ // builder fee changes, that should ship as a CLI release rather than take effect
100
+ // silently on every installed client.
101
+ const MAX_BUILDER_FEE_TENTHS_BP = 80;
102
+
103
+ // Fetch the builder-fee status + code from the proxy (single source of truth,
104
+ // Decision D1): { approved, max_fee_rate, required_fee, builder_address }. One
105
+ // call yields both the {b,f} attached to every order/close and the approval
106
+ // gate. Fail closed — this endpoint shares availability with screening, so if
107
+ // it's down we abort rather than trade without the builder code.
108
+ async function fetchBuilderFee(apiInstance, walletAddress) {
109
+ const qs = new URLSearchParams({ wallet_address: walletAddress }).toString();
110
+ let status;
111
+ try {
112
+ // cache: false — this gates whether we sign a builder-fee approval, so a
113
+ // stale verdict either skips a needed approval (HL then rejects the order)
114
+ // or re-signs one that already exists.
115
+ status = await apiInstance.request(`/api/v1/perp/builder-fee?${qs}`, {}, { method: 'GET', cache: false });
116
+ } catch (err) {
117
+ throw new CommandError(
118
+ `Could not resolve the Hyperliquid builder fee, so the trade was not submitted: ${err.message}`,
119
+ 'BUILDER_FEE_UNAVAILABLE',
120
+ );
121
+ }
122
+ if (!status || !status.builder_address || !Number.isInteger(status.required_fee)) {
123
+ throw new CommandError(
124
+ 'Builder-fee status response was malformed, so the trade was not submitted.',
125
+ 'BUILDER_FEE_UNAVAILABLE',
126
+ );
127
+ }
128
+ // Bound the fee at its single entry point: every order/close attaches
129
+ // required_fee as its builder code, and every approval signs a maxFeeRate
130
+ // derived from the same number, so checking here covers both.
131
+ if (status.required_fee < 0 || status.required_fee > MAX_BUILDER_FEE_TENTHS_BP) {
132
+ throw new CommandError(
133
+ `Refusing to trade: the builder fee returned was ${status.required_fee} tenths of a basis point (${builderMaxFeeRate(status.required_fee)}), above the ${MAX_BUILDER_FEE_TENTHS_BP} (${builderMaxFeeRate(MAX_BUILDER_FEE_TENTHS_BP)}) this CLI accepts. Upgrade the CLI if Nansen's builder fee has changed.`,
134
+ 'BUILDER_FEE_TOO_HIGH',
135
+ );
136
+ }
137
+ return status;
138
+ }
139
+
140
+ // The {b,f} builder code attached to order/close actions. `b` is lowercased (HL
141
+ // requirement); `f` is the fee in tenths of a basis point.
142
+ function builderCode(status) {
143
+ return { b: String(status.builder_address).toLowerCase(), f: status.required_fee };
144
+ }
145
+
146
+ // maxFeeRate string signed in approveBuilderFee, derived from the per-order fee
147
+ // so the two can't drift — mirrors the API's config.hl_builder_max_fee_rate
148
+ // (80 tenths-of-a-bp -> "0.08%").
149
+ function builderMaxFeeRate(requiredFee) {
150
+ const percent = requiredFee / 1000;
151
+ return percent.toFixed(4).replace(/0+$/, '').replace(/\.$/, '') + '%';
152
+ }
153
+
154
+ // HL nonce: current time in milliseconds. Must be recent and strictly
155
+ // increasing per account; Date.now() satisfies both for a single command.
156
+ function hlNonce() {
157
+ return Date.now();
158
+ }
159
+
160
+ // ── Wallet helpers ───────────────────────────────────────────────────
161
+
162
+ // Resolution and key handling are shared with bridge.js (wallet-signing.js), so
163
+ // a fix to either can't land on one money path and miss the other.
164
+ function resolveWalletAddress(walletName) {
165
+ return resolveEvmWallet(walletName, 'Hyperliquid perp trading');
166
+ }
167
+
168
+ // ── Screening (Chunk 4) ──────────────────────────────────────────────
169
+ //
170
+ // Per-trade OFAC screening against the live SDN list. This is the compliance
171
+ // checkpoint that lets the CLI submit directly to HL: every mutating action
172
+ // re-screens the signing wallet before it is signed. Fail CLOSED — a sanctioned
173
+ // hit, a non-200 (503 = SDN snapshot unavailable), a network error, or a
174
+ // response that doesn't cover every requested address all abort the trade
175
+ // before signing, never trade through.
176
+
177
+ // Exported for bridge.js, which needs the same fail-closed check before it signs
178
+ // (its EVM deposit leg broadcasts straight to a public RPC, so no server-side
179
+ // screen sits in that path). Worth lifting into its own module if a third caller
180
+ // appears.
181
+ export async function screenOrThrow(apiInstance, addresses) {
182
+ let result;
183
+ try {
184
+ // cache: false is load-bearing, not hygiene. Every mutating command
185
+ // re-screens the signing wallet immediately before signing; serving that
186
+ // verdict from a cache written up to 5 minutes ago would let a
187
+ // newly-listed address through on exactly the guarantee this check exists
188
+ // to provide.
189
+ result = await apiInstance.request('/api/v1/sanctions/screen', { addresses }, { cache: false });
190
+ } catch (err) {
191
+ throw new CommandError(
192
+ `Compliance screening is unavailable, so the trade was not submitted: ${err.message}`,
193
+ 'SCREENING_UNAVAILABLE',
194
+ );
195
+ }
196
+
197
+ const results = Array.isArray(result?.results) ? result.results : [];
198
+ const sanctioned = results.filter(r => r && r.sanctioned).map(r => r.address);
199
+ if (sanctioned.length > 0) {
200
+ throw new CommandError(
201
+ `Wallet address is on the compliance blocklist and cannot trade: ${sanctioned.join(', ')}`,
202
+ 'SANCTIONED',
203
+ );
204
+ }
205
+
206
+ // A 200 that omitted a requested address is unverifiable — fail closed rather
207
+ // than assume the missing address is clean.
208
+ const screened = new Set(results.map(r => String(r.address).toLowerCase()));
209
+ const missing = addresses.filter(a => !screened.has(String(a).toLowerCase()));
210
+ if (missing.length > 0) {
211
+ throw new CommandError(
212
+ `Compliance screening did not cover all addresses, so the trade was not submitted: ${missing.join(', ')}`,
213
+ 'SCREENING_UNAVAILABLE',
214
+ );
215
+ }
216
+ }
217
+
218
+ // ── Sign + direct submit ─────────────────────────────────────────────
219
+
220
+ // Sign an EIP-712 payload with the local wallet key or via Privy. Returns the
221
+ // {r,s,v} object submitExchange expects. Works for both L1 (phantom-agent) and
222
+ // user-signed payloads — the field list comes from the payload's own types.
223
+ async function signHlAction(eip712, { privateKeyHex, privyClient, privyWalletId, log }) {
224
+ if (privyClient && privyWalletId) {
225
+ log(' Signing via Privy...');
226
+ const result = await privyClient.ethSignTypedDataV4(privyWalletId, eip712);
227
+ const sig = result.data?.signature || result.signature || result;
228
+ return {
229
+ r: '0x' + sig.slice(2, 66),
230
+ s: '0x' + sig.slice(66, 130),
231
+ v: parseInt(sig.slice(130, 132), 16),
232
+ };
233
+ }
234
+ log(' Signing...');
235
+ return signAgent(eip712, privateKeyHex);
236
+ }
237
+
238
+ // The direct-to-HL flow that replaces prepareSignExecute: screen the signing
239
+ // wallet against the live SDN list (Chunk 4), sign the locally-built action,
240
+ // and submit straight to api.hyperliquid.xyz (Chunk 2). `prepared` is
241
+ // { action, nonce, eip712, size?, price? } from an hl-action.js builder; the
242
+ // vault is always null for a normal wallet (the CLI signs L1 actions with the
243
+ // wallet key directly). submitExchange throws on any HL rejection.
244
+ async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
245
+ const { action, nonce, eip712, size, price } = prepared;
246
+ const { walletAddress, log } = ctx;
247
+
248
+ log(' Screening...');
249
+ await screenOrThrow(apiInstance, [walletAddress]);
250
+
251
+ // Report the values actually encoded in the signed action (rounded size,
252
+ // slippage-adjusted market price), not the raw input, so we don't misreport
253
+ // the fill.
254
+ if (size !== undefined && price !== undefined) {
255
+ log(` Submitting: ${size} @ ${price}`);
256
+ }
257
+
258
+ const signature = await signHlAction(eip712, ctx);
259
+
260
+ log(' Submitting to Hyperliquid...');
261
+ const result = await submitExchange({ action, nonce, signature, vaultAddress: null });
262
+
263
+ const status = result.status ?? 'ok';
264
+ log(` Status: ${status}`);
265
+ if (result.response) {
266
+ const resp = typeof result.response === 'string' ? result.response : JSON.stringify(result.response);
267
+ log(` Response: ${resp}`);
268
+ }
269
+ return result;
270
+ }
271
+
272
+ // ── Builder-fee onboarding (Chunk 5) ─────────────────────────────────
273
+ //
274
+ // HL silently rejects orders carrying our builder code until the master wallet
275
+ // has approved a matching maxFeeRate. Auto-fire the one-time approval before the
276
+ // first order/close; skip when already approved. The approval is a user-signed
277
+ // action signed by the same wallet key, and is screened like any other.
278
+ async function ensureBuilderApproved(apiInstance, status, ctx) {
279
+ if (status.approved) return;
280
+ const maxFeeRate = builderMaxFeeRate(status.required_fee);
281
+ const builder = String(status.builder_address).toLowerCase();
282
+ // Name the rate and the beneficiary before signing: this authorises a maximum
283
+ // fee on Hyperliquid, so what was approved should be visible in the transcript
284
+ // rather than implied by "(one-time)". fetchBuilderFee has already bounded the
285
+ // rate at MAX_BUILDER_FEE_TENTHS_BP.
286
+ ctx.log(` Approving Nansen builder fee (one-time): max ${maxFeeRate} to ${builder}`);
287
+ const nonce = hlNonce();
288
+ const { action, primaryType, signTypes } = buildApproveBuilderFeeAction({
289
+ maxFeeRate,
290
+ builder,
291
+ nonce,
292
+ });
293
+ const eip712 = userSignedEip712(primaryType, signTypes, action);
294
+ await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx);
295
+ }
296
+
297
+ // ── Input validation ─────────────────────────────────────────────────
298
+ //
299
+ // The perp path coerces strings to booleans (side -> is_buy, margin-type ->
300
+ // is_cross) before anything reaches the backend, so a typo can't be caught
301
+ // server-side — it silently flips to the false branch (short / isolated).
302
+ // Validate against explicit allowlists, and reject non-positive/non-finite
303
+ // numerics, before signing anything.
304
+ //
305
+ // All guards throw a coded CommandError ('INVALID_INPUT') rather than a bare
306
+ // Error, so agents can branch on the error code instead of string-matching.
307
+
308
+ const ORDER_SIDES = new Set(['buy', 'long', 'sell', 'short']);
309
+ const CLOSE_SIDES = new Set(['buy', 'sell']);
310
+ const MARGIN_TYPES = new Set(['cross', 'isolated']);
311
+ // Case-insensitive input -> canonical value the backend expects. Hyperliquid
312
+ // is case-sensitive (Gtc not gtc, limit not LIMIT), so normalise here rather
313
+ // than forwarding the raw string and letting the backend reject it.
314
+ const TIF_VALUES = new Map([['gtc', 'Gtc'], ['ioc', 'Ioc'], ['alo', 'Alo']]);
315
+ const ORDER_TYPES = new Map([['limit', 'limit'], ['market', 'market']]);
316
+
317
+ function invalid(message) {
318
+ return new CommandError(message, 'INVALID_INPUT');
319
+ }
320
+
321
+ // The arg parser collects a repeated flag into an array (to support genuinely
322
+ // repeatable flags elsewhere). Perp flags are never repeatable, so reject the
323
+ // array with a clear message instead of crashing in a string guard or silently
324
+ // using the first element.
325
+ function scalar(raw, name) {
326
+ if (Array.isArray(raw)) {
327
+ throw invalid(`--${name} was provided more than once. Pass --${name} exactly once.`);
328
+ }
329
+ return raw;
330
+ }
331
+
332
+ function assertSide(raw, allowed) {
333
+ const side = String(scalar(raw, 'side') ?? '').toLowerCase();
334
+ if (!allowed.has(side)) {
335
+ throw invalid(`Invalid --side "${raw}". Must be one of: ${[...allowed].join(', ')}.`);
336
+ }
337
+ return side;
338
+ }
339
+
340
+ function assertMarginType(raw) {
341
+ // --margin-type is optional and defaults to cross when omitted.
342
+ if (raw === undefined) return 'cross';
343
+ const marginType = String(scalar(raw, 'margin-type') ?? '').toLowerCase();
344
+ if (!MARGIN_TYPES.has(marginType)) {
345
+ throw invalid(`Invalid --margin-type "${raw}". Must be one of: ${[...MARGIN_TYPES].join(', ')}.`);
346
+ }
347
+ return marginType;
348
+ }
349
+
350
+ function parsePositiveNumber(raw, name) {
351
+ // Strict numeric check before parseFloat — parseFloat("100abc") returns 100,
352
+ // so trailing garbage would otherwise slip through and only fail at the backend.
353
+ const s = String(scalar(raw, name) ?? '').trim();
354
+ if (!/^\d*\.?\d+$/.test(s)) {
355
+ throw invalid(`Invalid --${name} "${raw}". Must be a positive number.`);
356
+ }
357
+ const n = parseFloat(s);
358
+ if (!Number.isFinite(n) || n <= 0) {
359
+ throw invalid(`Invalid --${name} "${raw}". Must be a positive number.`);
360
+ }
361
+ return n;
362
+ }
363
+
364
+ function parsePositiveInt(raw, name) {
365
+ // Digits-only check before parseInt — parseInt("2.5") floors to 2 and
366
+ // parseInt("123abc") yields 123, so a fractional or garbage value would
367
+ // otherwise be silently accepted.
368
+ const s = String(scalar(raw, name) ?? '').trim();
369
+ if (!/^\d+$/.test(s)) {
370
+ throw invalid(`Invalid --${name} "${raw}". Must be a positive integer.`);
371
+ }
372
+ const n = parseInt(s, 10);
373
+ if (!Number.isInteger(n) || n <= 0) {
374
+ throw invalid(`Invalid --${name} "${raw}". Must be a positive integer.`);
375
+ }
376
+ return n;
377
+ }
378
+
379
+ function parseSlippage(raw) {
380
+ // Slippage is a decimal fraction in [0, 1] (0.03 = 3%). Reject trailing
381
+ // garbage (parseFloat would accept "0.03abc") and percent-vs-decimal
382
+ // mix-ups (e.g. "3" meaning 3% would otherwise be a 300% tolerance).
383
+ const s = String(scalar(raw, 'slippage') ?? '').trim();
384
+ const n = /^\d*\.?\d+$/.test(s) ? parseFloat(s) : NaN;
385
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
386
+ throw invalid(`Invalid --slippage "${raw}". Use a decimal between 0 and 1 (e.g. 0.03 for 3%).`);
387
+ }
388
+ return n;
389
+ }
390
+
391
+ // Count the decimal places in a validated numeric string. The numeric guards
392
+ // above reject scientific notation and trailing garbage, so a plain split on
393
+ // "." is exact (no float-repr drift from parseFloat).
394
+ function countDecimals(numStr) {
395
+ const s = String(numStr).trim();
396
+ const dot = s.indexOf('.');
397
+ return dot === -1 ? 0 : s.length - dot - 1;
398
+ }
399
+
400
+ // Hyperliquid rounds an over-precise size/price to the asset's precision rather
401
+ // than rejecting it (size -> szDecimals; price -> 6 - szDecimals decimals for
402
+ // perps), so the order still fills — but silently at a different value than the
403
+ // user typed. Warn up front (the post-prepare "Submitting" line then shows the
404
+ // exact rounded value). Fail open: with no szDecimals (meta unavailable) skip.
405
+ function warnImpreciseValue(coin, szDecimals, { sizeRaw, priceRaw }, warn) {
406
+ if (!Number.isInteger(szDecimals)) return;
407
+ if (sizeRaw !== undefined && countDecimals(sizeRaw) > szDecimals) {
408
+ warn(`⚠️ --size ${sizeRaw} is more precise than ${coin} allows (max ${szDecimals} decimals); Hyperliquid will round it.`);
409
+ }
410
+ const maxPriceDecimals = Math.max(0, 6 - szDecimals);
411
+ if (priceRaw !== undefined && countDecimals(priceRaw) > maxPriceDecimals) {
412
+ warn(`⚠️ --price ${priceRaw} is more precise than ${coin} allows (max ${maxPriceDecimals} decimals); Hyperliquid will round it.`);
413
+ }
414
+ }
415
+
416
+ // Resolve the signing half of a mutating command's context for an
417
+ // already-resolved wallet: a local private key, or a Privy client + wallet id.
418
+ // Returns the ctx object buildScreenSignSubmit consumes (walletAddress + one of
419
+ // the two signing paths + log). Kept separate from resolveWalletAddress so a
420
+ // command that needs the address earlier (e.g. close's direction pre-check) can
421
+ // resolve the key afterwards, matching the previous ordering. Takes the resolved
422
+ // wallet, not its name, so the wallet is read once per command.
423
+ async function resolveSigningCtx(wallet, log) {
424
+ const ctx = {
425
+ walletAddress: wallet.address,
426
+ privateKeyHex: null,
427
+ privyClient: null,
428
+ privyWalletId: null,
429
+ log,
430
+ };
431
+ if (wallet.provider === 'privy') {
432
+ const { PrivyClient } = await import('./privy.js');
433
+ ctx.privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
434
+ ctx.privyWalletId = wallet.privyWalletIds?.evm;
435
+ } else {
436
+ ctx.privateKeyHex = resolvePrivateKey(wallet);
437
+ }
438
+ return ctx;
439
+ }
440
+
441
+ function assertTif(raw) {
442
+ // --tif is optional and defaults to Gtc when omitted.
443
+ if (raw === undefined) return 'Gtc';
444
+ const tif = TIF_VALUES.get(String(scalar(raw, 'tif') ?? '').toLowerCase());
445
+ if (!tif) {
446
+ throw invalid(`Invalid --tif "${raw}". Must be one of: Gtc, Ioc, Alo.`);
447
+ }
448
+ return tif;
449
+ }
450
+
451
+ function assertOrderType(raw) {
452
+ // --type is optional and defaults to limit when omitted.
453
+ if (raw === undefined) return 'limit';
454
+ const type = ORDER_TYPES.get(String(scalar(raw, 'type') ?? '').toLowerCase());
455
+ if (!type) {
456
+ throw invalid(`Invalid --type "${raw}". Must be one of: limit, market.`);
457
+ }
458
+ return type;
459
+ }
460
+
461
+ // Resolve the asset symbol from --coin (or its --symbol alias), rejecting a
462
+ // duplicated flag. Returns the upper-cased symbol, or '' when neither is set.
463
+ function resolveCoin(options) {
464
+ const raw = scalar(options.coin, 'coin') ?? scalar(options.symbol, 'symbol');
465
+ return String(raw ?? '').toUpperCase();
466
+ }
467
+
468
+ // ── Command builder ──────────────────────────────────────────────────
469
+
470
+ export function buildPerpCommands(deps = {}) {
471
+ const { log = console.log, warn = (m) => process.stderr.write(`${m}\n`) } = deps;
472
+
473
+ return {
474
+ 'order': async (args, apiInstance, flags, options) => {
475
+ const coin = resolveCoin(options);
476
+ const walletName = scalar(options.wallet, 'wallet');
477
+
478
+ if (!coin || !options.side || options.size === undefined || options.price === undefined) {
479
+ throw new CommandError(
480
+ `Usage: nansen perp order --coin <symbol> --side <buy|sell> --size <amount> --price <price> [options]
481
+
482
+ OPTIONS:
483
+ --coin Asset symbol (BTC, ETH, etc.)
484
+ --side buy (long) or sell (short)
485
+ --size Position size in base asset units
486
+ --price Limit price (or mark price for market orders)
487
+ --type Order type: limit (default) or market
488
+ --tif Time-in-force: Gtc (default), Ioc, Alo
489
+ --slippage Slippage for market orders (default 0.03 = 3%)
490
+ --take-profit Take-profit trigger price
491
+ --stop-loss Stop-loss trigger price
492
+ --wallet Wallet name`, 'MISSING_PARAM');
493
+ }
494
+
495
+ const side = assertSide(options.side, ORDER_SIDES);
496
+ const orderType = assertOrderType(options.type);
497
+ const tif = assertTif(options.tif);
498
+ const size = parsePositiveNumber(options.size, 'size');
499
+ const price = parsePositiveNumber(options.price, 'price');
500
+ const slippage = options.slippage !== undefined ? parseSlippage(options.slippage) : 0.03;
501
+ const tp = options['take-profit'] !== undefined ? parsePositiveNumber(options['take-profit'], 'take-profit') : undefined;
502
+ const sl = options['stop-loss'] !== undefined ? parsePositiveNumber(options['stop-loss'], 'stop-loss') : undefined;
503
+ const isBuy = side === 'buy' || side === 'long';
504
+
505
+ // One meta read serves both the advisory precision warning and the
506
+ // required build metadata. Fetched fail-open so a meta outage doesn't
507
+ // preempt a clearer error below (missing wallet, wrong password); we
508
+ // re-require it just before building the action.
509
+ const assetMeta = await fetchAssetMeta(apiInstance, coin);
510
+ warnImpreciseValue(coin, assetMeta?.szDecimals, { sizeRaw: options.size, priceRaw: options.price }, warn);
511
+
512
+ const wallet = resolveWalletAddress(walletName);
513
+ const ctx = await resolveSigningCtx(wallet, log);
514
+
515
+ const { assetId, szDecimals } = requireAsset(assetMeta, coin);
516
+
517
+ log(`\n Perp Order: ${coin} ${isBuy ? 'LONG' : 'SHORT'} ${size} @ ${price} (${orderType})`);
518
+
519
+ // Single source of truth for the builder code + approval gate (D1).
520
+ // On the first trade this screens the wallet once for the builder-fee
521
+ // approval and again for the order itself. The two round-trips are
522
+ // deliberate: each signed action re-screens the signer, so this is not
523
+ // duplication to collapse.
524
+ const builderStatus = await fetchBuilderFee(apiInstance, wallet.address);
525
+ await ensureBuilderApproved(apiInstance, builderStatus, ctx);
526
+
527
+ const { action, size: effSize, price: effPrice } = buildOrderAction(
528
+ {
529
+ isBuy,
530
+ size,
531
+ price,
532
+ orderType,
533
+ reduceOnly: false,
534
+ tif,
535
+ slippage,
536
+ takeProfit: tp ?? null,
537
+ stopLoss: sl ?? null,
538
+ builder: builderCode(builderStatus),
539
+ },
540
+ { assetId, szDecimals },
541
+ );
542
+ const nonce = hlNonce();
543
+ const eip712 = l1Eip712(action, null, nonce);
544
+
545
+ await buildScreenSignSubmit(apiInstance, { action, nonce, eip712, size: effSize, price: effPrice }, ctx);
546
+ log('');
547
+ return undefined;
548
+ },
549
+
550
+ 'cancel': async (args, apiInstance, flags, options) => {
551
+ const coin = resolveCoin(options);
552
+ const walletName = scalar(options.wallet, 'wallet');
553
+
554
+ if (!coin || options.oid === undefined) {
555
+ throw new CommandError('Usage: nansen perp cancel --coin <symbol> --oid <orderId> [--wallet <name>]', 'MISSING_PARAM');
556
+ }
557
+
558
+ const oid = parsePositiveInt(options.oid, 'oid');
559
+
560
+ const assetMeta = await fetchAssetMeta(apiInstance, coin);
561
+ const wallet = resolveWalletAddress(walletName);
562
+ const ctx = await resolveSigningCtx(wallet, log);
563
+ const { assetId } = requireAsset(assetMeta, coin);
564
+
565
+ log(`\n Cancel: ${coin} order #${oid}`);
566
+
567
+ const { action } = buildCancelAction({ orderId: oid }, { assetId });
568
+ const nonce = hlNonce();
569
+ const eip712 = l1Eip712(action, null, nonce);
570
+
571
+ await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx);
572
+ log('');
573
+ return undefined;
574
+ },
575
+
576
+ 'close': async (args, apiInstance, flags, options) => {
577
+ const coin = resolveCoin(options);
578
+ const walletName = scalar(options.wallet, 'wallet');
579
+
580
+ if (!coin || options.size === undefined || options.price === undefined || !options.side) {
581
+ throw new CommandError(
582
+ `Usage: nansen perp close --coin <symbol> --size <amount> --price <markPrice> --side <buy|sell> [options]
583
+
584
+ --side buy (closing a short) or sell (closing a long)
585
+ --slippage Slippage tolerance (default 0.03 = 3%)`, 'MISSING_PARAM');
586
+ }
587
+
588
+ const side = assertSide(options.side, CLOSE_SIDES);
589
+ const size = parsePositiveNumber(options.size, 'size');
590
+ const price = parsePositiveNumber(options.price, 'price');
591
+ const slippage = options.slippage !== undefined ? parseSlippage(options.slippage) : 0.03;
592
+ const isBuy = side === 'buy';
593
+
594
+ // Advisory: warn before signing if the close size is finer than the asset
595
+ // allows (Hyperliquid rounds rather than rejects). --price here is only a
596
+ // reference mark for the slippage calc, so its precision is not flagged.
597
+ const assetMeta = await fetchAssetMeta(apiInstance, coin);
598
+ warnImpreciseValue(coin, assetMeta?.szDecimals, { sizeRaw: options.size }, warn);
599
+
600
+ const wallet = resolveWalletAddress(walletName);
601
+
602
+ // Validate the close direction against the open position so a wrong --side
603
+ // fails fast with a clear message instead of the backend's opaque "reduce
604
+ // only order would increase position". sell closes a long, buy closes a
605
+ // short. Fall open if positions can't be fetched — HL still checks.
606
+ let openPositions = null;
607
+ try {
608
+ const result = await perpRead(apiInstance, 'positions', { wallet_address: wallet.address });
609
+ openPositions = result.positions || [];
610
+ } catch {
611
+ // positions lookup failed — skip the direction pre-check.
612
+ }
613
+ if (openPositions) {
614
+ const pos = openPositions.find(p => String(p.coin).toUpperCase() === coin);
615
+ const szi = pos ? parseFloat(pos.szi) : NaN;
616
+ if (Number.isFinite(szi) && szi !== 0) {
617
+ const requiredSide = szi > 0 ? 'sell' : 'buy';
618
+ if (side !== requiredSide) {
619
+ const posSide = szi > 0 ? 'long' : 'short';
620
+ throw invalid(
621
+ `Cannot close a ${posSide} ${coin} position with --side ${side}. Use --side ${requiredSide} (sell closes a long, buy closes a short).`,
622
+ );
623
+ }
624
+ }
625
+ }
626
+
627
+ const ctx = await resolveSigningCtx(wallet, log);
628
+ const { assetId, szDecimals } = requireAsset(assetMeta, coin);
629
+
630
+ log(`\n Close: ${coin} ${isBuy ? 'buy-to-close' : 'sell-to-close'} ${size} @ ${price}`);
631
+
632
+ const builderStatus = await fetchBuilderFee(apiInstance, wallet.address);
633
+ await ensureBuilderApproved(apiInstance, builderStatus, ctx);
634
+
635
+ const { action, size: effSize, price: effPrice } = buildCloseAction(
636
+ { size, price, isBuy, slippage, builder: builderCode(builderStatus) },
637
+ { assetId, szDecimals },
638
+ );
639
+ const nonce = hlNonce();
640
+ const eip712 = l1Eip712(action, null, nonce);
641
+
642
+ await buildScreenSignSubmit(apiInstance, { action, nonce, eip712, size: effSize, price: effPrice }, ctx);
643
+ log('');
644
+ return undefined;
645
+ },
646
+
647
+ 'leverage': async (args, apiInstance, flags, options) => {
648
+ const coin = resolveCoin(options);
649
+ const walletName = scalar(options.wallet, 'wallet');
650
+
651
+ if (!coin || options.leverage === undefined) {
652
+ throw new CommandError('Usage: nansen perp leverage --coin <symbol> --leverage <n> [--margin-type cross|isolated] [--wallet <name>]', 'MISSING_PARAM');
653
+ }
654
+
655
+ const marginType = assertMarginType(options['margin-type']);
656
+ const leverage = parsePositiveInt(options.leverage, 'leverage');
657
+
658
+ // Pre-validate against the asset's max leverage so an over-max value fails
659
+ // fast with a clear message instead of an opaque HL rejection. Falls open
660
+ // if meta is unavailable or the coin isn't listed (HL still checks); the
661
+ // build below re-requires meta since it needs the asset id.
662
+ const assetMeta = await fetchAssetMeta(apiInstance, coin);
663
+ if (assetMeta && Number.isFinite(assetMeta.maxLeverage) && leverage > assetMeta.maxLeverage) {
664
+ throw invalid(`Leverage ${leverage}x exceeds the ${assetMeta.maxLeverage}x maximum for ${coin}.`);
665
+ }
666
+
667
+ const isCross = marginType === 'cross';
668
+ const wallet = resolveWalletAddress(walletName);
669
+ const ctx = await resolveSigningCtx(wallet, log);
670
+ const { assetId } = requireAsset(assetMeta, coin);
671
+
672
+ log(`\n Leverage: ${coin} ${leverage}x ${isCross ? 'cross' : 'isolated'}`);
673
+
674
+ const { action } = buildLeverageAction({ leverage, isCross }, { assetId });
675
+ const nonce = hlNonce();
676
+ const eip712 = l1Eip712(action, null, nonce);
677
+
678
+ await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx);
679
+ log('');
680
+ return undefined;
681
+ },
682
+
683
+ 'transfer': async (args, apiInstance, flags, options) => {
684
+ const direction = scalar(options.direction, 'direction');
685
+ const walletName = scalar(options.wallet, 'wallet');
686
+
687
+ if (!direction || options.amount === undefined) {
688
+ throw new CommandError(
689
+ 'Usage: nansen perp transfer --direction <spot-to-perp|perp-to-spot> --amount <usdc> [--wallet <name>]',
690
+ 'MISSING_PARAM',
691
+ );
692
+ }
693
+
694
+ // Move USDC between the wallet's Spot and Perps balances (usdClassTransfer).
695
+ const DIRECTIONS = new Map([['spot-to-perp', true], ['perp-to-spot', false]]);
696
+ const toPerp = DIRECTIONS.get(String(direction).toLowerCase());
697
+ if (toPerp === undefined) {
698
+ throw invalid(`Invalid --direction "${direction}". Must be one of: spot-to-perp, perp-to-spot.`);
699
+ }
700
+ const amount = parsePositiveNumber(options.amount, 'amount');
701
+
702
+ const wallet = resolveWalletAddress(walletName);
703
+ const ctx = await resolveSigningCtx(wallet, log);
704
+
705
+ log(`\n Transfer: ${amount} USDC ${toPerp ? 'Spot → Perps' : 'Perps → Spot'}`);
706
+
707
+ // usdClassTransfer is user-signed: the nonce is embedded in the action.
708
+ const nonce = hlNonce();
709
+ const { action, primaryType, signTypes } = buildUsdClassTransferAction({ amount, toPerp, nonce });
710
+ const eip712 = userSignedEip712(primaryType, signTypes, action);
711
+
712
+ await buildScreenSignSubmit(apiInstance, { action, nonce, eip712 }, ctx);
713
+ log('');
714
+ return undefined;
715
+ },
716
+
717
+ 'approve-builder-fee': async (args, apiInstance, flags, options) => {
718
+ // One-time onboarding: authorize Nansen's builder fee so orders route with
719
+ // the builder code. order/close auto-fire this on the first trade; this
720
+ // command lets a client approve up front. No-op when already approved.
721
+ const walletName = scalar(options.wallet, 'wallet');
722
+ const wallet = resolveWalletAddress(walletName);
723
+ const ctx = await resolveSigningCtx(wallet, log);
724
+
725
+ const builderStatus = await fetchBuilderFee(apiInstance, wallet.address);
726
+ if (builderStatus.approved) {
727
+ log(`\n Builder fee already approved for ${wallet.address}\n`);
728
+ return undefined;
729
+ }
730
+
731
+ log(`\n Approve builder fee: ${wallet.address}`);
732
+ await ensureBuilderApproved(apiInstance, builderStatus, ctx);
733
+ log('');
734
+ return undefined;
735
+ },
736
+
737
+ 'positions': async (args, apiInstance, flags, options) => {
738
+ const walletName = scalar(options.wallet, 'wallet');
739
+ const wallet = resolveWalletAddress(walletName);
740
+
741
+ const result = await perpRead(apiInstance, 'positions', { wallet_address: wallet.address });
742
+ const positions = result.positions || [];
743
+
744
+ if (!positions.length) {
745
+ log('\n No open positions\n');
746
+ return undefined;
747
+ }
748
+
749
+ log(`\n Open Positions (${positions.length}):`);
750
+ for (const p of positions) {
751
+ const side = parseFloat(p.szi) >= 0 ? 'LONG' : 'SHORT';
752
+ log(` ${p.coin} ${side} size=${p.szi} entry=${p.entryPx} pnl=${p.unrealizedPnl} liq=${p.liquidationPx || 'n/a'}`);
753
+ }
754
+ log('');
755
+ return undefined;
756
+ },
757
+
758
+ 'orders': async (args, apiInstance, flags, options) => {
759
+ const walletName = scalar(options.wallet, 'wallet');
760
+ const wallet = resolveWalletAddress(walletName);
761
+
762
+ const result = await perpRead(apiInstance, 'orders', { wallet_address: wallet.address });
763
+ const orders = result.orders || [];
764
+
765
+ if (!orders.length) {
766
+ log('\n No open orders\n');
767
+ return undefined;
768
+ }
769
+
770
+ log(`\n Open Orders (${orders.length}):`);
771
+ for (const o of orders) {
772
+ log(` ${o.coin} ${o.side} size=${o.sz} price=${o.limitPx} oid=${o.oid}`);
773
+ }
774
+ log('');
775
+ return undefined;
776
+ },
777
+
778
+ 'account': async (args, apiInstance, flags, options) => {
779
+ const walletName = scalar(options.wallet, 'wallet');
780
+ const wallet = resolveWalletAddress(walletName);
781
+
782
+ const result = await perpRead(apiInstance, 'account', { wallet_address: wallet.address });
783
+ const ms = result.marginSummary || {};
784
+
785
+ // Sum per-position unrealized PnL. marginSummary.totalRawUsd is the account's
786
+ // total raw USD (≈ collateral / account value), NOT profit-and-loss — labeling
787
+ // it "Total PnL" made it read identical to account value (ECINT-6828).
788
+ const unrealizedPnl = (result.assetPositions || []).reduce(
789
+ (sum, p) => sum + (parseFloat(p.position?.unrealizedPnl) || 0),
790
+ 0,
791
+ );
792
+
793
+ log(`\n Hyperliquid Account: ${wallet.address}`);
794
+ log(` Account Value: $${ms.accountValue || '0'}`);
795
+ log(` Unrealized PnL: $${unrealizedPnl.toFixed(2)}`);
796
+ log(` Margin Used: $${ms.totalMarginUsed || '0'}`);
797
+ log(` Withdrawable: $${result.withdrawable || '0'}`);
798
+ // Spot balance is separate from Perps: USDC sent via Hyperliquid "Send"
799
+ // lands here and can't be traded until moved with `perp transfer`.
800
+ log(` Spot USDC: $${result.spotUsdc ?? 'n/a'}`);
801
+ log('');
802
+ return undefined;
803
+ },
804
+
805
+ 'meta': async (args, apiInstance, flags, options) => {
806
+ const result = await perpRead(apiInstance, 'meta', {});
807
+ let assets = result.assets || [];
808
+
809
+ const filter = String(scalar(options.filter, 'filter') ?? '').toUpperCase();
810
+ if (filter) {
811
+ assets = assets.filter(a => String(a.name).toUpperCase().includes(filter));
812
+ }
813
+ // Default to a preview; --all or --filter shows the full (matching) set so
814
+ // assets past the first 20 (e.g. HYPE) are reachable from the CLI.
815
+ const showAll = flags.all || !!filter;
816
+ const shown = showAll ? assets : assets.slice(0, 20);
817
+
818
+ const heading = filter ? ` matching "${options.filter}"` : '';
819
+ log(`\n Hyperliquid Perp Assets (${assets.length}${heading}):`);
820
+ log(' ID Name Size Dec Max Lev');
821
+ for (const a of shown) {
822
+ const id = String(a.asset_id).padStart(4);
823
+ const name = a.name.padEnd(12);
824
+ const szDec = String(a.sz_decimals).padStart(8);
825
+ const maxLev = String(a.max_leverage).padStart(9);
826
+ log(` ${id} ${name} ${szDec} ${maxLev}`);
827
+ }
828
+ if (!showAll && assets.length > 20) {
829
+ log(` ... and ${assets.length - 20} more (use --all, or --filter <text>)`);
830
+ }
831
+ log('');
832
+ return undefined;
833
+ },
834
+ };
835
+ }