nansen-cli 1.43.0 → 1.44.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 CHANGED
@@ -245,17 +245,28 @@ async function signHlAction(eip712, { privateKeyHex, privyClient, privyWalletId,
245
245
  // Parse the per-order statuses HL returns for an `order` action so the oid and
246
246
  // fill are surfaced — the perp analogue of spot printing its quote id. HL replies:
247
247
  // response.data.statuses[] = { resting:{oid} } | { filled:{oid,totalSz,avgPx} } | { error }
248
- // A rejected leg ({error}) has already thrown in submitExchange, so only
249
- // resting/filled legs reach here. A TP/SL bracket returns multiple legs; label
250
- // them the same way extractActionErrors does (parent / take-profit / stop-loss).
248
+ // Successful responses and structured rejection responses both pass through
249
+ // here. A TP/SL bracket returns multiple legs; label them the same way
250
+ // extractActionErrors does (parent / take-profit / stop-loss).
251
251
  // Gated on the SUBMITTED action being an order: leverage/transfer/builder-fee
252
252
  // actions (type "default") and cancels return no oids, so [] falls back to the
253
253
  // concise raw response line in buildScreenSignSubmit.
254
254
  export function summarizeOrderResult(result, action) {
255
255
  if (action?.type !== 'order') return [];
256
- const statuses = result?.response?.data?.statuses;
257
- if (!Array.isArray(statuses)) return [];
258
- const multiLeg = (action.orders?.length ?? 0) > 1;
256
+ const responseStatuses = result?.response?.data?.statuses;
257
+ if (!Array.isArray(responseStatuses)) return [];
258
+ const orderCount = action.orders?.length ?? 0;
259
+ const multiLeg = orderCount > 1;
260
+ // Hyperliquid may return one pre-validation error for an entire batch rather
261
+ // than one status per order. Treat that single error as the outcome of every
262
+ // submitted leg so bracket attribution remains one-to-one with action.orders.
263
+ const statuses = multiLeg
264
+ && responseStatuses.length === 1
265
+ && responseStatuses[0]
266
+ && typeof responseStatuses[0] === 'object'
267
+ && 'error' in responseStatuses[0]
268
+ ? Array.from({ length: orderCount }, () => responseStatuses[0])
269
+ : responseStatuses;
259
270
  const out = [];
260
271
  for (const [index, entry] of statuses.entries()) {
261
272
  if (!entry || typeof entry !== 'object') continue;
@@ -273,31 +284,51 @@ export function summarizeOrderResult(result, action) {
273
284
  // above 2^53 arrived rounded. Flag precision (oidSafe) so the caller can
274
285
  // withhold a copy-paste cancel — and BI can drop the id — rather than act on
275
286
  // a wrong oid presented as authoritative.
287
+ const orderSide = action.orders?.[index]?.b;
288
+ const side = orderSide === true ? 'buy' : orderSide === false ? 'sell' : undefined;
289
+ const reduceOnly = action.orders?.[index]?.r === true;
290
+ const positionSide = side === undefined
291
+ ? undefined
292
+ : reduceOnly
293
+ ? (side === 'buy' ? 'short' : 'long')
294
+ : (side === 'buy' ? 'long' : 'short');
276
295
  if (entry.filled && entry.filled.oid !== undefined) {
277
296
  const { oid, totalSz, avgPx } = entry.filled;
278
- out.push({ leg, kind: 'filled', oid, oidSafe: Number.isSafeInteger(oid), totalSz, avgPx });
297
+ out.push({ index, leg, side, positionSide, kind: 'filled', oid, oidSafe: Number.isSafeInteger(oid), totalSz, avgPx });
279
298
  } else if (entry.resting && entry.resting.oid !== undefined) {
280
299
  const { oid } = entry.resting;
281
- out.push({ leg, kind: 'resting', oid, oidSafe: Number.isSafeInteger(oid) });
300
+ out.push({ index, leg, side, positionSide, kind: 'resting', oid, oidSafe: Number.isSafeInteger(oid) });
301
+ } else if ('error' in entry) {
302
+ out.push({ index, leg, side, positionSide, kind: 'rejected' });
282
303
  }
283
304
  }
284
305
  return out;
285
306
  }
286
307
 
287
- // Fire the perp_order_completed event via the injected tracker. Deliberately
288
- // minimal (privacy): only the trade side and the parent Hyperliquid order id
289
- // no asset, price, size, or fill detail. The order-placement response carries no
290
- // trade/fill id (that exists only once the order fills, via the fills feed), so
291
- // side + oid is the reliable maximum here. The oid is omitted when it arrived
292
- // rounded past 2^53 (oidSafe false) so BI never records a wrong id. `summary` is
293
- // summarizeOrderResult's output; its parent leg carries the order id.
294
- function emitPerpOrderCompleted(telemetry, summary) {
295
- const parent = summary.find((o) => o.leg === 'parent') ?? summary[0];
296
- return telemetry.track({
308
+ // Fire one privacy-minimal event per response leg. Each oid is joined to fills
309
+ // in BI; the shared nonce reconstructs the submitted batch. Raw wallet, prices,
310
+ // sizes and error text never leave the CLI. Unsafe uint64 ids are omitted.
311
+ function emitPerpOrderCompleted(telemetry, summary, walletAddress, submissionId, errorCode) {
312
+ return Promise.all(summary.map((order) => telemetry.track({
297
313
  command: telemetry.command,
298
- side: telemetry.side,
299
- oid: parent && parent.oidSafe ? parent.oid : undefined,
300
- });
314
+ // Pass the leg's own side (not the command-level side) so telemetry can
315
+ // distinguish a leg whose side is unknown and omit position_side rather
316
+ // than fabricate one. order.side is always set for real HL legs.
317
+ side: order.side,
318
+ position_side: order.positionSide,
319
+ outcome: order.kind,
320
+ // Guard submission_id so a future refactor that drops the nonce can never
321
+ // stringify undefined into the BI dedup key (perpOutcomeEventId derives
322
+ // the event id from it).
323
+ ...(submissionId !== undefined && { submission_id: String(submissionId) }),
324
+ leg_index: order.index,
325
+ leg: order.leg,
326
+ wallet_address: walletAddress,
327
+ oid: order.oidSafe ? order.oid : undefined,
328
+ // A mixed response is a command-level PARTIAL_FILL, but successful legs
329
+ // remain successful. Attach the stable code only to rejected legs.
330
+ ...(order.kind === 'rejected' && errorCode ? { error_code: errorCode } : {}),
331
+ })));
301
332
  }
302
333
 
303
334
  async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
@@ -317,7 +348,24 @@ async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
317
348
  const signature = await signHlAction(eip712, ctx);
318
349
 
319
350
  log(' Submitting to Hyperliquid...');
320
- const result = await submitExchange({ action, nonce, signature, vaultAddress: null });
351
+ let result;
352
+ try {
353
+ result = await submitExchange({ action, nonce, signature, vaultAddress: null });
354
+ } catch (error) {
355
+ if (telemetry && error.exchangeResult) {
356
+ // Emit only authoritative per-leg outcomes. An opaque or malformed HTTP
357
+ // body has an indeterminate result and remains covered by cli_command_failed.
358
+ const outcomes = summarizeOrderResult(error.exchangeResult, action);
359
+ if (outcomes.length) {
360
+ try {
361
+ await emitPerpOrderCompleted(telemetry, outcomes, walletAddress, nonce, error.code);
362
+ } catch {
363
+ // Best-effort; preserve the original exchange error.
364
+ }
365
+ }
366
+ }
367
+ throw error;
368
+ }
321
369
 
322
370
  const status = result.status ?? 'ok';
323
371
  log(` Status: ${status}`);
@@ -345,7 +393,7 @@ async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
345
393
  log(` Response: ${resp}`);
346
394
  }
347
395
 
348
- // Emit the order OUTCOME to BI (oid, fill status/price/size, TP/SL legs) — the
396
+ // Emit the order outcome to BI (one privacy-minimal event per response leg) — the
349
397
  // perp analogue of the command-level telemetry, which fires too early (before
350
398
  // this HL response) to observe any of it. Order/close only: cancel / leverage
351
399
  // / transfer / builder-fee actions carry no `telemetry` and also summarize to
@@ -353,7 +401,7 @@ async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
353
401
  // completed order into a cli_command_failed.
354
402
  if (telemetry && orders.length) {
355
403
  try {
356
- await emitPerpOrderCompleted(telemetry, orders);
404
+ await emitPerpOrderCompleted(telemetry, orders, walletAddress, nonce);
357
405
  } catch {
358
406
  // Best-effort; never surface a tracking error after a real fill.
359
407
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Nansen CLI - Shared list-query helpers
3
+ *
4
+ * Builds pagination and order_by request fragments from CLI options.
5
+ * Lives in a leaf module so src/cli.js and src/commands/*.js share one
6
+ * implementation without circular imports.
7
+ */
8
+
9
+ import { NansenError, ErrorCode } from './api.js';
10
+
11
+ export function buildPagination(options) {
12
+ if (options.limit === undefined && options.page === undefined) return undefined;
13
+ const perPage = options.limit === undefined ? undefined : Number(options.limit);
14
+ if (perPage !== undefined && (!Number.isInteger(perPage) || perPage < 1)) {
15
+ throw new NansenError('--limit must be a positive integer', ErrorCode.INVALID_PARAMS);
16
+ }
17
+ return {
18
+ page: Math.max(1, parseInt(options.page, 10) || 1),
19
+ per_page: perPage,
20
+ };
21
+ }
22
+
23
+ // Parse simple sort syntax: "field:direction" or "field" (defaults to DESC)
24
+ export function parseSort(sortOption, orderByOption) {
25
+ // If --order-by is provided, use it (full JSON control)
26
+ if (orderByOption) return orderByOption;
27
+ if (!sortOption) return undefined;
28
+ const parts = String(sortOption).split(':');
29
+ const field = parts[0];
30
+ const direction = (parts[1] || 'desc').toUpperCase();
31
+ return [{ field, direction }];
32
+ }