nansen-cli 1.2.0 → 1.3.1

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-356%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
 
@@ -156,6 +156,7 @@ nansen cache clear
156
156
  | `--cache` | Enable response caching |
157
157
  | `--no-cache` | Bypass cache for this request |
158
158
  | `--cache-ttl <s>` | Cache TTL in seconds (default: 300) |
159
+ | `--stream` | Output as JSON lines (NDJSON) for incremental processing |
159
160
  | `--chain <chain>` | Blockchain to query |
160
161
  | `--chains <json>` | Multiple chains as JSON array |
161
162
  | `--limit <n>` | Number of results |
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/TODO.md CHANGED
@@ -2,17 +2,6 @@
2
2
 
3
3
  > **Built by agents, for agents.** We prioritize improvements that create the best possible AI agent experience.
4
4
 
5
- ## P1 - Should Have
6
-
7
- ### Test Cleanup
8
- - [ ] Remove duplicated `parseArgs` in unit.test.js (now exported from cli.js)
9
- - [ ] Reduce cli.test.js subprocess tests to ~10 smoke tests
10
-
11
- ### Streaming Output
12
- - [ ] `--stream` flag for large result sets
13
- - [ ] Output as JSON lines (newline-delimited JSON)
14
- - [ ] Enable incremental processing by agents
15
-
16
5
  ## P2 - Nice to Have
17
6
 
18
7
  ### Test Coverage Gaps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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",
@@ -41,6 +44,8 @@
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') {
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 {
@@ -411,6 +494,60 @@ export function formatError(error) {
411
494
  };
412
495
  }
413
496
 
497
+ /**
498
+ * Format data as JSON lines (NDJSON) for streaming output
499
+ * Each record is output as a separate JSON line
500
+ */
501
+ export function formatStream(data) {
502
+ // Extract array of records from various response shapes
503
+ let records = [];
504
+ if (Array.isArray(data)) {
505
+ records = data;
506
+ } else if (data?.data && Array.isArray(data.data)) {
507
+ records = data.data;
508
+ } else if (data?.results && Array.isArray(data.results)) {
509
+ records = data.results;
510
+ } else if (data?.data?.results && Array.isArray(data.data.results)) {
511
+ records = data.data.results;
512
+ } else if (typeof data === 'object' && data !== null) {
513
+ // Single object - output as single line
514
+ records = [data];
515
+ }
516
+
517
+ if (records.length === 0) {
518
+ return '';
519
+ }
520
+
521
+ // Output each record as a separate JSON line
522
+ return records.map(record => JSON.stringify(record)).join('\n');
523
+ }
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
+
414
551
  // Parse simple sort syntax: "field:direction" or "field" (defaults to DESC)
