nansen-cli 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # Changesets
2
+
3
+ Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4
+ with multi-package repos, or single-package repos to help you version and publish your code. You can
5
+ find the full documentation for it [in the repository](https://github.com/changesets/changesets)
6
+
7
+ We have a quick list of common questions to get you started engaging with this project in
8
+ [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
package/CLAUDE.md CHANGED
@@ -136,12 +136,14 @@ Structured error codes for programmatic handling:
136
136
  |------|-------------|
137
137
  | `UNAUTHORIZED` | Invalid or missing API key (401) |
138
138
  | `FORBIDDEN` | Valid key but insufficient permissions (403) |
139
+ | `CREDITS_EXHAUSTED` | Insufficient API credits (403) — do not retry |
139
140
  | `RATE_LIMITED` | Too many requests (429) |
140
141
  | `INVALID_ADDRESS` | Address format validation failed |
141
142
  | `INVALID_TOKEN` | Token address validation failed |
142
143
  | `INVALID_CHAIN` | Unsupported or invalid chain |
143
144
  | `INVALID_PARAMS` | Generic parameter validation error |
144
145
  | `MISSING_PARAM` | Required parameter not provided |
146
+ | `UNSUPPORTED_FILTER` | Filter not supported for this token/chain (400) |
145
147
  | `NOT_FOUND` | Resource not found (404) |
146
148
  | `TOKEN_NOT_FOUND` | Token doesn't exist |
147
149
  | `ADDRESS_NOT_FOUND` | Address has no data |
@@ -176,6 +178,15 @@ Structured error codes for programmatic handling:
176
178
  - **Beta endpoints** (`/api/beta/...`) may have different pagination
177
179
  - **EVM vs Solana addresses** — validation auto-detects based on chain param
178
180
 
181
+ ## Changesets
182
+
183
+ Every PR that changes user-facing behavior must include a changeset:
184
+ ```bash
185
+ npx changeset
186
+ ```
187
+ Choose `patch` for bug fixes, `minor` for new features, `major` for breaking changes.
188
+ CI will not publish without a changeset.
189
+
179
190
  ## PR Checklist
180
191
 
181
192
  - [ ] Tests pass (`npm test`)
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/nansen-cli.svg)](https://www.npmjs.com/package/nansen-cli)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Tests](https://img.shields.io/badge/tests-323%20passing-brightgreen.svg)]()
6
- [![Coverage](https://img.shields.io/badge/coverage-80%25-brightgreen.svg)]()
5
+ [![Tests](https://img.shields.io/badge/tests-325%20passing-brightgreen.svg)]()
6
+ [![Coverage](https://img.shields.io/badge/coverage-83%25-brightgreen.svg)]()
7
7
 
8
8
  > **Built by agents, for agents.** We prioritize the best possible AI agent experience.
9
9
 
package/SKILL.md ADDED
@@ -0,0 +1,132 @@
1
+ ---
2
+ name: nansen-cli
3
+ description: Query the Nansen API for onchain analytics - Smart Money flows, wallet profiling, token analysis, and DeFi portfolio data. Use when analyzing crypto wallets, tracking smart money activity, or researching tokens.
4
+ license: MIT
5
+ metadata:
6
+ author: nansen-ai
7
+ version: "1.3.0"
8
+ compatibility: Requires Node.js 18+. Needs NANSEN_API_KEY environment variable or run `nansen login`.
9
+ ---
10
+
11
+ # Nansen CLI
12
+
13
+ Command-line interface for the [Nansen API](https://docs.nansen.ai) - onchain analytics for crypto investors and AI agents.
14
+
15
+ ## Setup
16
+
17
+ ```bash
18
+ # Install globally
19
+ npm install -g nansen-cli
20
+
21
+ # Authenticate (interactive)
22
+ nansen login
23
+
24
+ # Or set environment variable
25
+ export NANSEN_API_KEY=your-api-key
26
+ ```
27
+
28
+ Get your API key at [app.nansen.ai/api](https://app.nansen.ai/api).
29
+
30
+ ## Commands
31
+
32
+ ### Smart Money
33
+ Track sophisticated market participants:
34
+ ```bash
35
+ nansen smart-money netflow --chain solana --pretty
36
+ nansen smart-money dex-trades --chain solana --labels "Smart Trader"
37
+ nansen smart-money holdings --chain solana
38
+ ```
39
+
40
+ ### Wallet Profiler
41
+ Analyze any wallet:
42
+ ```bash
43
+ nansen profiler balance --address 0x123... --chain ethereum
44
+ nansen profiler labels --address 0x123... --chain ethereum
45
+ nansen profiler pnl --address 0x123... --chain ethereum
46
+ nansen profiler search --query "Vitalik"
47
+ ```
48
+
49
+ ### Token God Mode
50
+ Deep token analytics:
51
+ ```bash
52
+ nansen token screener --chain solana --timeframe 24h
53
+ nansen token holders --token <address> --chain solana --smart-money
54
+ nansen token flows --token <address> --chain solana
55
+ nansen token pnl --token <address> --chain solana
56
+ ```
57
+
58
+ ### Portfolio
59
+ DeFi holdings analysis:
60
+ ```bash
61
+ nansen portfolio defi --wallet 0x123...
62
+ ```
63
+
64
+ ## Output Formats
65
+
66
+ - **Default**: JSON (for AI agents)
67
+ - `--pretty`: Formatted JSON
68
+ - `--table`: Human-readable table
69
+ - `--stream`: NDJSON (one record per line)
70
+ - `--fields`: Filter specific fields
71
+
72
+ ## Key Options
73
+
74
+ | Option | Description |
75
+ |--------|-------------|
76
+ | `--chain` | Blockchain (solana, ethereum, base, etc.) |
77
+ | `--chains` | Multiple chains as JSON array |
78
+ | `--limit` | Number of results |
79
+ | `--days` | Date range in days |
80
+ | `--sort` | Sort field (e.g., `value_usd:desc`) |
81
+ | `--smart-money` | Filter for Smart Money only |
82
+
83
+ ## Supported Chains
84
+
85
+ ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, zksync, mantle, ronin, sei, sonic, monad, hyperevm
86
+
87
+ ## Smart Money Labels
88
+
89
+ Fund, Smart Trader, 30D Smart Trader, 90D Smart Trader, 180D Smart Trader, Smart HL Perps Trader
90
+
91
+ ## Schema Introspection
92
+
93
+ Get the full API schema for programmatic use:
94
+ ```bash
95
+ nansen schema --pretty
96
+ nansen schema smart-money --pretty
97
+ ```
98
+
99
+ ## Known Endpoint Issues
100
+
101
+ ### Chain/Token-Specific Limitations
102
+ - `token holders --smart-money` — Fails with `UNSUPPORTED_FILTER` for tokens without smart money tracking (e.g., WCT on Optimism). Not all tokens have smart money data. Do not retry.
103
+ - `token flow-intelligence` — May return all-zero flows for tokens without significant smart money activity. This is normal, not an error.
104
+
105
+ ### Credit Management
106
+ - `profiler labels` and `profiler balance` consume credits. Budget ~20 calls per session.
107
+ - `Insufficient credits` (403, code `CREDITS_EXHAUSTED`) is a hard stop — no retry will help.
108
+ - Check your Nansen dashboard for credit balance: [app.nansen.ai](https://app.nansen.ai).
109
+ - Run balance checks in batches of 3-4 to avoid burning credits on rate-limit retries.
110
+
111
+ ### Error Codes to Watch
112
+ | Code | Meaning | Action |
113
+ |------|---------|--------|
114
+ | `UNSUPPORTED_FILTER` | Filter not available for this token/chain | Remove the filter and retry, or skip this token |
115
+ | `CREDITS_EXHAUSTED` | API credits depleted | Stop all API calls. Check dashboard. |
116
+ | `RATE_LIMITED` | Too many requests (429) | Wait and retry (automatic with default retry) |
117
+
118
+ ## Examples
119
+
120
+ ```bash
121
+ # Find trending Solana tokens with Smart Money activity
122
+ nansen token screener --chain solana --timeframe 24h --smart-money --pretty
123
+
124
+ # Check who's accumulating a specific token
125
+ nansen token holders --token So11111111111111111111111111111111111111112 --chain solana --smart-money --limit 20 --pretty
126
+
127
+ # Profile a whale wallet
128
+ nansen profiler balance --address Gu29tjXrVr9v5n42sX1DNrMiF3BwbrTm379szgB9qXjc --chain solana --pretty
129
+
130
+ # Track Smart Money flows into memecoins
131
+ nansen smart-money netflow --chain solana --labels "Smart Trader" --pretty
132
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -12,7 +12,10 @@
12
12
  "test": "vitest run",
13
13
  "test:watch": "vitest",
14
14
  "test:coverage": "vitest run --coverage",
15
- "test:live": "NANSEN_LIVE_TEST=1 vitest run"
15
+ "test:live": "NANSEN_LIVE_TEST=1 vitest run",
16
+ "changeset": "changeset",
17
+ "changeset:version": "changeset version",
18
+ "changeset:publish": "changeset publish"
16
19
  },
17
20
  "keywords": [
18
21
  "nansen",
@@ -31,16 +34,18 @@
31
34
  "license": "MIT",
32
35
  "repository": {
33
36
  "type": "git",
34
- "url": "git+https://github.com/nansen-ai/nansen-cli.git"
37
+ "url": "git+https://github.com/askeluv/nansen-cli.git"
35
38
  },
36
39
  "bugs": {
37
- "url": "https://github.com/nansen-ai/nansen-cli/issues"
40
+ "url": "https://github.com/askeluv/nansen-cli/issues"
38
41
  },
39
- "homepage": "https://github.com/nansen-ai/nansen-cli#readme",
42
+ "homepage": "https://github.com/askeluv/nansen-cli#readme",
40
43
  "engines": {
41
44
  "node": ">=18.0.0"
42
45
  },
43
46
  "devDependencies": {
47
+ "@changesets/changelog-github": "^0.5.1",
48
+ "@changesets/cli": "^2.29.4",
44
49
  "@vitest/coverage-v8": "^4.0.18",
45
50
  "vitest": "^4.0.18"
46
51
  }
package/src/api.js CHANGED
@@ -18,6 +18,7 @@ export const ErrorCode = {
18
18
  // Authentication & Authorization
19
19
  UNAUTHORIZED: 'UNAUTHORIZED', // 401 - Invalid or missing API key
20
20
  FORBIDDEN: 'FORBIDDEN', // 403 - Valid key but insufficient permissions
21
+ CREDITS_EXHAUSTED: 'CREDITS_EXHAUSTED', // 403 - Insufficient API credits
21
22
 
22
23
  // Rate Limiting
23
24
  RATE_LIMITED: 'RATE_LIMITED', // 429 - Too many requests
@@ -28,6 +29,7 @@ export const ErrorCode = {
28
29
  INVALID_CHAIN: 'INVALID_CHAIN', // Unsupported or invalid chain
29
30
  INVALID_PARAMS: 'INVALID_PARAMS', // Generic parameter validation error
30
31
  MISSING_PARAM: 'MISSING_PARAM', // Required parameter not provided
32
+ UNSUPPORTED_FILTER: 'UNSUPPORTED_FILTER', // Filter not supported for this token/chain
31
33
 
32
34
  // Resource Errors
33
35
  NOT_FOUND: 'NOT_FOUND', // 404 - Resource not found
@@ -77,6 +79,8 @@ function statusToErrorCode(status, data = {}) {
77
79
 
78
80
  switch (status) {
79
81
  case 400:
82
+ case 422:
83
+ if (messageLower.includes('field') && messageLower.includes('not recognized')) return ErrorCode.UNSUPPORTED_FILTER;
80
84
  if (messageLower.includes('address')) return ErrorCode.INVALID_ADDRESS;
81
85
  if (messageLower.includes('token')) return ErrorCode.INVALID_TOKEN;
82
86
  if (messageLower.includes('chain')) return ErrorCode.INVALID_CHAIN;
@@ -84,6 +88,7 @@ function statusToErrorCode(status, data = {}) {
84
88
  case 401:
85
89
  return ErrorCode.UNAUTHORIZED;
86
90
  case 403:
91
+ if (messageLower.includes('credit') || messageLower.includes('insufficient')) return ErrorCode.CREDITS_EXHAUSTED;
87
92
  return ErrorCode.FORBIDDEN;
88
93
  case 404:
89
94
  if (messageLower.includes('token')) return ErrorCode.TOKEN_NOT_FOUND;
@@ -245,8 +250,8 @@ const ADDRESS_PATTERNS = {
245
250
 
246
251
  const EVM_CHAINS = [
247
252
  'ethereum', 'arbitrum', 'base', 'bnb', 'polygon', 'optimism',
248
- 'avalanche', 'linea', 'scroll', 'zksync', 'mantle', 'ronin',
249
- 'sei', 'plasma', 'sonic', 'unichain', 'monad', 'hyperevm', 'iotaevm'
253
+ 'avalanche', 'linea', 'scroll', 'mantle', 'ronin',
254
+ 'sei', 'plasma', 'sonic', 'monad', 'hyperevm', 'iotaevm'
250
255
  ];
251
256
 
252
257
  /**
@@ -334,7 +339,7 @@ const DEFAULT_RETRY_OPTIONS = {
334
339
  /**
335
340
  * Sleep for a given number of milliseconds
336
341
  */
337
- function sleep(ms) {
342
+ export function sleep(ms) {
338
343
  return new Promise(resolve => setTimeout(resolve, ms));
339
344
  }
340
345
 
@@ -392,6 +397,15 @@ export class NansenAPI {
392
397
  };
393
398
  }
394
399
 
400
+ static cleanBody(body) {
401
+ return Object.fromEntries(
402
+ Object.entries(body).filter(([_, v]) =>
403
+ v !== undefined && v !== null &&
404
+ !(typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0)
405
+ )
406
+ );
407
+ }
408
+
395
409
  async request(endpoint, body = {}, options = {}) {
396
410
  const url = `${this.baseUrl}${endpoint}`;
397
411
  const { maxRetries, baseDelayMs, maxDelayMs, retryOnStatus } = this.retryOptions;
@@ -420,7 +434,7 @@ export class NansenAPI {
420
434
  'apikey': this.apiKey,
421
435
  ...options.headers
422
436
  },
423
- body: JSON.stringify(body)
437
+ body: JSON.stringify(NansenAPI.cleanBody(body))
424
438
  });
425
439
  } catch (err) {
426
440
  // Network-level errors - retry these too
@@ -461,10 +475,17 @@ export class NansenAPI {
461
475
  }
462
476
 
463
477
  if (!response.ok) {
464
- const message = data.message || data.error || `API error: ${response.status}`;
478
+ let message = data.message || data.error || `API error: ${response.status}`;
465
479
  const code = statusToErrorCode(response.status, data);
466
480
  const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
467
-
481
+
482
+ // Enhance messages for specific error codes
483
+ if (code === ErrorCode.UNSUPPORTED_FILTER) {
484
+ message = message.replace(/\.+$/, '') + '. This filter is not supported for this token/chain combination. Do not retry.';
485
+ } else if (code === ErrorCode.CREDITS_EXHAUSTED) {
486
+ message = message.replace(/\.+$/, '') + '. No retry will help. Check your Nansen dashboard for credit balance.';
487
+ }
488
+
468
489
  lastError = new NansenError(message, code, response.status, {
469
490
  ...data,
470
491
  attempt: attempt + 1,
@@ -592,14 +613,20 @@ export class NansenAPI {
592
613
  }
593
614
 
594
615
  async addressTransactions(params = {}) {
595
- const { address, chain = 'ethereum', filters = {}, orderBy, pagination } = params;
616
+ const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30, date } = params;
596
617
  if (address) {
597
618
  const validation = validateAddress(address, chain);
598
619
  if (!validation.valid) throw new NansenError(validation.error, validation.code);
599
620
  }
621
+ const dateRange = date || (() => {
622
+ const to = new Date().toISOString().split('T')[0];
623
+ const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
624
+ return { from, to };
625
+ })();
600
626
  return this.request('/api/v1/profiler/address/transactions', {
601
627
  address,
602
628
  chain,
629
+ date: dateRange,
603
630
  filters,
604
631
  order_by: orderBy,
605
632
  pagination
@@ -619,10 +646,9 @@ export class NansenAPI {
619
646
  }
620
647
 
621
648
  async entitySearch(params = {}) {
622
- const { query, pagination } = params;
623
- return this.request('/api/beta/profiler/entity-name-search', {
624
- parameters: { query },
625
- pagination
649
+ const { query } = params;
650
+ return this.request('/api/v1/search/entity-name', {
651
+ search_query: query
626
652
  });
627
653
  }
628
654
 
@@ -645,7 +671,7 @@ export class NansenAPI {
645
671
  }
646
672
 
647
673
  async addressRelatedWallets(params = {}) {
648
- const { address, chain = 'ethereum', filters = {}, orderBy, pagination } = params;
674
+ const { address, chain = 'ethereum', orderBy, pagination } = params;
649
675
  if (address) {
650
676
  const validation = validateAddress(address, chain);
651
677
  if (!validation.valid) throw new NansenError(validation.error, validation.code);
@@ -653,7 +679,6 @@ export class NansenAPI {
653
679
  return this.request('/api/v1/profiler/address/related-wallets', {
654
680
  address,
655
681
  chain,
656
- filters,
657
682
  order_by: orderBy,
658
683
  pagination
659
684
  });
@@ -678,7 +703,7 @@ export class NansenAPI {
678
703
  }
679
704
 
680
705
  async addressPnlSummary(params = {}) {
681
- const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
706
+ const { address, chain = 'ethereum', orderBy, pagination, days = 30 } = params;
682
707
  if (address) {
683
708
  const validation = validateAddress(address, chain);
684
709
  if (!validation.valid) throw new NansenError(validation.error, validation.code);
@@ -689,20 +714,19 @@ export class NansenAPI {
689
714
  address,
690
715
  chain,
691
716
  date: { from, to },
692
- filters,
693
717
  order_by: orderBy,
694
718
  pagination
695
719
  });
696
720
  }
697
721
 
698
722
  async addressPerpPositions(params = {}) {
699
- const { address, filters = {}, orderBy, pagination } = params;
723
+ const { address, filters = {}, orderBy } = params;
700
724
  // Perp positions work with HL addresses (not validated)
725
+ // Note: This endpoint does NOT support pagination parameter
701
726
  return this.request('/api/v1/profiler/perp-positions', {
702
727
  address,
703
728
  filters,
704
- order_by: orderBy,
705
- pagination
729
+ order_by: orderBy
706
730
  });
707
731
  }
708
732
 
@@ -749,14 +773,20 @@ export class NansenAPI {
749
773
  }
750
774
 
751
775
  async tokenFlows(params = {}) {
752
- const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination } = params;
776
+ const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination, days = 30, date } = params;
753
777
  if (tokenAddress) {
754
778
  const validation = validateTokenAddress(tokenAddress, chain);
755
779
  if (!validation.valid) throw new NansenError(validation.error, validation.code);
756
780
  }
781
+ const dateRange = date || (() => {
782
+ const to = new Date().toISOString().split('T')[0];
783
+ const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
784
+ return { from, to };
785
+ })();
757
786
  return this.request('/api/v1/tgm/flows', {
758
787
  token_address: tokenAddress,
759
788
  chain,
789
+ date: dateRange,
760
790
  filters,
761
791
  order_by: orderBy,
762
792
  pagination
@@ -807,14 +837,20 @@ export class NansenAPI {
807
837
  }
808
838
 
809
839
  async tokenWhoBoughtSold(params = {}) {
810
- const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination } = params;
840
+ const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination, days = 30, date } = params;
811
841
  if (tokenAddress) {
812
842
  const validation = validateTokenAddress(tokenAddress, chain);
813
843
  if (!validation.valid) throw new NansenError(validation.error, validation.code);
814
844
  }
845
+ const dateRange = date || (() => {
846
+ const to = new Date().toISOString().split('T')[0];
847
+ const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
848
+ return { from, to };
849
+ })();
815
850
  return this.request('/api/v1/tgm/who-bought-sold', {
816
851
  token_address: tokenAddress,
817
852
  chain,
853
+ date: dateRange,
818
854
  filters,
819
855
  order_by: orderBy,
820
856
  pagination
@@ -822,17 +858,14 @@ export class NansenAPI {
822
858
  }
823
859
 
824
860
  async tokenFlowIntelligence(params = {}) {
825
- const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination } = params;
861
+ const { tokenAddress, chain = 'solana' } = params;
826
862
  if (tokenAddress) {
827
863
  const validation = validateTokenAddress(tokenAddress, chain);
828
864
  if (!validation.valid) throw new NansenError(validation.error, validation.code);
829
865
  }
830
866
  return this.request('/api/v1/tgm/flow-intelligence', {
831
867
  token_address: tokenAddress,
832
- chain,
833
- filters,
834
- order_by: orderBy,
835
- pagination
868
+ chain
836
869
  });
837
870
  }
838
871
 
package/src/cli.js CHANGED
@@ -3,13 +3,19 @@
3
3
  * Extracted from index.js for coverage
4
4
  */
5
5
 
6
- import { NansenAPI, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir } from './api.js';
6
+ import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep } from './api.js';
7
+ import fs from 'fs';
8
+ import { getUpdateNotification, scheduleUpdateCheck } from './update-check.js';
9
+ import { createRequire } from 'module';
7
10
  import * as readline from 'readline';
8
11
 
12
+ const require = createRequire(import.meta.url);
13
+ const { version: VERSION } = require('../package.json');
14
+
9
15
  // ============= Schema Definition =============
10
16
 
11
17
  export const SCHEMA = {
12
- version: '1.1.0',
18
+ version: VERSION,
13
19
  commands: {
14
20
  'smart-money': {
15
21
  description: 'Smart Money analytics - track sophisticated market participants',
@@ -36,22 +42,22 @@ export const SCHEMA = {
36
42
  sort: { type: 'string' },
37
43
  filters: { type: 'object' }
38
44
  },
39
- returns: ['tx_hash', 'wallet_address', 'token_address', 'token_symbol', 'side', 'amount', 'value_usd', 'timestamp']
45
+ returns: ['chain', 'block_timestamp', 'transaction_hash', 'trader_address', 'trader_address_label', 'token_bought_address', 'token_sold_address', 'token_bought_amount', 'token_sold_amount', 'token_bought_symbol', 'token_sold_symbol', 'trade_value_usd']
40
46
  },
41
47
  'perp-trades': {
42
48
  description: 'Perpetual trading on Hyperliquid',
43
49
  options: { limit: { type: 'number' }, sort: { type: 'string' }, filters: { type: 'object' } },
44
- returns: ['wallet_address', 'symbol', 'side', 'size', 'price', 'value_usd', 'pnl_usd', 'timestamp']
50
+ returns: ['trader_address', 'trader_address_label', 'token_symbol', 'side', 'action', 'token_amount', 'price_usd', 'value_usd', 'type', 'block_timestamp', 'transaction_hash']
45
51
  },
46
52
  'holdings': {
47
53
  description: 'Aggregated token balances',
48
54
  options: { chain: { type: 'string', default: 'solana' }, chains: { type: 'array' }, limit: { type: 'number' }, labels: { type: 'string|array' } },
49
- returns: ['token_address', 'token_symbol', 'chain', 'balance', 'balance_usd', 'holder_count']
55
+ returns: ['chain', 'token_address', 'token_symbol', 'token_sectors', 'value_usd', 'balance_24h_percent_change', 'holders_count', 'share_of_holdings_percent', 'token_age_days', 'market_cap_usd']
50
56
  },
51
57
  'dcas': {
52
58
  description: 'DCA strategies on Jupiter',
53
59
  options: { limit: { type: 'number' }, filters: { type: 'object' } },
54
- returns: ['wallet_address', 'input_token', 'output_token', 'total_input', 'total_output', 'avg_price']
60
+ returns: ['dca_created_at', 'dca_updated_at', 'trader_address', 'trader_address_label', 'dca_vault_address', 'input_token_address', 'output_token_address', 'deposit_token_amount', 'token_spent_amount', 'output_token_redeemed_amount', 'dca_status', 'input_token_symbol', 'output_token_symbol', 'deposit_value_usd']
55
61
  },
56
62
  'historical-holdings': {
57
63
  description: 'Historical holdings over time',
@@ -70,7 +76,7 @@ export const SCHEMA = {
70
76
  chain: { type: 'string', default: 'ethereum' },
71
77
  entity: { type: 'string', description: 'Entity name instead of address' }
72
78
  },
73
- returns: ['token_address', 'token_symbol', 'token_name', 'balance', 'balance_usd', 'price_usd']
79
+ returns: ['chain', 'address', 'token_address', 'token_symbol', 'token_name', 'token_amount', 'price_usd', 'value_usd']
74
80
  },
75
81
  'labels': {
76
82
  description: 'Behavioral and entity labels',
@@ -79,8 +85,8 @@ export const SCHEMA = {
79
85
  },
80
86
  'transactions': {
81
87
  description: 'Transaction history',
82
- options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, limit: { type: 'number' }, days: { type: 'number', default: 30 } },
83
- returns: ['tx_hash', 'block_number', 'timestamp', 'from', 'to', 'value', 'value_usd', 'method']
88
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, date: { type: 'string', required: true, description: 'Date or date range (YYYY-MM-DD or {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"})' }, limit: { type: 'number' }, days: { type: 'number', default: 30 } },
89
+ returns: ['chain', 'method', 'tokens_sent', 'tokens_received', 'volume_usd', 'block_timestamp', 'transaction_hash']
84
90
  },
85
91
  'pnl': {
86
92
  description: 'PnL and trade performance',
@@ -90,7 +96,7 @@ export const SCHEMA = {
90
96
  'search': {
91
97
  description: 'Search for entities by name',
92
98
  options: { query: { type: 'string', required: true, description: 'Search query' }, limit: { type: 'number' } },
93
- returns: ['entity_name', 'address', 'chain', 'labels']
99
+ returns: ['entity_name']
94
100
  },
95
101
  'historical-balances': {
96
102
  description: 'Historical balances over time',
@@ -100,17 +106,17 @@ export const SCHEMA = {
100
106
  'related-wallets': {
101
107
  description: 'Find wallets related to an address',
102
108
  options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, limit: { type: 'number' } },
103
- returns: ['address', 'relationship', 'transaction_count', 'volume_usd']
109
+ returns: ['address', 'address_label', 'relation', 'transaction_hash', 'block_timestamp', 'order', 'chain']
104
110
  },
105
111
  'counterparties': {
106
112
  description: 'Top counterparties by volume',
107
113
  options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, days: { type: 'number', default: 30 } },
108
- returns: ['counterparty_address', 'counterparty_label', 'transaction_count', 'volume_usd']
114
+ returns: ['counterparty_address', 'counterparty_address_label', 'interaction_count', 'total_volume_usd', 'volume_in_usd', 'volume_out_usd', 'tokens_info']
109
115
  },
110
116
  'pnl-summary': {
111
117
  description: 'Summarized PnL metrics',
112
118
  options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, days: { type: 'number', default: 30 } },
113
- returns: ['total_realized_pnl', 'total_unrealized_pnl', 'win_rate', 'total_trades']
119
+ returns: ['top5_tokens', 'traded_token_count', 'traded_times', 'realized_pnl_usd', 'realized_pnl_percent', 'win_rate']
114
120
  },
115
121
  'perp-positions': {
116
122
  description: 'Current perpetual positions',
@@ -121,6 +127,38 @@ export const SCHEMA = {
121
127
  description: 'Perpetual trading history',
122
128
  options: { address: { type: 'string', required: true }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
123
129
  returns: ['symbol', 'side', 'size', 'price', 'value_usd', 'pnl_usd', 'timestamp']
130
+ },
131
+ 'batch': {
132
+ description: 'Batch profile multiple addresses',
133
+ options: {
134
+ addresses: { type: 'string', description: 'Comma-separated addresses' },
135
+ file: { type: 'string', description: 'File with one address per line' },
136
+ chain: { type: 'string', default: 'ethereum' },
137
+ include: { type: 'string', default: 'labels,balance', description: 'Comma-separated: labels,balance,pnl' },
138
+ delay: { type: 'number', default: 1000, description: 'Delay between requests in ms' }
139
+ },
140
+ returns: ['address', 'chain', 'labels', 'balance', 'pnl', 'error']
141
+ },
142
+ 'trace': {
143
+ description: 'Multi-hop counterparty trace (BFS)',
144
+ options: {
145
+ address: { type: 'string', required: true },
146
+ chain: { type: 'string', default: 'ethereum' },
147
+ depth: { type: 'number', default: 2, description: 'Max hops (1-5)' },
148
+ width: { type: 'number', default: 10, description: 'Top N counterparties per hop' },
149
+ days: { type: 'number', default: 30 },
150
+ delay: { type: 'number', default: 1000, description: 'Delay between requests in ms' }
151
+ },
152
+ returns: ['root', 'chain', 'depth', 'nodes', 'edges', 'stats']
153
+ },
154
+ 'compare': {
155
+ description: 'Compare two wallets (shared counterparties, tokens)',
156
+ options: {
157
+ addresses: { type: 'string', required: true, description: 'Two comma-separated addresses' },
158
+ chain: { type: 'string', default: 'ethereum' },
159
+ days: { type: 'number', default: 30 }
160
+ },
161
+ returns: ['addresses', 'chain', 'shared_counterparties', 'shared_tokens', 'balances']
124
162
  }
125
163
  }
126
164
  },
@@ -142,12 +180,12 @@ export const SCHEMA = {
142
180
  'holders': {
143
181
  description: 'Token holder analysis',
144
182
  options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, 'smart-money': { type: 'boolean' }, limit: { type: 'number' } },
145
- returns: ['wallet_address', 'balance', 'balance_usd', 'pct_supply', 'labels']
183
+ returns: ['address', 'address_label', 'token_amount', 'total_outflow', 'total_inflow', 'balance_change_24h', 'balance_change_7d', 'balance_change_30d', 'ownership_percentage', 'value_usd']
146
184
  },
147
185
  'flows': {
148
186
  description: 'Token flow metrics',
149
- options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, limit: { type: 'number' } },
150
- returns: ['label', 'inflow', 'outflow', 'net_flow', 'wallet_count']
187
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, date: { type: 'string', required: true, description: 'Date or date range (YYYY-MM-DD or {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"})' }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
188
+ returns: ['date', 'price_usd', 'token_amount', 'value_usd', 'holders_count', 'total_inflows_count', 'total_outflows_count']
151
189
  },
152
190
  'dex-trades': {
153
191
  description: 'DEX trading activity',
@@ -161,17 +199,17 @@ export const SCHEMA = {
161
199
  },
162
200
  'who-bought-sold': {
163
201
  description: 'Recent buyers and sellers',
164
- options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, limit: { type: 'number' } },
165
- returns: ['wallet_address', 'side', 'amount', 'value_usd', 'timestamp', 'labels']
202
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, date: { type: 'string', required: true, description: 'Date or date range (YYYY-MM-DD or {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"})' }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
203
+ returns: ['address', 'address_label', 'bought_token_volume', 'sold_token_volume', 'token_trade_volume', 'bought_volume_usd', 'sold_volume_usd', 'trade_volume_usd']
166
204
  },
167
205
  'flow-intelligence': {
168
206
  description: 'Detailed flow intelligence by label',
169
- options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, limit: { type: 'number' } },
170
- returns: ['label', 'inflow_usd', 'outflow_usd', 'net_flow_usd', 'unique_wallets']
207
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, days: { type: 'number', default: 30 } },
208
+ returns: ['public_figure_net_flow_usd', 'public_figure_wallet_count', 'top_pnl_net_flow_usd', 'top_pnl_wallet_count', 'whale_net_flow_usd', 'whale_wallet_count', 'smart_trader_net_flow_usd', 'smart_trader_wallet_count', 'exchange_net_flow_usd', 'exchange_wallet_count', 'fresh_wallets_net_flow_usd', 'fresh_wallets_wallet_count']
171
209
  },
172
210
  'transfers': {
173
211
  description: 'Token transfer history',
174
- options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
212
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, days: { type: 'number', default: 30 }, limit: { type: 'number' }, from: { type: 'string', description: 'Filter by sender address' }, to: { type: 'string', description: 'Filter by recipient address' }, enrich: { type: 'boolean', description: 'Enrich addresses with Nansen labels' } },
175
213
  returns: ['tx_hash', 'from', 'to', 'amount', 'value_usd', 'timestamp']
176
214
  },
177
215
  'jup-dca': {
@@ -212,9 +250,10 @@ export const SCHEMA = {
212
250
  table: { type: 'boolean', description: 'Format output as human-readable table' },
213
251
  fields: { type: 'string', description: 'Comma-separated list of fields to include in output' },
214
252
  'no-retry': { type: 'boolean', description: 'Disable automatic retry on rate limits/errors' },
215
- retries: { type: 'number', default: 3, description: 'Max retry attempts' }
253
+ retries: { type: 'number', default: 3, description: 'Max retry attempts' },
254
+ format: { type: 'string', enum: ['json', 'csv'], description: 'Output format (default: json)' }
216
255
  },
217
- chains: ['ethereum', 'solana', 'base', 'bnb', 'arbitrum', 'polygon', 'optimism', 'avalanche', 'linea', 'scroll', 'zksync', 'mantle', 'ronin', 'sei', 'plasma', 'sonic', 'unichain', 'monad', 'hyperevm', 'iotaevm'],
256
+ chains: ['ethereum', 'solana', 'base', 'bnb', 'arbitrum', 'polygon', 'optimism', 'avalanche', 'linea', 'scroll', 'mantle', 'ronin', 'sei', 'plasma', 'sonic', 'monad', 'hyperevm', 'iotaevm'],
218
257
  smartMoneyLabels: ['Fund', 'Smart Trader', '30D Smart Trader', '90D Smart Trader', '180D Smart Trader', 'Smart HL Perps Trader']
219
258
  };
220
259
 
@@ -278,7 +317,7 @@ export function parseArgs(args) {
278
317
  const key = arg.slice(2);
279
318
  const next = args[i + 1];
280
319
 
281
- if (key === 'pretty' || key === 'help' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream') {
320
+ if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich') {
282
321
  result.flags[key] = true;
283
322
  } else if (next && !next.startsWith('-')) {
284
323
  // Try to parse as JSON first
@@ -384,9 +423,53 @@ export function formatTable(data) {
384
423
  return lines.join('\n');
385
424
  }
386
425
 
426
+ /**
427
+ * Format data as CSV with header row
428
+ */
429
+ export function formatCsv(data) {
430
+ // Extract array of records from various response shapes
431
+ let records = [];
432
+ if (Array.isArray(data)) {
433
+ records = data;
434
+ } else if (data?.data && Array.isArray(data.data)) {
435
+ records = data.data;
436
+ } else if (data?.results && Array.isArray(data.results)) {
437
+ records = data.results;
438
+ } else if (data?.data?.results && Array.isArray(data.data.results)) {
439
+ records = data.data.results;
440
+ } else if (typeof data === 'object' && data !== null) {
441
+ records = [data];
442
+ }
443
+
444
+ if (records.length === 0) return '';
445
+
446
+ const columns = [...new Set(records.flatMap(r => Object.keys(r)))];
447
+
448
+ const escape = (val) => {
449
+ if (val === null || val === undefined) return '';
450
+ const s = typeof val === 'object' ? JSON.stringify(val) : String(val);
451
+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {
452
+ return '"' + s.replace(/"/g, '""') + '"';
453
+ }
454
+ return s;
455
+ };
456
+
457
+ const lines = [columns.join(',')];
458
+ for (const record of records) {
459
+ lines.push(columns.map(col => escape(record[col])).join(','));
460
+ }
461
+ return lines.join('\n');
462
+ }
463
+
387
464
  // Format output data (returns string, does not print)
388
- export function formatOutput(data, { pretty = false, table = false } = {}) {
389
- if (table) {
465
+ export function formatOutput(data, { pretty = false, table = false, csv = false } = {}) {
466
+ if (csv) {
467
+ if (data.success === false) {
468
+ return { type: 'error', text: `Error: ${data.error}` };
469
+ }
470
+ const csvData = data.data || data;
471
+ return { type: 'csv', text: formatCsv(csvData) };
472
+ } else if (table) {
390
473
  if (data.success === false) {
391
474
  return { type: 'error', text: `Error: ${data.error}` };
392
475
  } else {
@@ -439,6 +522,32 @@ export function formatStream(data) {
439
522
  return records.map(record => JSON.stringify(record)).join('\n');
440
523
  }
441
524
 
525
+ /**
526
+ * Parse --date option into {from, to} object.
527
+ * Accepts: "YYYY-MM-DD" (single date → from=date, to=date),
528
+ * '{"from":"YYYY-MM-DD","to":"YYYY-MM-DD"}' (JSON object),
529
+ * or already-parsed object {from, to}.
530
+ * Falls back to days-based range if no date provided.
531
+ */
532
+ export function parseDateOption(dateOption, days = 30) {
533
+ if (dateOption) {
534
+ if (typeof dateOption === 'object' && dateOption.from) {
535
+ return dateOption;
536
+ }
537
+ if (typeof dateOption === 'string') {
538
+ // Simple date string: use as both from and to
539
+ const dateMatch = dateOption.match(/^\d{4}-\d{2}-\d{2}$/);
540
+ if (dateMatch) {
541
+ return { from: dateOption, to: dateOption };
542
+ }
543
+ }
544
+ }
545
+ // Default: use days-based range
546
+ const to = new Date().toISOString().split('T')[0];
547
+ const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
548
+ return { from, to };
549
+ }
550
+
442
551
  // Parse simple sort syntax: "field:direction" or "field" (defaults to DESC)
443
552
  export function parseSort(sortOption, orderByOption) {
444
553
  // If --order-by is provided, use it (full JSON control)
@@ -455,6 +564,202 @@ export function parseSort(sortOption, orderByOption) {
455
564
  return [{ field, direction }];
456
565
  }
457
566
 
567
+ // Enrich transfers with Nansen labels for from/to addresses
568
+ async function enrichTransfers(result, apiInstance, chain) {
569
+ const transfers = result?.data?.results || result?.transfers || result?.data || [];
570
+ if (!Array.isArray(transfers) || transfers.length === 0) return result;
571
+
572
+ // Collect unique addresses (cap at 50)
573
+ const addrs = new Set();
574
+ for (const t of transfers) {
575
+ if (t.from) addrs.add(t.from);
576
+ if (t.to) addrs.add(t.to);
577
+ if (addrs.size >= 50) break;
578
+ }
579
+
580
+ // Batch lookup labels
581
+ const labelMap = {};
582
+ for (const addr of addrs) {
583
+ try {
584
+ const labelsResult = await apiInstance.addressLabels({ address: addr, chain });
585
+ labelMap[addr] = labelsResult?.labels || labelsResult?.data?.results || [];
586
+ } catch {
587
+ labelMap[addr] = [];
588
+ }
589
+ }
590
+
591
+ // Merge labels into transfers
592
+ for (const t of transfers) {
593
+ if (t.from && labelMap[t.from]) t.from_labels = labelMap[t.from];
594
+ if (t.to && labelMap[t.to]) t.to_labels = labelMap[t.to];
595
+ }
596
+
597
+ return result;
598
+ }
599
+
600
+ // ============= Composite Functions =============
601
+
602
+ export async function batchProfile(api, params = {}) {
603
+ const { addresses = [], chain = 'ethereum', include = ['labels', 'balance'], delayMs = 1000 } = params;
604
+ const results = [];
605
+ for (let i = 0; i < addresses.length; i++) {
606
+ const address = addresses[i].trim();
607
+ const entry = { address, chain };
608
+ const validation = validateAddress(address, chain);
609
+ if (!validation.valid) {
610
+ entry.error = validation.error;
611
+ results.push(entry);
612
+ if (i < addresses.length - 1) await sleep(delayMs);
613
+ continue;
614
+ }
615
+ try {
616
+ if (include.includes('labels')) {
617
+ entry.labels = await api.addressLabels({ address, chain });
618
+ }
619
+ if (include.includes('balance')) {
620
+ entry.balance = await api.addressBalance({ address, chain });
621
+ }
622
+ if (include.includes('pnl')) {
623
+ entry.pnl = await api.addressPnl({ address, chain });
624
+ }
625
+ } catch (err) {
626
+ entry.error = err.message;
627
+ }
628
+ results.push(entry);
629
+ if (i < addresses.length - 1) await sleep(delayMs);
630
+ }
631
+ return { results, total: addresses.length, completed: results.filter(r => !r.error).length };
632
+ }
633
+
634
+ export async function traceCounterparties(api, params = {}) {
635
+ const { address, chain = 'ethereum', depth = 2, width = 10, days = 30, delayMs = 1000 } = params;
636
+ if (!address) {
637
+ throw new NansenError('address is required for trace', ErrorCode.MISSING_PARAM);
638
+ }
639
+ const validation = validateAddress(address, chain);
640
+ if (!validation.valid) {
641
+ throw new NansenError(validation.error, ErrorCode.INVALID_ADDRESS);
642
+ }
643
+ const clampedDepth = Math.max(1, Math.min(depth, 5));
644
+ const visited = new Set();
645
+ const nodes = [];
646
+ const edges = [];
647
+ const queue = [{ addr: address, hop: 0 }];
648
+ visited.add(address);
649
+ nodes.push(address);
650
+
651
+ while (queue.length > 0) {
652
+ const { addr, hop } = queue.shift();
653
+ if (hop >= clampedDepth) continue;
654
+
655
+ try {
656
+ const result = await api.addressCounterparties({
657
+ address: addr, chain, days,
658
+ pagination: { page: 1, per_page: width },
659
+ });
660
+
661
+ const counterparties = result?.data?.results || result?.counterparties || result?.data || [];
662
+ const items = Array.isArray(counterparties) ? counterparties.slice(0, width) : [];
663
+
664
+ for (const cp of items) {
665
+ const cpAddr = cp.counterparty_address || cp.address || cp.counterparty;
666
+ if (!cpAddr) continue;
667
+
668
+ edges.push({
669
+ from: addr, to: cpAddr,
670
+ volume_usd: cp.volume_usd || cp.total_volume_usd || 0,
671
+ tx_count: cp.transaction_count || cp.tx_count || 0,
672
+ hop: hop + 1,
673
+ });
674
+
675
+ if (!visited.has(cpAddr)) {
676
+ visited.add(cpAddr);
677
+ nodes.push(cpAddr);
678
+ queue.push({ addr: cpAddr, hop: hop + 1 });
679
+ }
680
+ }
681
+ } catch (err) {
682
+ // Skip addresses that fail (404, etc) but continue the traversal
683
+ }
684
+
685
+ if (queue.length > 0) await sleep(delayMs);
686
+ }
687
+
688
+ return {
689
+ root: address, chain, depth: clampedDepth,
690
+ nodes, edges,
691
+ stats: { nodes_visited: nodes.length, edges_found: edges.length, max_depth_reached: Math.max(0, ...edges.map(e => e.hop)) },
692
+ };
693
+ }
694
+
695
+ export async function compareWallets(api, params = {}) {
696
+ const { addresses = [], chain = 'ethereum', days = 30, delayMs = 1000 } = params;
697
+ if (addresses.length !== 2) {
698
+ throw new NansenError('Exactly 2 addresses are required for comparison', ErrorCode.INVALID_PARAMS);
699
+ }
700
+ const [addr1, addr2] = addresses;
701
+ for (const addr of [addr1, addr2]) {
702
+ const validation = validateAddress(addr, chain);
703
+ if (!validation.valid) {
704
+ throw new NansenError(validation.error, ErrorCode.INVALID_ADDRESS);
705
+ }
706
+ }
707
+
708
+ // Fetch counterparties and balances for both addresses
709
+ const [cp1, cp2] = await Promise.all([
710
+ api.addressCounterparties({ address: addr1, chain, days }).catch(() => null),
711
+ api.addressCounterparties({ address: addr2, chain, days }).catch(() => null),
712
+ ]);
713
+ await sleep(delayMs);
714
+ const [bal1, bal2] = await Promise.all([
715
+ api.addressBalance({ address: addr1, chain }).catch(() => null),
716
+ api.addressBalance({ address: addr2, chain }).catch(() => null),
717
+ ]);
718
+
719
+ // Extract counterparty addresses
720
+ const extractCps = (result) => {
721
+ const list = result?.data?.results || result?.counterparties || result?.data || [];
722
+ return Array.isArray(list) ? list : [];
723
+ };
724
+ const cps1 = extractCps(cp1);
725
+ const cps2 = extractCps(cp2);
726
+ const cpAddrs1 = new Set(cps1.map(c => c.counterparty_address || c.address || c.counterparty).filter(Boolean));
727
+ const cpAddrs2 = new Set(cps2.map(c => c.counterparty_address || c.address || c.counterparty).filter(Boolean));
728
+ const sharedCpAddrs = [...cpAddrs1].filter(a => cpAddrs2.has(a));
729
+
730
+ // Extract token holdings
731
+ const extractTokens = (result) => {
732
+ const list = result?.data?.results || result?.balances || result?.data || [];
733
+ return Array.isArray(list) ? list : [];
734
+ };
735
+ const tokens1 = extractTokens(bal1);
736
+ const tokens2 = extractTokens(bal2);
737
+ const tokenSyms1 = new Set(tokens1.map(t => t.token_symbol).filter(Boolean));
738
+ const tokenSyms2 = new Set(tokens2.map(t => t.token_symbol).filter(Boolean));
739
+ const sharedTokens = [...tokenSyms1].filter(s => tokenSyms2.has(s));
740
+
741
+ return {
742
+ addresses: [addr1, addr2], chain,
743
+ shared_counterparties: sharedCpAddrs,
744
+ shared_tokens: sharedTokens,
745
+ balances: [
746
+ { address: addr1, total_usd: tokens1.reduce((sum, t) => sum + (t.balance_usd || 0), 0) },
747
+ { address: addr2, total_usd: tokens2.reduce((sum, t) => sum + (t.balance_usd || 0), 0) },
748
+ ],
749
+ };
750
+ }
751
+
752
+ // ASCII Art Banner
753
+ export const BANNER = `
754
+ ███╗ ██╗ █████╗ ███╗ ██╗███████╗███████╗███╗ ██╗
755
+ ████╗ ██║██╔══██╗████╗ ██║██╔════╝██╔════╝████╗ ██║
756
+ ██╔██╗ ██║███████║██╔██╗ ██║███████╗█████╗ ██╔██╗ ██║
757
+ ██║╚██╗██║██╔══██║██║╚██╗██║╚════██║██╔══╝ ██║╚██╗██║
758
+ ██║ ╚████║██║ ██║██║ ╚████║███████║███████╗██║ ╚████║
759
+ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝╚═╝ ╚═══╝
760
+ Surface The Signal
761
+ `;
762
+
458
763
  // Help text
459
764
  export const HELP = `
460
765
  Nansen CLI - Command-line interface for Nansen API
@@ -469,7 +774,7 @@ COMMANDS:
469
774
  schema Output JSON schema for all commands (for agent introspection)
470
775
  cache Cache management (clear)
471
776
  smart-money Smart Money analytics (netflow, dex-trades, holdings, dcas, historical-holdings)
472
- profiler Wallet profiling (balance, labels, transactions, pnl, perp-positions, perp-trades)
777
+ profiler Wallet profiling (balance, labels, pnl, batch, trace, compare, counterparties)
473
778
  token Token God Mode (screener, holders, flows, trades, pnl, perp-trades, perp-positions)
474
779
  portfolio Portfolio analytics (defi-holdings)
475
780
  help Show this help message
@@ -492,6 +797,7 @@ GLOBAL OPTIONS:
492
797
  --no-cache Disable cache for this request
493
798
  --cache-ttl <s> Cache TTL in seconds (default: 300)
494
799
  --stream Output as JSON lines (NDJSON) for incremental processing
800
+ --format csv Output as CSV with header row
495
801
 
496
802
  EXAMPLES:
497
803
  # Get Smart Money netflow on Solana
@@ -510,7 +816,7 @@ EXAMPLES:
510
816
  nansen profiler search --query "Vitalik"
511
817
 
512
818
  # Get token holders with filters
513
- nansen token holders --token 0x123... --filters '{"only_smart_money":true}'
819
+ nansen token holders --token 0x123... --smart-money
514
820
 
515
821
  SMART MONEY LABELS:
516
822
  Fund, Smart Trader, 30D Smart Trader, 90D Smart Trader,
@@ -518,8 +824,8 @@ SMART MONEY LABELS:
518
824
 
519
825
  SUPPORTED CHAINS:
520
826
  ethereum, solana, base, bnb, arbitrum, polygon, optimism,
521
- avalanche, linea, scroll, zksync, mantle, ronin, sei,
522
- plasma, sonic, unichain, monad, hyperevm, iotaevm
827
+ avalanche, linea, scroll, mantle, ronin, sei,
828
+ plasma, sonic, monad, hyperevm, iotaevm
523
829
 
524
830
  For more info: https://docs.nansen.ai
525
831
  `;
@@ -733,17 +1039,57 @@ export function buildCommands(deps = {}) {
733
1039
  const handlers = {
734
1040
  'balance': () => apiInstance.addressBalance({ address, entityName, chain, filters, orderBy }),
735
1041
  'labels': () => apiInstance.addressLabels({ address, chain, pagination }),
736
- 'transactions': () => apiInstance.addressTransactions({ address, chain, filters, orderBy, pagination }),
1042
+ 'transactions': () => {
1043
+ const date = parseDateOption(options.date, days);
1044
+ return apiInstance.addressTransactions({ address, chain, filters, orderBy, pagination, days, date });
1045
+ },
737
1046
  'pnl': () => apiInstance.addressPnl({ address, chain }),
738
- 'search': () => apiInstance.entitySearch({ query: options.query, pagination }),
1047
+ 'search': () => apiInstance.entitySearch({ query: options.query }),
739
1048
  'historical-balances': () => apiInstance.addressHistoricalBalances({ address, chain, filters, orderBy, pagination, days }),
740
- 'related-wallets': () => apiInstance.addressRelatedWallets({ address, chain, filters, orderBy, pagination }),
1049
+ 'related-wallets': () => apiInstance.addressRelatedWallets({ address, chain, orderBy, pagination }),
741
1050
  'counterparties': () => apiInstance.addressCounterparties({ address, chain, filters, orderBy, pagination, days }),
742
- 'pnl-summary': () => apiInstance.addressPnlSummary({ address, chain, filters, orderBy, pagination, days }),
1051
+ 'pnl-summary': () => apiInstance.addressPnlSummary({ address, chain, orderBy, pagination, days }),
743
1052
  'perp-positions': () => apiInstance.addressPerpPositions({ address, filters, orderBy, pagination }),
744
1053
  'perp-trades': () => apiInstance.addressPerpTrades({ address, filters, orderBy, pagination, days }),
1054
+ 'batch': () => {
1055
+ let addresses = [];
1056
+ if (options.addresses) {
1057
+ addresses = options.addresses.split(',').map(a => a.trim()).filter(Boolean);
1058
+ } else if (options.file) {
1059
+ const content = fs.readFileSync(options.file, 'utf8');
1060
+ try {
1061
+ const parsed = JSON.parse(content);
1062
+ if (!Array.isArray(parsed)) {
1063
+ throw new NansenError('File must contain a JSON array of address strings or one address per line', ErrorCode.INVALID_PARAMS);
1064
+ }
1065
+ if (!parsed.every(item => typeof item === 'string')) {
1066
+ throw new NansenError('File must contain a JSON array of address strings or one address per line', ErrorCode.INVALID_PARAMS);
1067
+ }
1068
+ addresses = parsed.map(a => a.trim()).filter(Boolean);
1069
+ } catch (e) {
1070
+ if (e instanceof NansenError) throw e;
1071
+ addresses = content.split('\n').map(a => a.trim()).filter(Boolean);
1072
+ }
1073
+ }
1074
+ if (addresses.length > 100) {
1075
+ throw new NansenError('Batch is limited to 100 addresses', ErrorCode.INVALID_PARAMS);
1076
+ }
1077
+ const include = options.include ? options.include.split(',').map(s => s.trim()) : ['labels', 'balance'];
1078
+ const delayMs = options.delay ? parseInt(options.delay) : 1000;
1079
+ return batchProfile(apiInstance, { addresses, chain, include, delayMs });
1080
+ },
1081
+ 'trace': () => {
1082
+ const depth = options.depth ? Math.max(1, Math.min(parseInt(options.depth), 5)) : 2;
1083
+ const width = options.width ? parseInt(options.width) : 10;
1084
+ const delayMs = options.delay ? parseInt(options.delay) : 1000;
1085
+ return traceCounterparties(apiInstance, { address, chain, depth, width, days, delayMs });
1086
+ },
1087
+ 'compare': () => {
1088
+ const addrs = (options.addresses || '').split(',').map(a => a.trim()).filter(Boolean);
1089
+ return compareWallets(apiInstance, { addresses: addrs, chain, days });
1090
+ },
745
1091
  'help': () => ({
746
- commands: ['balance', 'labels', 'transactions', 'pnl', 'search', 'historical-balances', 'related-wallets', 'counterparties', 'pnl-summary', 'perp-positions', 'perp-trades'],
1092
+ commands: ['balance', 'labels', 'transactions', 'pnl', 'search', 'historical-balances', 'related-wallets', 'counterparties', 'pnl-summary', 'perp-positions', 'perp-trades', 'batch', 'trace', 'compare'],
747
1093
  description: 'Wallet profiling endpoints',
748
1094
  example: 'nansen profiler balance --address 0x123... --chain ethereum'
749
1095
  })
@@ -771,18 +1117,30 @@ export function buildCommands(deps = {}) {
771
1117
  // Convenience filter for smart money only
772
1118
  const onlySmartMoney = options['smart-money'] || flags['smart-money'] || false;
773
1119
  if (onlySmartMoney) {
774
- filters.only_smart_money = true;
1120
+ filters.include_smart_money_labels = filters.include_smart_money_labels ||
1121
+ ['Fund', 'Smart Trader', '30D Smart Trader', '90D Smart Trader', '180D Smart Trader'];
775
1122
  }
776
1123
 
777
1124
  const handlers = {
778
1125
  'screener': () => apiInstance.tokenScreener({ chains, timeframe, filters, orderBy, pagination }),
779
- 'holders': () => apiInstance.tokenHolders({ tokenAddress, chain, filters, orderBy, pagination }),
780
- 'flows': () => apiInstance.tokenFlows({ tokenAddress, chain, filters, orderBy, pagination }),
1126
+ 'holders': () => apiInstance.tokenHolders({ tokenAddress, chain, labelType: onlySmartMoney ? 'smart_money' : 'all_holders', filters, orderBy, pagination }),
1127
+ 'flows': () => {
1128
+ const date = parseDateOption(options.date, days);
1129
+ return apiInstance.tokenFlows({ tokenAddress, chain, filters, orderBy, pagination, days, date });
1130
+ },
781
1131
  'dex-trades': () => apiInstance.tokenDexTrades({ tokenAddress, chain, onlySmartMoney, filters, orderBy, pagination, days }),
782
1132
  'pnl': () => apiInstance.tokenPnlLeaderboard({ tokenAddress, chain, filters, orderBy, pagination, days }),
783
- 'who-bought-sold': () => apiInstance.tokenWhoBoughtSold({ tokenAddress, chain, filters, orderBy, pagination }),
784
- 'flow-intelligence': () => apiInstance.tokenFlowIntelligence({ tokenAddress, chain, filters, orderBy, pagination }),
785
- 'transfers': () => apiInstance.tokenTransfers({ tokenAddress, chain, filters, orderBy, pagination, days }),
1133
+ 'who-bought-sold': () => {
1134
+ const date = parseDateOption(options.date, days);
1135
+ return apiInstance.tokenWhoBoughtSold({ tokenAddress, chain, filters, orderBy, pagination, days, date });
1136
+ },
1137
+ 'flow-intelligence': () => apiInstance.tokenFlowIntelligence({ tokenAddress, chain, days }),
1138
+ 'transfers': () => {
1139
+ // Inject --from/--to into filters
1140
+ if (options.from) filters.from_address = options.from;
1141
+ if (options.to) filters.to_address = options.to;
1142
+ return apiInstance.tokenTransfers({ tokenAddress, chain, filters, orderBy, pagination, days });
1143
+ },
786
1144
  'jup-dca': () => apiInstance.tokenJupDca({ tokenAddress, filters, orderBy, pagination }),
787
1145
  'perp-trades': () => apiInstance.tokenPerpTrades({ tokenSymbol, filters, orderBy, pagination, days }),
788
1146
  'perp-positions': () => apiInstance.tokenPerpPositions({ tokenSymbol, filters, orderBy, pagination }),
@@ -798,7 +1156,14 @@ export function buildCommands(deps = {}) {
798
1156
  return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
799
1157
  }
800
1158
 
801
- return handlers[subcommand]();
1159
+ let result = await handlers[subcommand]();
1160
+
1161
+ // Enrich transfers with Nansen labels for from/to addresses
1162
+ if (subcommand === 'transfers' && (options.enrich || flags.enrich)) {
1163
+ result = await enrichTransfers(result, apiInstance, chain);
1164
+ }
1165
+
1166
+ return result;
802
1167
  },
803
1168
 
804
1169
  'portfolio': async (args, apiInstance, flags, options) => {
@@ -838,17 +1203,29 @@ export async function runCLI(rawArgs, deps = {}) {
838
1203
  } = deps;
839
1204
 
840
1205
  const { _: positional, flags, options } = parseArgs(rawArgs);
841
-
1206
+
842
1207
  const command = positional[0] || 'help';
843
1208
  const subArgs = positional.slice(1);
844
1209
  const pretty = flags.pretty || flags.p;
845
1210
  const table = flags.table || flags.t;
846
1211
  const stream = flags.stream || flags.s;
1212
+ const csv = options.format === 'csv';
1213
+
1214
+ // Update check (read cached result + schedule background refresh)
1215
+ const updateNotification = getUpdateNotification(VERSION);
1216
+ scheduleUpdateCheck();
1217
+ const notify = () => { if (updateNotification) errorOutput(updateNotification); };
847
1218
 
848
1219
  const commands = { ...buildCommands(deps), ...commandOverrides };
849
1220
 
1221
+ if (flags.version || flags.v) {
1222
+ output(VERSION);
1223
+ return { type: 'version', data: VERSION };
1224
+ }
1225
+
850
1226
  if (command === 'help' || flags.help || flags.h) {
851
- output(HELP);
1227
+ output(BANNER + HELP);
1228
+ notify();
852
1229
  return { type: 'help' };
853
1230
  }
854
1231
 
@@ -859,6 +1236,7 @@ export async function runCLI(rawArgs, deps = {}) {
859
1236
  };
860
1237
  const formatted = formatOutput(errorData, { pretty, table });
861
1238
  output(formatted.text);
1239
+ notify();
862
1240
  exit(1);
863
1241
  return { type: 'error', data: errorData };
864
1242
  }
@@ -871,9 +1249,11 @@ export async function runCLI(rawArgs, deps = {}) {
871
1249
  if (command === 'schema' && result) {
872
1250
  const formatted = formatOutput(result, { pretty, table: false });
873
1251
  output(formatted.text);
1252
+ notify();
874
1253
  return { type: 'schema', data: result };
875
1254
  }
876
-
1255
+
1256
+ notify();
877
1257
  return { type: 'no-auth', command };
878
1258
  }
879
1259
 
@@ -905,17 +1285,20 @@ export async function runCLI(rawArgs, deps = {}) {
905
1285
  if (streamOutput) {
906
1286
  output(streamOutput);
907
1287
  }
1288
+ notify();
908
1289
  return { type: 'stream', data: result };
909
1290
  }
910
-
1291
+
911
1292
  const successData = { success: true, data: result };
912
- const formatted = formatOutput(successData, { pretty, table });
1293
+ const formatted = formatOutput(successData, { pretty, table, csv });
913
1294
  output(formatted.text);
914
- return { type: 'success', data: result };
1295
+ notify();
1296
+ return { type: csv ? 'csv' : 'success', data: result };
915
1297
  } catch (error) {
916
1298
  const errorData = formatError(error);
917
- const formatted = formatOutput(errorData, { pretty, table });
1299
+ const formatted = formatOutput(errorData, { pretty, table, csv });
918
1300
  errorOutput(formatted.text);
1301
+ notify();
919
1302
  exit(1);
920
1303
  return { type: 'error', data: errorData };
921
1304
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Update check — lightweight, non-blocking, zero-dependency.
3
+ *
4
+ * getUpdateNotification(currentVersion) — reads cached result, returns string or null
5
+ * scheduleUpdateCheck() — spawns detached background fetch if cache is stale
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { spawn } from 'child_process';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
15
+ const CACHE_FILE = path.join(CONFIG_DIR, 'update-check.json');
16
+ const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
17
+ const PACKAGE_NAME = 'nansen-cli';
18
+
19
+ /**
20
+ * Compare two semver strings. Returns true if latest > current.
21
+ */
22
+ function isNewer(latest, current) {
23
+ const parse = v => v.replace(/^v/, '').split('.').map(Number);
24
+ const [lM, lm, lp] = parse(latest);
25
+ const [cM, cm, cp] = parse(current);
26
+ if (lM !== cM) return lM > cM;
27
+ if (lm !== cm) return lm > cm;
28
+ return lp > cp;
29
+ }
30
+
31
+ /**
32
+ * Read the cached check result and return a notification string (or null).
33
+ */
34
+ export function getUpdateNotification(currentVersion) {
35
+ try {
36
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return null;
37
+ if (!fs.existsSync(CACHE_FILE)) return null;
38
+
39
+ const raw = fs.readFileSync(CACHE_FILE, 'utf8');
40
+ const { latest } = JSON.parse(raw);
41
+ if (!latest) return null;
42
+
43
+ if (isNewer(latest, currentVersion)) {
44
+ return `Update available: ${currentVersion} → ${latest} (npm i -g ${PACKAGE_NAME})`;
45
+ }
46
+ return null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * If the cache is missing or stale, spawn a detached background process to refresh it.
54
+ */
55
+ export function scheduleUpdateCheck() {
56
+ try {
57
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
58
+
59
+ // Check staleness
60
+ if (fs.existsSync(CACHE_FILE)) {
61
+ const { checkedAt } = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
62
+ if (checkedAt && Date.now() - checkedAt < STALE_MS) return;
63
+ }
64
+
65
+ // Inline script executed by the detached child
66
+ const script = `
67
+ const https = require('https');
68
+ const fs = require('fs');
69
+ const path = require('path');
70
+ const dir = ${JSON.stringify(CONFIG_DIR)};
71
+ const file = ${JSON.stringify(CACHE_FILE)};
72
+ const req = https.get('https://registry.npmjs.org/${PACKAGE_NAME}/latest', { timeout: 5000 }, (res) => {
73
+ let body = '';
74
+ res.on('data', c => body += c);
75
+ res.on('end', () => {
76
+ try {
77
+ const { version } = JSON.parse(body);
78
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700, recursive: true });
79
+ fs.writeFileSync(file, JSON.stringify({ latest: version, checkedAt: Date.now() }));
80
+ } catch {}
81
+ });
82
+ });
83
+ req.on('error', () => {});
84
+ req.setTimeout(5000, () => req.destroy());
85
+ `;
86
+
87
+ const child = spawn(process.execPath, ['-e', script], {
88
+ detached: true,
89
+ stdio: 'ignore'
90
+ });
91
+ child.unref();
92
+ } catch {
93
+ // silent
94
+ }
95
+ }