nansen-cli 1.36.2 → 1.38.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 +42 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +2 -0
- package/skills/nansen-wallet-keychain-migration/SKILL.md +14 -12
- package/src/api.js +11 -0
- package/src/cli.js +122 -58
- package/src/cost-cache.js +19 -1
- package/src/doctor.js +480 -0
- package/src/keychain.js +46 -0
- package/src/perp.js +134 -5
- package/src/schema.json +93 -0
- package/src/telemetry.js +59 -2
- package/src/trade-validation.js +441 -0
- package/src/trading.js +387 -18
- package/src/update-check.js +45 -24
- package/src/walletconnect-trading.js +11 -7
package/src/perp.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
userSignedEip712,
|
|
23
23
|
} from './hl-action.js';
|
|
24
24
|
import { submitExchange } from './hl-client.js';
|
|
25
|
+
import { trackPerpOrderCompleted } from './telemetry.js';
|
|
25
26
|
import { resolveEvmWallet, resolvePrivateKey } from './wallet-signing.js';
|
|
26
27
|
import { hashTypedData } from './x402-evm.js';
|
|
27
28
|
|
|
@@ -241,8 +242,66 @@ async function signHlAction(eip712, { privateKeyHex, privyClient, privyWalletId,
|
|
|
241
242
|
// { action, nonce, eip712, size?, price? } from an hl-action.js builder; the
|
|
242
243
|
// vault is always null for a normal wallet (the CLI signs L1 actions with the
|
|
243
244
|
// wallet key directly). submitExchange throws on any HL rejection.
|
|
245
|
+
// Parse the per-order statuses HL returns for an `order` action so the oid and
|
|
246
|
+
// fill are surfaced — the perp analogue of spot printing its quote id. HL replies:
|
|
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).
|
|
251
|
+
// Gated on the SUBMITTED action being an order: leverage/transfer/builder-fee
|
|
252
|
+
// actions (type "default") and cancels return no oids, so [] falls back to the
|
|
253
|
+
// concise raw response line in buildScreenSignSubmit.
|
|
254
|
+
export function summarizeOrderResult(result, action) {
|
|
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;
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const [index, entry] of statuses.entries()) {
|
|
261
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
262
|
+
const tpsl = action.orders?.[index]?.t?.trigger?.tpsl;
|
|
263
|
+
const leg = tpsl === 'tp'
|
|
264
|
+
? 'take-profit'
|
|
265
|
+
: tpsl === 'sl'
|
|
266
|
+
? 'stop-loss'
|
|
267
|
+
: action.grouping === 'normalTpsl' && index === 0
|
|
268
|
+
? 'parent'
|
|
269
|
+
: multiLeg
|
|
270
|
+
? `leg ${index + 1}`
|
|
271
|
+
: 'parent';
|
|
272
|
+
// HL oids are uint64; JSON.parse already narrowed them to Number, so any id
|
|
273
|
+
// above 2^53 arrived rounded. Flag precision (oidSafe) so the caller can
|
|
274
|
+
// withhold a copy-paste cancel — and BI can drop the id — rather than act on
|
|
275
|
+
// a wrong oid presented as authoritative.
|
|
276
|
+
if (entry.filled && entry.filled.oid !== undefined) {
|
|
277
|
+
const { oid, totalSz, avgPx } = entry.filled;
|
|
278
|
+
out.push({ leg, kind: 'filled', oid, oidSafe: Number.isSafeInteger(oid), totalSz, avgPx });
|
|
279
|
+
} else if (entry.resting && entry.resting.oid !== undefined) {
|
|
280
|
+
const { oid } = entry.resting;
|
|
281
|
+
out.push({ leg, kind: 'resting', oid, oidSafe: Number.isSafeInteger(oid) });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
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({
|
|
297
|
+
command: telemetry.command,
|
|
298
|
+
side: telemetry.side,
|
|
299
|
+
oid: parent && parent.oidSafe ? parent.oid : undefined,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
244
303
|
async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
|
|
245
|
-
const { action, nonce, eip712, size, price } = prepared;
|
|
304
|
+
const { action, nonce, eip712, size, price, coin, telemetry } = prepared;
|
|
246
305
|
const { walletAddress, log } = ctx;
|
|
247
306
|
|
|
248
307
|
log(' Screening...');
|
|
@@ -262,10 +321,44 @@ async function buildScreenSignSubmit(apiInstance, prepared, ctx) {
|
|
|
262
321
|
|
|
263
322
|
const status = result.status ?? 'ok';
|
|
264
323
|
log(` Status: ${status}`);
|
|
265
|
-
|
|
324
|
+
|
|
325
|
+
// Surface the order id(s) HL returned so the caller can track/cancel the
|
|
326
|
+
// order — mirrors how spot prints its quote id plus a ready-to-run follow-up.
|
|
327
|
+
const orders = summarizeOrderResult(result, action);
|
|
328
|
+
if (orders.length) {
|
|
329
|
+
for (const o of orders) {
|
|
330
|
+
const tag = o.leg === 'parent' ? '' : ` [${o.leg}]`;
|
|
331
|
+
// Withhold the exact id (and the copy-paste cancel) when it arrived rounded
|
|
332
|
+
// past 2^53 — a wrong oid presented as actionable is worse than none.
|
|
333
|
+
const oidText = o.oidSafe ? `oid ${o.oid}` : 'oid too large to display precisely';
|
|
334
|
+
if (o.kind === 'filled') {
|
|
335
|
+
log(` Filled${tag}: ${o.totalSz} @ ${o.avgPx} (${oidText})`);
|
|
336
|
+
} else {
|
|
337
|
+
log(` Resting order${tag}: ${oidText}`);
|
|
338
|
+
if (coin && o.oidSafe) log(` Cancel: nansen perp cancel --coin ${coin} --oid ${o.oid}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} else if (result.response) {
|
|
342
|
+
// Non-order actions (leverage, transfer, builder-fee approval) or a response
|
|
343
|
+
// shape without statuses: keep the concise raw line.
|
|
266
344
|
const resp = typeof result.response === 'string' ? result.response : JSON.stringify(result.response);
|
|
267
345
|
log(` Response: ${resp}`);
|
|
268
346
|
}
|
|
347
|
+
|
|
348
|
+
// Emit the order OUTCOME to BI (oid, fill status/price/size, TP/SL legs) — the
|
|
349
|
+
// perp analogue of the command-level telemetry, which fires too early (before
|
|
350
|
+
// this HL response) to observe any of it. Order/close only: cancel / leverage
|
|
351
|
+
// / transfer / builder-fee actions carry no `telemetry` and also summarize to
|
|
352
|
+
// []. Guarded + swallowed so a telemetry failure can never downgrade a
|
|
353
|
+
// completed order into a cli_command_failed.
|
|
354
|
+
if (telemetry && orders.length) {
|
|
355
|
+
try {
|
|
356
|
+
await emitPerpOrderCompleted(telemetry, orders);
|
|
357
|
+
} catch {
|
|
358
|
+
// Best-effort; never surface a tracking error after a real fill.
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
269
362
|
return result;
|
|
270
363
|
}
|
|
271
364
|
|
|
@@ -468,7 +561,11 @@ function resolveCoin(options) {
|
|
|
468
561
|
// ── Command builder ──────────────────────────────────────────────────
|
|
469
562
|
|
|
470
563
|
export function buildPerpCommands(deps = {}) {
|
|
471
|
-
const {
|
|
564
|
+
const {
|
|
565
|
+
log = console.log,
|
|
566
|
+
warn = (m) => process.stderr.write(`${m}\n`),
|
|
567
|
+
track = trackPerpOrderCompleted,
|
|
568
|
+
} = deps;
|
|
472
569
|
|
|
473
570
|
return {
|
|
474
571
|
'order': async (args, apiInstance, flags, options) => {
|
|
@@ -542,7 +639,23 @@ OPTIONS:
|
|
|
542
639
|
const nonce = hlNonce();
|
|
543
640
|
const eip712 = l1Eip712(action, null, nonce);
|
|
544
641
|
|
|
545
|
-
await buildScreenSignSubmit(
|
|
642
|
+
await buildScreenSignSubmit(
|
|
643
|
+
apiInstance,
|
|
644
|
+
{
|
|
645
|
+
action,
|
|
646
|
+
nonce,
|
|
647
|
+
eip712,
|
|
648
|
+
size: effSize,
|
|
649
|
+
price: effPrice,
|
|
650
|
+
coin,
|
|
651
|
+
telemetry: {
|
|
652
|
+
command: 'order',
|
|
653
|
+
side: isBuy ? 'buy' : 'sell',
|
|
654
|
+
track,
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
ctx,
|
|
658
|
+
);
|
|
546
659
|
log('');
|
|
547
660
|
return undefined;
|
|
548
661
|
},
|
|
@@ -639,7 +752,23 @@ OPTIONS:
|
|
|
639
752
|
const nonce = hlNonce();
|
|
640
753
|
const eip712 = l1Eip712(action, null, nonce);
|
|
641
754
|
|
|
642
|
-
await buildScreenSignSubmit(
|
|
755
|
+
await buildScreenSignSubmit(
|
|
756
|
+
apiInstance,
|
|
757
|
+
{
|
|
758
|
+
action,
|
|
759
|
+
nonce,
|
|
760
|
+
eip712,
|
|
761
|
+
size: effSize,
|
|
762
|
+
price: effPrice,
|
|
763
|
+
coin,
|
|
764
|
+
telemetry: {
|
|
765
|
+
command: 'close',
|
|
766
|
+
side: isBuy ? 'buy' : 'sell',
|
|
767
|
+
track,
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
ctx,
|
|
771
|
+
);
|
|
643
772
|
log('');
|
|
644
773
|
return undefined;
|
|
645
774
|
},
|
package/src/schema.json
CHANGED
|
@@ -564,6 +564,15 @@
|
|
|
564
564
|
}
|
|
565
565
|
}
|
|
566
566
|
},
|
|
567
|
+
"first-funder": {
|
|
568
|
+
"endpoint": "/api/v1/profiler/address/first-funder",
|
|
569
|
+
"description": "Find the first wallet that funded an EVM address",
|
|
570
|
+
"options": {
|
|
571
|
+
"address": {
|
|
572
|
+
"required": true
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
},
|
|
567
576
|
"pnl": {
|
|
568
577
|
"endpoint": "/api/v1/profiler/address/pnl",
|
|
569
578
|
"description": "PnL and trade performance",
|
|
@@ -1557,6 +1566,27 @@
|
|
|
1557
1566
|
"aggregator": {
|
|
1558
1567
|
"type": "string",
|
|
1559
1568
|
"description": "Force a specific aggregator: lifi, relay, jupiter, or okx. Filters the returned quote list client-side; errors if no quote from that aggregator was returned."
|
|
1569
|
+
},
|
|
1570
|
+
"swap-mode": {
|
|
1571
|
+
"type": "string",
|
|
1572
|
+
"default": "exactIn",
|
|
1573
|
+
"description": "\"exactIn\" (default) to spend exactly --amount of the sell token, or \"exactOut\" to receive exactly --amount of the buy token. Not supported together with --amount-unit percent."
|
|
1574
|
+
},
|
|
1575
|
+
"slippage": {
|
|
1576
|
+
"type": "string",
|
|
1577
|
+
"description": "Slippage tolerance as a decimal between 0 and 1 (e.g. 0.03 for 3%). Values outside that range are rejected."
|
|
1578
|
+
},
|
|
1579
|
+
"auto-slippage": {
|
|
1580
|
+
"type": "boolean",
|
|
1581
|
+
"description": "Let slippage be calculated automatically instead of using a fixed --slippage."
|
|
1582
|
+
},
|
|
1583
|
+
"max-auto-slippage": {
|
|
1584
|
+
"type": "string",
|
|
1585
|
+
"description": "Upper bound applied when --auto-slippage is enabled, as a decimal between 0 and 1 (e.g. 0.03 for 3%)."
|
|
1586
|
+
},
|
|
1587
|
+
"max-input": {
|
|
1588
|
+
"type": "string",
|
|
1589
|
+
"description": "exactOut only: hard ceiling on the sell-token spend, in base units. Required for EVM (Base) exactOut and optional on Solana (which has no ERC-20 approval to scope). Measured against the slippage-buffered approval (input + slippage), not the bare quote input, so it matches the amount that can actually leave the wallet. Persisted with the quote and enforced before any approval or signing — a quote whose buffered approval exceeds it is refused."
|
|
1560
1590
|
}
|
|
1561
1591
|
},
|
|
1562
1592
|
"prerequisites": [
|
|
@@ -1566,6 +1596,11 @@
|
|
|
1566
1596
|
"execute": {
|
|
1567
1597
|
"description": "Sign and broadcast a quoted trade",
|
|
1568
1598
|
"options": {
|
|
1599
|
+
"quote": {
|
|
1600
|
+
"type": "string",
|
|
1601
|
+
"required": true,
|
|
1602
|
+
"description": "Quote ID returned by `nansen trade quote` (alias: --quote-id)."
|
|
1603
|
+
},
|
|
1569
1604
|
"chain": {
|
|
1570
1605
|
"type": "string",
|
|
1571
1606
|
"default": "base",
|
|
@@ -1578,6 +1613,14 @@
|
|
|
1578
1613
|
"gasless": {
|
|
1579
1614
|
"type": "boolean",
|
|
1580
1615
|
"description": "Relay-only: have Relay's solver pay gas + broadcast (user signs only). Requires the selected quote's aggregator to be \"relay\". Not supported via WalletConnect."
|
|
1616
|
+
},
|
|
1617
|
+
"quote-index": {
|
|
1618
|
+
"type": "string",
|
|
1619
|
+
"description": "Pin a specific quote by 0-based index when the cached quote returned several. Must be within range; there is no fallback to the other quotes."
|
|
1620
|
+
},
|
|
1621
|
+
"no-simulate": {
|
|
1622
|
+
"type": "boolean",
|
|
1623
|
+
"description": "Skip the pre-broadcast simulation."
|
|
1581
1624
|
}
|
|
1582
1625
|
}
|
|
1583
1626
|
},
|
|
@@ -1792,6 +1835,56 @@
|
|
|
1792
1835
|
"account": {
|
|
1793
1836
|
"description": "Show API key status, plan, and remaining credits. Does not consume credits."
|
|
1794
1837
|
},
|
|
1838
|
+
"auth": {
|
|
1839
|
+
"description": "Offline authentication status. Reports API key presence and source (env var vs config file), base URL, and x402 wallet readiness without any network call. Consumes no credits.",
|
|
1840
|
+
"subcommands": {
|
|
1841
|
+
"status": {
|
|
1842
|
+
"description": "Show where credentials come from, entirely offline",
|
|
1843
|
+
"returns": [
|
|
1844
|
+
"logged_in",
|
|
1845
|
+
"api_key.present",
|
|
1846
|
+
"api_key.source",
|
|
1847
|
+
"api_key.masked",
|
|
1848
|
+
"config_file.path",
|
|
1849
|
+
"config_file.exists",
|
|
1850
|
+
"config_file.error",
|
|
1851
|
+
"base_url.value",
|
|
1852
|
+
"base_url.source",
|
|
1853
|
+
"x402.configured",
|
|
1854
|
+
"x402.wallets_dir",
|
|
1855
|
+
"x402.wallets_dir_error",
|
|
1856
|
+
"x402.wallet_count",
|
|
1857
|
+
"x402.default_wallet",
|
|
1858
|
+
"x402.default_wallet_provider",
|
|
1859
|
+
"x402.password.available",
|
|
1860
|
+
"x402.password.source",
|
|
1861
|
+
"x402.password.keychain_available",
|
|
1862
|
+
"offline"
|
|
1863
|
+
],
|
|
1864
|
+
"examples": [
|
|
1865
|
+
"nansen auth status --pretty"
|
|
1866
|
+
]
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
},
|
|
1870
|
+
"doctor": {
|
|
1871
|
+
"description": "Diagnostics for the CLI setup: Node version, auth config, wallet storage and password hygiene, keychain availability, caches, telemetry, plus a safe API connectivity probe (unauthenticated, consumes no credits). Local checks work with the API unavailable; --offline skips network entirely.",
|
|
1872
|
+
"options": {
|
|
1873
|
+
"json": {
|
|
1874
|
+
"type": "boolean",
|
|
1875
|
+
"description": "Return machine-readable checks ({id, status, message, fix?}) instead of formatted text"
|
|
1876
|
+
},
|
|
1877
|
+
"offline": {
|
|
1878
|
+
"type": "boolean",
|
|
1879
|
+
"description": "Skip the connectivity probe — no network access at all"
|
|
1880
|
+
}
|
|
1881
|
+
},
|
|
1882
|
+
"examples": [
|
|
1883
|
+
"nansen doctor",
|
|
1884
|
+
"nansen doctor --offline",
|
|
1885
|
+
"nansen doctor --json --pretty"
|
|
1886
|
+
]
|
|
1887
|
+
},
|
|
1795
1888
|
"web": {
|
|
1796
1889
|
"description": "Web search and fetch commands",
|
|
1797
1890
|
"subcommands": {
|
package/src/telemetry.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* how long they take, and where errors occur. Events are fire-and-forget —
|
|
6
6
|
* failures are silently ignored and never block the CLI.
|
|
7
7
|
*
|
|
8
|
+
* Perp `order`/`close` additionally emit a `perp_order_completed` event that
|
|
9
|
+
* carries only the trade side and the Hyperliquid order id, tied to the same
|
|
10
|
+
* random anonymous_id. All telemetry is opt-out via DO_NOT_TRACK=1 or
|
|
11
|
+
* NANSEN_NO_TELEMETRY=1.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import fs from 'fs';
|
|
@@ -26,8 +30,15 @@ const TIMEOUT_MS = 1000;
|
|
|
26
30
|
|
|
27
31
|
// ─── opt-out ──────────────────────────────────────────────
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The single source of truth for the opt-out predicate — only the literal '1'
|
|
35
|
+
* disables telemetry. Also used by `nansen doctor` to report telemetry state.
|
|
36
|
+
*/
|
|
37
|
+
export function isTelemetryDisabled(env = process.env) {
|
|
38
|
+
return env.DO_NOT_TRACK === '1' || env.NANSEN_NO_TELEMETRY === '1';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const TELEMETRY_DISABLED = isTelemetryDisabled();
|
|
31
42
|
|
|
32
43
|
// ─── environment ──────────────────────────────────────────
|
|
33
44
|
|
|
@@ -245,3 +256,49 @@ export function trackCommandFailed({
|
|
|
245
256
|
context: buildContext(),
|
|
246
257
|
});
|
|
247
258
|
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Track a completed Hyperliquid perp order (`nansen perp order` / `perp close`).
|
|
262
|
+
*
|
|
263
|
+
* Fired from `buildScreenSignSubmit` in perp.js AFTER the HL /exchange response
|
|
264
|
+
* is parsed (`summarizeOrderResult`). This is the only event that sees the order
|
|
265
|
+
* OUTCOME: `cli_command_succeeded` fires at the command wrapper, before the
|
|
266
|
+
* order path returns, so it captures command metadata but never the fill. Perp
|
|
267
|
+
* orders bypass the Nansen API on submit (CLI signs and posts straight to
|
|
268
|
+
* Hyperliquid — Decision D4), so the backend never sees the response either;
|
|
269
|
+
* this client-side event is the only way order outcomes reach BI.
|
|
270
|
+
*
|
|
271
|
+
* Fires on success only: a HL rejection throws in `submitExchange` and is
|
|
272
|
+
* captured by `cli_command_failed`, so no failed event is emitted here.
|
|
273
|
+
*
|
|
274
|
+
* Deliberately minimal: only the trade side and the Hyperliquid order id — no
|
|
275
|
+
* asset, price, size, or fill detail. A trade (fill) id is not carried by the
|
|
276
|
+
* order-placement response (it exists only once the order fills, via the fills
|
|
277
|
+
* feed), so it is not available here. `oid` is omitted when it exceeded JS
|
|
278
|
+
* safe-integer precision at parse time (see summarizeOrderResult).
|
|
279
|
+
*
|
|
280
|
+
* @param {object} opts
|
|
281
|
+
* @param {'order'|'close'} opts.command - Which perp command placed the order (routes `path`)
|
|
282
|
+
* @param {'buy'|'sell'} opts.side - Normalized trade side
|
|
283
|
+
* @param {number} [opts.oid] - Parent leg's Hyperliquid order id (omitted if imprecise)
|
|
284
|
+
*/
|
|
285
|
+
export function trackPerpOrderCompleted({ command, side, oid }) {
|
|
286
|
+
return sendEvent({
|
|
287
|
+
event: 'perp_order_completed',
|
|
288
|
+
event_source: getEventSource(),
|
|
289
|
+
event_id: crypto.randomUUID(),
|
|
290
|
+
user_id: null,
|
|
291
|
+
anonymous_id: getAnonymousId(),
|
|
292
|
+
session_id: getSessionId(),
|
|
293
|
+
timestamp: new Date().toISOString(),
|
|
294
|
+
// Same path as the command's cli_command_succeeded ("/perp/order" |
|
|
295
|
+
// "/perp/close"), so BI can line the two events up per command.
|
|
296
|
+
path: commandToPath(`perp ${command}`),
|
|
297
|
+
properties: {
|
|
298
|
+
source: `nansen-cli/${cliVersion}`,
|
|
299
|
+
side,
|
|
300
|
+
...(oid !== undefined && { oid }),
|
|
301
|
+
},
|
|
302
|
+
context: buildContext(),
|
|
303
|
+
});
|
|
304
|
+
}
|