415
552
  export function parseSort(sortOption, orderByOption) {
416
553
  // If --order-by is provided, use it (full JSON control)
@@ -427,6 +564,202 @@ export function parseSort(sortOption, orderByOption) {
427
564
  return [{ field, direction }];
428
565
  }
429
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
+
430
763
  // Help text
431
764
  export const HELP = `
432
765
  Nansen CLI - Command-line interface for Nansen API
@@ -441,7 +774,7 @@ COMMANDS:
441
774
  schema Output JSON schema for all commands (for agent introspection)
442
775
  cache Cache management (clear)
443
776
  smart-money Smart Money analytics (netflow, dex-trades, holdings, dcas, historical-holdings)
444
- profiler Wallet profiling (balance, labels, transactions, pnl, perp-positions, perp-trades)
777
+ profiler Wallet profiling (balance, labels, pnl, batch, trace, compare, counterparties)
445
778
  token Token God Mode (screener, holders, flows, trades, pnl, perp-trades, perp-positions)
446
779
  portfolio Portfolio analytics (defi-holdings)
447
780
  help Show this help message
@@ -463,6 +796,8 @@ GLOBAL OPTIONS:
463
796
  --cache Enable response caching (default: off)
464
797
  --no-cache Disable cache for this request
465
798
  --cache-ttl <s> Cache TTL in seconds (default: 300)
799
+ --stream Output as JSON lines (NDJSON) for incremental processing
800
+ --format csv Output as CSV with header row
466
801
 
467
802
  EXAMPLES:
468
803
  # Get Smart Money netflow on Solana
@@ -481,7 +816,7 @@ EXAMPLES:
481
816
  nansen profiler search --query "Vitalik"
482
817
 
483
818
  # Get token holders with filters
484
- nansen token holders --token 0x123... --filters '{"only_smart_money":true}'
819
+ nansen token holders --token 0x123... --smart-money
485
820
 
486
821
  SMART MONEY LABELS:
487
822
  Fund, Smart Trader, 30D Smart Trader, 90D Smart Trader,
@@ -489,8 +824,8 @@ SMART MONEY LABELS:
489
824
 
490
825
  SUPPORTED CHAINS:
491
826
  ethereum, solana, base, bnb, arbitrum, polygon, optimism,
492
- avalanche, linea, scroll, zksync, mantle, ronin, sei,
493
- plasma, sonic, unichain, monad, hyperevm, iotaevm
827
+ avalanche, linea, scroll, mantle, ronin, sei,
828
+ plasma, sonic, monad, hyperevm, iotaevm
494
829
 
495
830
  For more info: https://docs.nansen.ai
496
831
  `;
@@ -704,17 +1039,57 @@ export function buildCommands(deps = {}) {
704
1039
  const handlers = {
705
1040
  'balance': () => apiInstance.addressBalance({ address, entityName, chain, filters, orderBy }),
706
1041
  'labels': () => apiInstance.addressLabels({ address, chain, pagination }),
707
- '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
+ },
708
1046
  'pnl': () => apiInstance.addressPnl({ address, chain }),
709
- 'search': () => apiInstance.entitySearch({ query: options.query, pagination }),
1047
+ 'search': () => apiInstance.entitySearch({ query: options.query }),
710
1048
  'historical-balances': () => apiInstance.addressHistoricalBalances({ address, chain, filters, orderBy, pagination, days }),
711
- 'related-wallets': () => apiInstance.addressRelatedWallets({ address, chain, filters, orderBy, pagination }),
1049
+ 'related-wallets': () => apiInstance.addressRelatedWallets({ address, chain, orderBy, pagination }),
712
1050
  'counterparties': () => apiInstance.addressCounterparties({ address, chain, filters, orderBy, pagination, days }),
713
- 'pnl-summary': () => apiInstance.addressPnlSummary({ address, chain, filters, orderBy, pagination, days }),
1051
+ 'pnl-summary': () => apiInstance.addressPnlSummary({ address, chain, orderBy, pagination, days }),
714
1052
  'perp-positions': () => apiInstance.addressPerpPositions({ address, filters, orderBy, pagination }),
715
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
+ },
716
1091
  'help': () => ({
717
- 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'],
718
1093
  description: 'Wallet profiling endpoints',
719
1094
  example: 'nansen profiler balance --address 0x123... --chain ethereum'
720
1095
  })
@@ -742,18 +1117,30 @@ export function buildCommands(deps = {}) {
742
1117
  // Convenience filter for smart money only
743
1118
  const onlySmartMoney = options['smart-money'] || flags['smart-money'] || false;
744
1119
  if (onlySmartMoney) {
745
- 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'];
746
1122
  }
747
1123
 
748
1124
  const handlers = {
749
1125
  'screener': () => apiInstance.tokenScreener({ chains, timeframe, filters, orderBy, pagination }),
750
- 'holders': () => apiInstance.tokenHolders({ tokenAddress, chain, filters, orderBy, pagination }),
751
- '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
+ },
752
1131
  'dex-trades': () => apiInstance.tokenDexTrades({ tokenAddress, chain, onlySmartMoney, filters, orderBy, pagination, days }),
753
1132
  'pnl': () => apiInstance.tokenPnlLeaderboard({ tokenAddress, chain, filters, orderBy, pagination, days }),
754
- 'who-bought-sold': () => apiInstance.tokenWhoBoughtSold({ tokenAddress, chain, filters, orderBy, pagination }),
755
- 'flow-intelligence': () => apiInstance.tokenFlowIntelligence({ tokenAddress, chain, filters, orderBy, pagination }),
756
- '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
+ },
757
1144
  'jup-dca': () => apiInstance.tokenJupDca({ tokenAddress, filters, orderBy, pagination }),
758
1145
  'perp-trades': () => apiInstance.tokenPerpTrades({ tokenSymbol, filters, orderBy, pagination, days }),
759
1146
  'perp-positions': () => apiInstance.tokenPerpPositions({ tokenSymbol, filters, orderBy, pagination }),
@@ -769,7 +1156,14 @@ export function buildCommands(deps = {}) {
769
1156
  return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
770
1157
  }
771
1158
 
772
- 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;
773
1167
  },
