nansen-cli 1.33.0 → 1.35.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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.35.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#469](https://github.com/nansen-ai/nansen-cli/pull/469) [`85b1934`](https://github.com/nansen-ai/nansen-cli/commit/85b1934ae25ed02d1726de8ebe92ea41a98f4454) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface the API's credit and rate-limit response headers.
8
+
9
+ Failed calls now report quota state in their error details: an out-of-credits error carries your actual remaining balance, and a rate-limited error carries the limit, what is left, and how long the window needs to drain. Previously the only credit figure the CLI could show was the static per-endpoint estimate published in the API reference — a quote, not what you were charged.
10
+
11
+ A warning goes to stderr when your balance will not cover another call of the size just made, so it never interferes with the JSON on stdout.
12
+
13
+ Successful responses carry the same numbers under an exported `RESPONSE_META` symbol, and the client exposes `lastResponseMeta`. Both are additive: the JSON each command prints is unchanged.
14
+
15
+ - [#470](https://github.com/nansen-ai/nansen-cli/pull/470) [`8159300`](https://github.com/nansen-ai/nansen-cli/commit/81593008f829cc83f1d4a6ee9e5c1a10237a9553) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface the API's request id.
16
+
17
+ Failed calls now carry `details.requestId` — the value that identifies the call end to end. Quote it when reporting a problem; previously nothing identifying a failed request ever reached the user, which made server errors effectively unreportable. Successful responses expose it alongside the credit and rate-limit figures under the `RESPONSE_META` symbol.
18
+
19
+ Absent on deployments that do not send the header yet, in which case the field is simply omitted.
20
+
21
+ ## 1.34.0
22
+
23
+ ### Minor Changes
24
+
25
+ - [#459](https://github.com/nansen-ai/nansen-cli/pull/459) [`37e6725`](https://github.com/nansen-ai/nansen-cli/commit/37e6725aeeb3b83eb29c4650908b8dbb522ed316) Thanks [@dependabot](https://github.com/apps/dependabot)! - Drop support for Node.js 18 (EOL since April 2025). The minimum supported version is now Node.js 20, matching our test toolchain (vitest 4.x requires Node 20+).
26
+
27
+ - [#460](https://github.com/nansen-ai/nansen-cli/pull/460) [`aac4bbe`](https://github.com/nansen-ai/nansen-cli/commit/aac4bbe18312edb48c91df60ab555f9c1d8334ce) Thanks [@gulshngill](https://github.com/gulshngill)! - Add trader_type, sectors_filter, sm_label_filter, and trader_label_filter filters to `nansen research perp screener` (ECINT-6680).
28
+
29
+ New CLI options:
30
+
31
+ - `--trader-type <type>` — filter by trader type: all, sm, whale, public_figure, high_winrate_hl_perps_trader
32
+ - `--sectors-filter <sectors>` — comma-separated sector:subcategory pairs, e.g. "Crypto:AI,TradFi:Stocks"
33
+ - `--sm-label-filter <labels>` — comma-separated Nansen SM labels (applies when trader-type is all or sm)
34
+ - `--trader-label-filter <labels>` — comma-separated HL perps trader labels (applies when trader-type is all or sm)
35
+
3
36
  ## 1.33.0
4
37
 
5
38
  ### Minor Changes
package/README.md CHANGED
@@ -177,17 +177,28 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
177
177
 
178
178
  ```json
179
179
  { "success": true, "data": <api_response> }
180
- { "success": false, "error": "message", "code": "ERROR_CODE", "status": 401 }
180
+ { "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "details": { ... } }
181
181
  ```
182
182
 
183
183
  **Critical error codes:**
184
184
 
185
185
  | Code | Action |
186
186
  |------|--------|
187
- | `CREDITS_EXHAUSTED` | Stop all API calls immediately. Check [app.nansen.ai](https://app.nansen.ai). |
187
+ | `CREDITS_EXHAUSTED` | Stop all API calls immediately. `details.credits.remaining` is your actual balance. Top up at [app.nansen.ai/api](https://app.nansen.ai/api). |
188
188
  | `UNAUTHORIZED` | Wrong or missing key. Re-auth. |
189
- | `RATE_LIMITED` | Auto-retried by CLI. |
189
+ | `RATE_LIMITED` | Auto-retried by CLI. `details.rateLimit.resetSeconds` is how long the window needs to drain. |
190
190
  | `UNSUPPORTED_FILTER` | Remove the filter and retry. |
191
+ | `SERVER_ERROR` | Not your fault. Quote `details.requestId` when reporting it. |
192
+
193
+ **Error metadata.** When the API reports them, `details` carries:
194
+
195
+ | Field | Meaning |
196
+ |-------|---------|
197
+ | `requestId` | Identifies this call end to end. Quote it in any support report. Opaque — do not parse it. |
198
+ | `credits` | `used`, `remaining` |
199
+ | `rateLimit` | `limit`, `remaining`, `resetSeconds` |
200
+
201
+ Any field may be absent or `null`, meaning unknown — never assume zero. A low-balance warning goes to **stderr**, so stdout stays pure JSON.
191
202
 
192
203
  ## Troubleshooting
193
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "homepage": "https://github.com/nansen-ai/nansen-cli#readme",
61
61
  "engines": {
62
- "node": ">=18.0.0"
62
+ "node": ">=20.0.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@changesets/changelog-github": "^0.5.1",
@@ -41,9 +41,19 @@ function hasTTY() {
41
41
  return process.stdin.isTTY && process.stderr.isTTY;
42
42
  }
43
43
 
44
+ // npx is a .cmd shim on Windows, and Node refuses to spawn .cmd/.bat without a
45
+ // shell (CVE-2024-27980). Go through cmd.exe explicitly rather than enabling
46
+ // `shell: true`, which would hand the whole command line to the shell parser.
47
+ const IS_WIN = process.platform === "win32";
48
+
49
+ function npxInvocation(args) {
50
+ return IS_WIN ? ["cmd.exe", ["/c", "npx", ...args]] : ["npx", args];
51
+ }
52
+
44
53
  function hasNpx() {
45
54
  try {
46
- execFileSync("npx", ["--version"], { stdio: "ignore", shell: process.platform === "win32" });
55
+ const [cmd, cmdArgs] = npxInvocation(["--version"]);
56
+ execFileSync(cmd, cmdArgs, { stdio: "ignore", shell: false });
47
57
  return true;
48
58
  } catch {
49
59
  return false;
@@ -86,7 +96,7 @@ function prompt(question) {
86
96
 
87
97
  function runCommand(cmd, args) {
88
98
  return new Promise((resolve) => {
89
- const child = spawn(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
99
+ const child = spawn(cmd, args, { stdio: "inherit", shell: false });
90
100
  child.on("close", (code) => resolve(code === 0));
91
101
  child.on("error", () => resolve(false));
92
102
  });
@@ -113,7 +123,7 @@ async function installSkill() {
113
123
  }
114
124
 
115
125
  log(`Installing Nansen skill...`);
116
- const ok = await runCommand("npx", ["-y", "skills", "add", SKILL_REPO]);
126
+ const ok = await runCommand(...npxInvocation(["-y", "skills", "add", SKILL_REPO]));
117
127
  if (!ok) {
118
128
  log(`${YELLOW}Skill installation failed. You can retry with: npx skills add ${SKILL_REPO}${RESET}`);
119
129
  }
@@ -30,3 +30,62 @@ nansen research perp leaderboard --days 7 --limit 20
30
30
  nansen research smart-money perp-trades --limit 20
31
31
  # → token_symbol, side, action (Open/Close), value_usd, price_usd, trader_address_label
32
32
  ```
33
+
34
+ ## New Filters (ECINT-6680)
35
+
36
+ ### `--trader-type`
37
+ Filter by trader type. Accepted values: `all` (default), `sm`, `whale`, `public_figure`, `high_winrate_hl_perps_trader`.
38
+
39
+ ```bash
40
+ # Show only whale traders
41
+ nansen research perp screener --trader-type whale --limit 10
42
+
43
+ # Show only smart money traders
44
+ nansen research perp screener --trader-type sm --limit 20
45
+
46
+ # Show high win-rate HL perps traders
47
+ nansen research perp screener --trader-type high_winrate_hl_perps_trader --limit 20
48
+ ```
49
+
50
+ ### `--sm-label-filter`
51
+ Comma-separated Nansen SM labels to filter by. Only applies when `--trader-type` is `all` or `sm`.
52
+
53
+ ```bash
54
+ # Filter to a specific SM label
55
+ nansen research perp screener --trader-type sm --sm-label-filter "30D Smart Trader"
56
+
57
+ # Multiple labels
58
+ nansen research perp screener --sm-label-filter "30D Smart Trader,Smart LP"
59
+ ```
60
+
61
+ ### `--trader-label-filter`
62
+ Comma-separated HL perps trader labels to filter by. Only applies when `--trader-type` is `all` or `sm`.
63
+
64
+ ```bash
65
+ # Filter to HL Perps Whale label
66
+ nansen research perp screener --trader-label-filter "HL Perps Whale"
67
+ ```
68
+
69
+ ### `--sectors-filter`
70
+ Comma-separated `category:subcategory` pairs to filter coins by sector.
71
+
72
+ ```bash
73
+ # Filter to AI and DeFi crypto sectors
74
+ nansen research perp screener --sectors-filter "Crypto:AI,Crypto:DeFi" --trader-type whale
75
+
76
+ # Combine with trader type and limit
77
+ nansen research perp screener --sectors-filter "Crypto:AI,Crypto:DeFi" --trader-type whale --limit 10 --sort volume:desc
78
+ ```
79
+
80
+ ## Combined Examples
81
+
82
+ ```bash
83
+ # Whale traders in AI crypto, sorted by volume
84
+ nansen research perp screener --trader-type whale --sectors-filter "Crypto:AI" --sort volume:desc --limit 10
85
+
86
+ # Smart money with specific label, last 7 days
87
+ nansen research perp screener --trader-type sm --sm-label-filter "30D Smart Trader" --days 7 --limit 20
88
+
89
+ # All traders in TradFi stocks sector
90
+ nansen research perp screener --sectors-filter "TradFi:Stocks" --sort open_interest:desc --limit 20
91
+ ```
package/src/api.js CHANGED
@@ -8,6 +8,16 @@ import path from 'path';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { EVM_CHAINS } from './chain-ids.js';
10
10
  import { getAnonymousId, TELEMETRY_DISABLED } from './telemetry.js';
11
+ import { readResponseMeta } from './response-meta.js';
12
+
13
+ /**
14
+ * Key for the credit/rate-limit metadata attached to a successful response.
15
+ *
16
+ * A symbol on purpose: JSON.stringify and Object.keys both skip it, so the JSON
17
+ * every command prints is byte-for-byte unchanged while callers that want the
18
+ * numbers can still read them off the returned object.
19
+ */
20
+ export const RESPONSE_META = Symbol('nansenResponseMeta');
11
21
 
12
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
23
 
@@ -465,6 +475,15 @@ export class NansenAPI {
465
475
  ttl: options.cache?.ttl ?? DEFAULT_CACHE_TTL
466
476
  };
467
477
  this.defaultHeaders = options.defaultHeaders || {};
478
+ /**
479
+ * Credit/rate-limit metadata from the most recent response, or null.
480
+ *
481
+ * Survives any reshaping a command handler does to the response body, which
482
+ * the RESPONSE_META symbol on the returned object does not. Last write wins
483
+ * when a handler makes several calls — the freshest balance, which is what a
484
+ * low-credit warning wants.
485
+ */
486
+ this.lastResponseMeta = null;
468
487
  }
469
488
 
470
489
  static cleanBody(body) {
@@ -520,7 +539,11 @@ export class NansenAPI {
520
539
  }
521
540
  } catch { /* balance check is best-effort */ }
522
541
  }
523
- return await paidResponse.json();
542
+ const data = await paidResponse.json();
543
+ const meta = readResponseMeta(paidResponse);
544
+ this.lastResponseMeta = meta;
545
+ if (meta && data !== null && typeof data === 'object') data[RESPONSE_META] = meta;
546
+ return data;
524
547
  }
525
548
 
526
549
  async request(endpoint, body = {}, options = {}) {
@@ -581,11 +604,17 @@ export class NansenAPI {
581
604
  data = await response.json();
582
605
  } catch (_err) {
583
606
  // Non-JSON response (rare, usually server errors)
607
+ const meta = readResponseMeta(response);
608
+ this.lastResponseMeta = meta;
584
609
  const error = new NansenError(
585
610
  `Invalid response from API (status ${response.status})`,
586
611
  response.status >= 500 ? ErrorCode.SERVER_ERROR : ErrorCode.UNKNOWN,
587
612
  response.status,
588
- { body: await response.text().catch(() => null), attempt: attempt + 1 }
613
+ {
614
+ body: await response.text().catch(() => null),
615
+ attempt: attempt + 1,
616
+ ...(meta?.requestId && { requestId: meta.requestId })
617
+ }
589
618
  );
590
619
 
591
620
  if (shouldRetry && attempt < maxRetries && response.status >= 500) {
@@ -702,10 +731,23 @@ export class NansenAPI {
702
731
  }
703
732
  }
704
733
 
734
+ // Quota state and the request id belong on the error above all: an
735
+ // out-of-credits or rate-limited failure is exactly when the caller
736
+ // needs the numbers, and a 5xx is worthless to support without the id.
737
+ // formatError() surfaces details, so this needs no plumbing.
738
+ //
739
+ // On a retried call this is the LAST attempt's id — each attempt gets
740
+ // its own server-side id, and the last one is the failure worth
741
+ // reporting.
742
+ const meta = readResponseMeta(response);
743
+ this.lastResponseMeta = meta;
705
744
  lastError = new NansenError(message, code, response.status, {
706
745
  ...data,
707
746
  attempt: attempt + 1,
708
- retryAfterMs
747
+ retryAfterMs,
748
+ ...(meta?.requestId && { requestId: meta.requestId }),
749
+ ...(meta?.credits && { credits: meta.credits }),
750
+ ...(meta?.rateLimit && { rateLimit: meta.rateLimit })
709
751
  });
710
752
 
711
753
  // Retry on specific status codes
@@ -722,12 +764,21 @@ export class NansenAPI {
722
764
  if (attempt > 0) {
723
765
  data._meta = { ...(data._meta || {}), retriedAttempts: attempt };
724
766
  }
725
-
767
+
726
768
  // Cache successful response
727
769
  if (useCache) {
728
770
  setCachedResponse(endpoint, body, data);
729
771
  }
730
-
772
+
773
+ // Attach after caching so the cache stores the payload alone — quota
774
+ // numbers are per-response and would be stale on a cache hit.
775
+ // Guarded: a response body can be a primitive, which cannot take a property.
776
+ const meta = readResponseMeta(response);
777
+ this.lastResponseMeta = meta;
778
+ if (meta) {
779
+ if (data !== null && typeof data === 'object') data[RESPONSE_META] = meta;
780
+ }
781
+
731
782
  return data;
732
783
  }
733
784
 
@@ -1187,13 +1238,18 @@ export class NansenAPI {
1187
1238
  // ============= Perp Endpoints =============
1188
1239
 
1189
1240
  async perpScreener(params = {}) {
1190
- const { filters = {}, orderBy, pagination, days = 30 } = params;
1191
- return this.request('/api/v1/perp-screener', {
1241
+ const { filters = {}, orderBy, pagination, days = 30, traderType, sectorsFilter, smLabelFilter, traderLabelFilter } = params;
1242
+ const body = {
1192
1243
  date: buildDateRange(days),
1193
1244
  filters,
1194
1245
  order_by: orderBy,
1195
1246
  pagination
1196
- });
1247
+ };
1248
+ if (traderType !== undefined) body.filters.trader_type = traderType;
1249
+ if (sectorsFilter !== undefined) body.filters.sectors_filter = sectorsFilter;
1250
+ if (smLabelFilter !== undefined) body.filters.sm_label_filter = smLabelFilter;
1251
+ if (traderLabelFilter !== undefined) body.filters.trader_label_filter = traderLabelFilter;
1252
+ return this.request('/api/v1/perp-screener', body);
1197
1253
  }
1198
1254
 
1199
1255
  async perpLeaderboard(params = {}) {
package/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import { resolveAddress, isEnsName } from './ens.js';
14
14
  import fs from 'fs';
15
15
  import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
16
16
  import { refreshCostMapIfStale, getCostForEndpoint } from './cost-cache.js';
17
+ import { creditWarning, noticeWarnings } from './response-meta.js';
17
18
  import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
18
19
  import { createRequire } from 'module';
19
20
  import * as readline from 'readline';
@@ -1367,7 +1368,19 @@ export function buildCommands(deps = {}) {
1367
1368
  const days = options.days ? parseInt(options.days) : 30;
1368
1369
 
1369
1370
  const handlers = {
1370
- 'screener': () => apiInstance.perpScreener({ filters, orderBy, pagination, days }),
1371
+ 'screener': () => {
1372
+ const traderType = options['trader-type'];
1373
+ const sectorsFilter = options['sectors-filter']
1374
+ ? options['sectors-filter'].split(',').map(s => s.trim()).filter(Boolean)
1375
+ : undefined;
1376
+ const smLabelFilter = options['sm-label-filter']
1377
+ ? options['sm-label-filter'].split(',').map(s => s.trim()).filter(Boolean)
1378
+ : undefined;
1379
+ const traderLabelFilter = options['trader-label-filter']
1380
+ ? options['trader-label-filter'].split(',').map(s => s.trim()).filter(Boolean)
1381
+ : undefined;
1382
+ return apiInstance.perpScreener({ filters, orderBy, pagination, days, traderType, sectorsFilter, smLabelFilter, traderLabelFilter });
1383
+ },
1371
1384
  'leaderboard': () => {
1372
1385
  const withLabels = resolveBooleanOption(options, flags, 'premium-labels');
1373
1386
  return apiInstance.perpLeaderboard({ filters, orderBy, pagination, days, withLabels });
@@ -1842,6 +1855,14 @@ export async function runCLI(rawArgs, deps = {}) {
1842
1855
  const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions, cache: cacheOptions, defaultHeaders });
1843
1856
  let result = await commands[command](subArgs, api, flags, options);
1844
1857
 
1858
+ // Credit balance warning, from the headers on the call just made. Goes to
1859
+ // stderr so it never contaminates the JSON on stdout that agents parse.
1860
+ // Placed before every return path below so it fires for operational
1861
+ // commands too, which print their own output and return undefined.
1862
+ const lowCredits = creditWarning(api.lastResponseMeta);
1863
+ if (lowCredits) errorOutput(lowCredits);
1864
+ for (const notice of noticeWarnings(api.lastResponseMeta)) errorOutput(notice);
1865
+
1845
1866
  // Commands that handle their own output return undefined
1846
1867
  if (result === undefined) {
1847
1868
  await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Request-id, credit, rate-limit, and notice metadata, read from Nansen API
3
+ * response headers.
4
+ *
5
+ * The API reports what a call actually cost, what quota is left, and an id that
6
+ * identifies the call end to end. Until now the CLI dropped those headers on the
7
+ * floor and showed only the static per-endpoint estimate published in the
8
+ * OpenAPI spec (see cost-cache.js), which is a quote rather than a charge.
9
+ *
10
+ * readResponseMeta(response) — parse the headers, or null if none are present
11
+ * creditWarning(meta) — stderr warning string when the balance is short, else null
12
+ *
13
+ * Every header is optional. Some auth rails charge no credits, some responses
14
+ * are served before quota is resolved, and older deployments may send neither
15
+ * the rate-limit triplet nor the request id — so a missing header means
16
+ * "unknown", never zero.
17
+ */
18
+
19
+ /** Header names, as documented in the API reference. */
20
+ const CREDITS_USED = 'x-nansen-credits-used';
21
+ const CREDITS_REMAINING = 'x-nansen-credits-remaining';
22
+ const RATE_LIMIT = 'x-ratelimit-limit';
23
+ const RATE_REMAINING = 'x-ratelimit-remaining';
24
+ const RATE_RESET = 'x-ratelimit-reset';
25
+ const UPGRADE_HINT = 'x-nansen-upgrade-hint';
26
+ const PLAN_NOTICE = 'x-nansen-plan-notice';
27
+ const API_KEY_NOTICE = 'x-nansen-api-key-notice';
28
+ const REQUEST_ID = 'x-request-id';
29
+
30
+ /**
31
+ * Read a header as a non-negative integer, or null when absent/unparseable.
32
+ * Tolerates any header bag with a .get() — a real Headers, or a Map in tests.
33
+ */
34
+ function intHeader(response, name) {
35
+ const raw = stringHeader(response, name);
36
+ if (raw === null) return null;
37
+ const value = Number.parseInt(raw, 10);
38
+ return Number.isInteger(value) && value >= 0 ? value : null;
39
+ }
40
+
41
+ /**
42
+ * Read a header as a trimmed non-empty string, or null when absent.
43
+ * Tolerates any header bag with a .get() — a real Headers, or a Map in tests.
44
+ */
45
+ function stringHeader(response, name) {
46
+ const raw = response?.headers?.get?.(name);
47
+ if (raw == null) return null;
48
+ const value = String(raw).trim();
49
+ return value === '' ? null : value;
50
+ }
51
+
52
+ /**
53
+ * Extract request-id, credit, rate-limit, and notice metadata from a fetch Response.
54
+ * Returns null when the response carries none of it, so callers can skip
55
+ * attaching an object full of nulls.
56
+ */
57
+ export function readResponseMeta(response) {
58
+ const used = intHeader(response, CREDITS_USED);
59
+ const remaining = intHeader(response, CREDITS_REMAINING);
60
+ const limit = intHeader(response, RATE_LIMIT);
61
+ const rateRemaining = intHeader(response, RATE_REMAINING);
62
+ const resetSeconds = intHeader(response, RATE_RESET);
63
+ const upgradeHint = stringHeader(response, UPGRADE_HINT);
64
+ const planNotice = stringHeader(response, PLAN_NOTICE);
65
+ const apiKeyNotice = stringHeader(response, API_KEY_NOTICE);
66
+ const requestId = stringHeader(response, REQUEST_ID);
67
+
68
+ const meta = {};
69
+ if (requestId !== null) {
70
+ // The single value that identifies this call to Nansen support. Opaque —
71
+ // never parse it or assume a format.
72
+ meta.requestId = requestId;
73
+ }
74
+ if (used !== null || remaining !== null) {
75
+ meta.credits = { used, remaining };
76
+ }
77
+ if (limit !== null || rateRemaining !== null || resetSeconds !== null) {
78
+ // resetSeconds is a delta in seconds — how long the tripped window needs to
79
+ // drain — not a wall-clock timestamp.
80
+ meta.rateLimit = { limit, remaining: rateRemaining, resetSeconds };
81
+ }
82
+ if (upgradeHint !== null || planNotice !== null || apiKeyNotice !== null) {
83
+ meta.notices = {
84
+ ...(upgradeHint !== null && { upgradeHint }),
85
+ ...(planNotice !== null && { planNotice }),
86
+ ...(apiKeyNotice !== null && { apiKeyNotice }),
87
+ };
88
+ }
89
+ return Object.keys(meta).length > 0 ? meta : null;
90
+ }
91
+
92
+ /**
93
+ * Yield notice strings for any server-set advisory headers.
94
+ * Each yields a `⚠️ <message>` line for stderr.
95
+ * Order: apiKeyNotice (most urgent) → upgradeHint → planNotice.
96
+ */
97
+ export function noticeWarnings(meta) {
98
+ const notices = meta?.notices;
99
+ if (!notices) return [];
100
+ const out = [];
101
+ if (notices.apiKeyNotice) out.push(`⚠️ ${notices.apiKeyNotice}`);
102
+ if (notices.upgradeHint) out.push(`ℹ️ ${notices.upgradeHint}`);
103
+ if (notices.planNotice) out.push(`ℹ️ ${notices.planNotice}`);
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Warn only when the remaining balance will not cover another call of the size
109
+ * just made.
110
+ */
111
+ export function creditWarning(meta) {
112
+ const credits = meta?.credits;
113
+ if (!credits) return null;
114
+ const { used, remaining } = credits;
115
+ if (remaining === null) return null;
116
+ if (remaining === 0) {
117
+ return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api';
118
+ }
119
+ if (used !== null && used > 0 && remaining < used) {
120
+ return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${used}). Top up at https://app.nansen.ai/api`;
121
+ }
122
+ return null;
123
+ }
package/src/schema.json CHANGED
@@ -522,7 +522,11 @@
522
522
  "options": {
523
523
  "market-cap": {
524
524
  "description": "Filter by market cap group",
525
- "enum": ["lowcap", "midcap", "largecap"]
525
+ "enum": [
526
+ "lowcap",
527
+ "midcap",
528
+ "largecap"
529
+ ]
526
530
  },
527
531
  "limit": {
528
532
  "default": 25
@@ -555,6 +559,25 @@
555
559
  "options": {
556
560
  "days": {
557
561
  "default": 30
562
+ },
563
+ "trader-type": {
564
+ "description": "Filter by trader type. One of: all, sm, whale, public_figure, high_winrate_hl_perps_trader. Defaults to all.",
565
+ "enum": [
566
+ "all",
567
+ "sm",
568
+ "whale",
569
+ "public_figure",
570
+ "high_winrate_hl_perps_trader"
571
+ ]
572
+ },
573
+ "sectors-filter": {
574
+ "description": "Comma-separated sector:subcategory pairs to filter by, e.g. \"Crypto:AI,TradFi:Stocks\"."
575
+ },
576
+ "sm-label-filter": {
577
+ "description": "Comma-separated Nansen SM labels to filter by, e.g. \"30D Smart Trader\". Only applies when trader-type is all or sm."
578
+ },
579
+ "trader-label-filter": {
580
+ "description": "Comma-separated HL perps trader labels to filter by, e.g. \"HL Perps Whale\". Only applies when trader-type is all or sm."
558
581
  }
559
582
  }
560
583
  },
@@ -616,8 +639,12 @@
616
639
  "endpoint": "/api/v1/prediction-market/market-screener",
617
640
  "description": "Get Prediction Market Screener",
618
641
  "options": {
619
- "query": { "default": "" },
620
- "sort-by": { "description": "Deprecated: use --sort field:dir instead" },
642
+ "query": {
643
+ "default": ""
644
+ },
645
+ "sort-by": {
646
+ "description": "Deprecated: use --sort field:dir instead"
647
+ },
621
648
  "tags": {},
622
649
  "min-liquidity": {},
623
650
  "max-liquidity": {},
@@ -638,8 +665,12 @@
638
665
  "endpoint": "/api/v1/prediction-market/event-screener",
639
666
  "description": "Get Prediction Market Event Screener",
640
667
  "options": {
641
- "query": { "default": "" },
642
- "sort-by": { "description": "Deprecated: use --sort field:dir instead" },
668
+ "query": {
669
+ "default": ""
670
+ },
671
+ "sort-by": {
672
+ "description": "Deprecated: use --sort field:dir instead"
673
+ },
643
674
  "tags": {},
644
675
  "min-liquidity": {},
645
676
  "max-liquidity": {},
@@ -717,104 +748,218 @@
717
748
  "endpoint": "/api/v1beta1/tgm/historical-dex-trades",
718
749
  "description": "Historical DEX trades for a token at a point in time",
719
750
  "options": {
720
- "token-address": { "required": true, "description": "Token address" },
721
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
722
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
723
- "chain": { "default": "solana", "description": "Chain" }
751
+ "token-address": {
752
+ "required": true,
753
+ "description": "Token address"
754
+ },
755
+ "from-date": {
756
+ "required": true,
757
+ "description": "Start of date range (YYYY-MM-DD)"
758
+ },
759
+ "to-date": {
760
+ "required": true,
761
+ "description": "End of date range (YYYY-MM-DD)"
762
+ },
763
+ "chain": {
764
+ "default": "solana",
765
+ "description": "Chain"
766
+ }
724
767
  }
725
768
  },
726
769
  "historical-pnl-leaderboard": {
727
770
  "endpoint": "/api/v1beta1/tgm/historical-pnl-leaderboard",
728
771
  "description": "Historical PnL leaderboard for a token",
729
772
  "options": {
730
- "token-address": { "required": true, "description": "Token address" },
731
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
732
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
733
- "chain": { "default": "solana", "description": "Chain" }
773
+ "token-address": {
774
+ "required": true,
775
+ "description": "Token address"
776
+ },
777
+ "from-date": {
778
+ "required": true,
779
+ "description": "Start of date range (YYYY-MM-DD)"
780
+ },
781
+ "to-date": {
782
+ "required": true,
783
+ "description": "End of date range (YYYY-MM-DD)"
784
+ },
785
+ "chain": {
786
+ "default": "solana",
787
+ "description": "Chain"
788
+ }
734
789
  }
735
790
  },
736
791
  "historical-token-flow-summary": {
737
792
  "endpoint": "/api/v1beta1/tgm/historical-token-flow-summary",
738
793
  "description": "Historical token flow summary (no pagination)",
739
794
  "options": {
740
- "token-address": { "required": true, "description": "Token address" },
741
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
742
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
743
- "chain": { "default": "solana", "description": "Chain" }
795
+ "token-address": {
796
+ "required": true,
797
+ "description": "Token address"
798
+ },
799
+ "from-date": {
800
+ "required": true,
801
+ "description": "Start of date range (YYYY-MM-DD)"
802
+ },
803
+ "to-date": {
804
+ "required": true,
805
+ "description": "End of date range (YYYY-MM-DD)"
806
+ },
807
+ "chain": {
808
+ "default": "solana",
809
+ "description": "Chain"
810
+ }
744
811
  }
745
812
  },
746
813
  "historical-token-quant-scores": {
747
814
  "endpoint": "/api/v1beta1/tgm/historical-token-quant-scores",
748
815
  "description": "Historical token quantitative scores at a snapshot date",
749
816
  "options": {
750
- "token-address": { "required": true, "description": "Token address" },
751
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
752
- "chain": { "default": "solana", "description": "Chain" }
817
+ "token-address": {
818
+ "required": true,
819
+ "description": "Token address"
820
+ },
821
+ "as-of-date": {
822
+ "required": true,
823
+ "description": "Snapshot date (YYYY-MM-DD)"
824
+ },
825
+ "chain": {
826
+ "default": "solana",
827
+ "description": "Chain"
828
+ }
753
829
  }
754
830
  },
755
831
  "historical-top-holders": {
756
832
  "endpoint": "/api/v1beta1/tgm/historical-top-holders",
757
833
  "description": "Historical top holders of a token at a snapshot date",
758
834
  "options": {
759
- "token-address": { "required": true, "description": "Token address" },
760
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
761
- "chain": { "default": "solana", "description": "Chain" }
835
+ "token-address": {
836
+ "required": true,
837
+ "description": "Token address"
838
+ },
839
+ "as-of-date": {
840
+ "required": true,
841
+ "description": "Snapshot date (YYYY-MM-DD)"
842
+ },
843
+ "chain": {
844
+ "default": "solana",
845
+ "description": "Chain"
846
+ }
762
847
  }
763
848
  },
764
849
  "historical-who-bought-sold": {
765
850
  "endpoint": "/api/v1beta1/tgm/historical-who-bought-sold",
766
851
  "description": "Historical buyers/sellers of a token",
767
852
  "options": {
768
- "token-address": { "required": true, "description": "Token address" },
769
- "from-date": { "required": true, "description": "Start of date range (YYYY-MM-DD)" },
770
- "to-date": { "required": true, "description": "End of date range (YYYY-MM-DD)" },
771
- "buy-or-sell": { "default": "BUY", "description": "BUY or SELL" },
772
- "chain": { "default": "solana", "description": "Chain" }
853
+ "token-address": {
854
+ "required": true,
855
+ "description": "Token address"
856
+ },
857
+ "from-date": {
858
+ "required": true,
859
+ "description": "Start of date range (YYYY-MM-DD)"
860
+ },
861
+ "to-date": {
862
+ "required": true,
863
+ "description": "End of date range (YYYY-MM-DD)"
864
+ },
865
+ "buy-or-sell": {
866
+ "default": "BUY",
867
+ "description": "BUY or SELL"
868
+ },
869
+ "chain": {
870
+ "default": "solana",
871
+ "description": "Chain"
872
+ }
773
873
  }
774
874
  },
775
875
  "historical-smart-money-balances": {
776
876
  "endpoint": "/api/v1beta1/smart-money/historical-token-balances",
777
877
  "description": "Historical smart money token balances at a snapshot date (no order_by)",
778
878
  "options": {
779
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
780
- "chains": { "default": "solana", "description": "Comma-separated chains" }
879
+ "as-of-date": {
880
+ "required": true,
881
+ "description": "Snapshot date (YYYY-MM-DD)"
882
+ },
883
+ "chains": {
884
+ "default": "solana",
885
+ "description": "Comma-separated chains"
886
+ }
781
887
  }
782
888
  },
783
889
  "historical-token-screener": {
784
890
  "endpoint": "/api/v1beta1/token-screener/historical",
785
891
  "description": "Historical token screener over a trailing window",
786
892
  "options": {
787
- "timeframe-days": { "required": true, "type": "number", "description": "Trailing window size in days" },
788
- "to-date": { "required": true, "description": "End date for the window (YYYY-MM-DD)" },
789
- "chains": { "default": "solana", "description": "Comma-separated chains" }
893
+ "timeframe-days": {
894
+ "required": true,
895
+ "type": "number",
896
+ "description": "Trailing window size in days"
897
+ },
898
+ "to-date": {
899
+ "required": true,
900
+ "description": "End date for the window (YYYY-MM-DD)"
901
+ },
902
+ "chains": {
903
+ "default": "solana",
904
+ "description": "Comma-separated chains"
905
+ }
790
906
  }
791
907
  },
792
908
  "historical-wallet-balances": {
793
909
  "endpoint": "/api/v1beta1/profiler/address/historical-token-balances",
794
910
  "description": "Historical token balances for a wallet at a snapshot date",
795
911
  "options": {
796
- "address": { "required": true, "description": "Wallet address" },
797
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
798
- "chain": { "default": "ethereum", "description": "Chain" }
912
+ "address": {
913
+ "required": true,
914
+ "description": "Wallet address"
915
+ },
916
+ "as-of-date": {
917
+ "required": true,
918
+ "description": "Snapshot date (YYYY-MM-DD)"
919
+ },
920
+ "chain": {
921
+ "default": "ethereum",
922
+ "description": "Chain"
923
+ }
799
924
  }
800
925
  },
801
926
  "historical-tx-lookup": {
802
927
  "endpoint": "/api/v1beta1/profiler/historical-transaction-lookup",
803
928
  "description": "Lookup a historical transaction by hash",
804
929
  "options": {
805
- "transaction-hash": { "required": true, "description": "Transaction hash (0x-prefixed, 66 chars)" },
806
- "as-of-date": { "required": true, "description": "Reference date for label and pricing resolution (YYYY-MM-DD)" },
807
- "block-timestamp": { "description": "Block timestamp (YYYY-MM-DD HH:MM:SS) — skips slow hash-resolution step if provided" },
808
- "chain": { "default": "ethereum", "description": "Chain (ethereum, bnb, base)" }
930
+ "transaction-hash": {
931
+ "required": true,
932
+ "description": "Transaction hash (0x-prefixed, 66 chars)"
933
+ },
934
+ "as-of-date": {
935
+ "required": true,
936
+ "description": "Reference date for label and pricing resolution (YYYY-MM-DD)"
937
+ },
938
+ "block-timestamp": {
939
+ "description": "Block timestamp (YYYY-MM-DD HH:MM:SS) \u2014 skips slow hash-resolution step if provided"
940
+ },
941
+ "chain": {
942
+ "default": "ethereum",
943
+ "description": "Chain (ethereum, bnb, base)"
944
+ }
809
945
  }
810
946
  },
811
947
  "historical-wallet-transactions": {
812
948
  "endpoint": "/api/v1beta1/profiler/address/historical-transactions",
813
949
  "description": "Historical transactions for a wallet at a snapshot date",
814
950
  "options": {
815
- "address": { "required": true, "description": "Wallet address" },
816
- "as-of-date": { "required": true, "description": "Snapshot date (YYYY-MM-DD)" },
817
- "chain": { "default": "ethereum", "description": "Chain" }
951
+ "address": {
952
+ "required": true,
953
+ "description": "Wallet address"
954
+ },
955
+ "as-of-date": {
956
+ "required": true,
957
+ "description": "Snapshot date (YYYY-MM-DD)"
958
+ },
959
+ "chain": {
960
+ "default": "ethereum",
961
+ "description": "Chain"
962
+ }
818
963
  }
819
964
  }
820
965
  }
@@ -971,7 +1116,7 @@
971
1116
  },
972
1117
  "to-chain": {
973
1118
  "type": "string",
974
- "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported swap to USDC first, then bridge. Bridge providers (Li.Fi or Relay) are selected automatically based on best price. Sub-dollar swaps are supported via Relay."
1119
+ "description": "Destination blockchain for cross-chain swap (solana or base). Omit for same-chain. At least one side must be USDC or a native token (ETH, SOL). Non-native to non-native is not supported \u2014 swap to USDC first, then bridge. Bridge providers (Li.Fi or Relay) are selected automatically based on best price. Sub-dollar swaps are supported via Relay."
975
1120
  },
976
1121
  "from": {
977
1122
  "type": "string",
@@ -1047,7 +1192,7 @@
1047
1192
  },
1048
1193
  "aggregator": {
1049
1194
  "type": "string",
1050
- "description": "lifi or relay. Overrides auto-detection use when polling from a different machine or after the 30-day local record TTL has expired."
1195
+ "description": "lifi or relay. Overrides auto-detection \u2014 use when polling from a different machine or after the 30-day local record TTL has expired."
1051
1196
  }
1052
1197
  }
1053
1198
  },
@@ -1101,7 +1246,9 @@
1101
1246
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1102
1247
  }
1103
1248
  },
1104
- "chains": ["solana"],
1249
+ "chains": [
1250
+ "solana"
1251
+ ],
1105
1252
  "prerequisites": [
1106
1253
  "A Solana wallet must be configured. Run: nansen wallet create"
1107
1254
  ]
@@ -1141,7 +1288,9 @@
1141
1288
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1142
1289
  }
1143
1290
  },
1144
- "chains": ["solana"]
1291
+ "chains": [
1292
+ "solana"
1293
+ ]
1145
1294
  },
1146
1295
  "cancel": {
1147
1296
  "description": "Cancel an open limit order",
@@ -1156,7 +1305,9 @@
1156
1305
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1157
1306
  }
1158
1307
  },
1159
- "chains": ["solana"]
1308
+ "chains": [
1309
+ "solana"
1310
+ ]
1160
1311
  },
1161
1312
  "update": {
1162
1313
  "description": "Update trigger price or slippage on an existing order",
@@ -1179,7 +1330,9 @@
1179
1330
  "description": "Wallet name (or \"walletconnect\"/\"wc\")"
1180
1331
  }
1181
1332
  },
1182
- "chains": ["solana"]
1333
+ "chains": [
1334
+ "solana"
1335
+ ]
1183
1336
  }
1184
1337
  }
1185
1338
  }