774
1168
 
775
1169
  'portfolio': async (args, apiInstance, flags, options) => {
@@ -809,16 +1203,29 @@ export async function runCLI(rawArgs, deps = {}) {
809
1203
  } = deps;
810
1204
 
811
1205
  const { _: positional, flags, options } = parseArgs(rawArgs);
812
-
1206
+
813
1207
  const command = positional[0] || 'help';
814
1208
  const subArgs = positional.slice(1);
815
1209
  const pretty = flags.pretty || flags.p;
816
1210
  const table = flags.table || flags.t;
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); };
817
1218
 
818
1219
  const commands = { ...buildCommands(deps), ...commandOverrides };
819
1220
 
1221
+ if (flags.version || flags.v) {
1222
+ output(VERSION);
1223
+ return { type: 'version', data: VERSION };
1224
+ }
1225
+
820
1226
  if (command === 'help' || flags.help || flags.h) {
821
- output(HELP);
1227
+ output(BANNER + HELP);
1228
+ notify();
822
1229
  return { type: 'help' };
823
1230
  }
824
1231
 
@@ -829,6 +1236,7 @@ export async function runCLI(rawArgs, deps = {}) {
829
1236
  };
830
1237
  const formatted = formatOutput(errorData, { pretty, table });
831
1238
  output(formatted.text);
1239
+ notify();
832
1240
  exit(1);
833
1241
  return { type: 'error', data: errorData };
834
1242
  }
@@ -841,9 +1249,11 @@ export async function runCLI(rawArgs, deps = {}) {
841
1249
  if (command === 'schema' && result) {
842
1250
  const formatted = formatOutput(result, { pretty, table: false });
843
1251
  output(formatted.text);
1252
+ notify();
844
1253
  return { type: 'schema', data: result };
845
1254
  }
846
-
1255
+
1256
+ notify();
847
1257
  return { type: 'no-auth', command };
848
1258
  }
849
1259
 
@@ -868,14 +1278,27 @@ export async function runCLI(rawArgs, deps = {}) {
868
1278
  result = filterFields(result, fields);
869
1279
  }
870
1280
 
1281
+ // Output in requested format
1282
+ if (stream) {
1283
+ // Stream mode: output each record as a JSON line (NDJSON)
1284
+ const streamOutput = formatStream(result);
1285
+ if (streamOutput) {
1286
+ output(streamOutput);
1287
+ }
1288
+ notify();
1289
+ return { type: 'stream', data: result };
1290
+ }
1291
+
871
1292
  const successData = { success: true, data: result };
872
- const formatted = formatOutput(successData, { pretty, table });
1293
+ const formatted = formatOutput(successData, { pretty, table, csv });
873
1294
  output(formatted.text);
874
- return { type: 'success', data: result };
1295
+ notify();
1296
+ return { type: csv ? 'csv' : 'success', data: result };
875
1297
  } catch (error) {
876
1298
  const errorData = formatError(error);
877
- const formatted = formatOutput(errorData, { pretty, table });
1299
+ const formatted = formatOutput(errorData, { pretty, table, csv });
878
1300
  errorOutput(formatted.text);
1301
+ notify();
879
1302
  exit(1);
880
1303
  return { type: 'error', data: errorData };
881
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
+ }