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.
- package/.changeset/README.md +8 -0
- package/CLAUDE.md +11 -0
- package/README.md +3 -2
- package/SKILL.md +132 -0
- package/TODO.md +0 -11
- package/package.json +7 -2
- package/src/api.js +58 -25
- package/src/cli.js +471 -48
- package/src/update-check.js +95 -0
|
@@ -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
|
[](https://www.npmjs.com/package/nansen-cli)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[]()
|
|
6
|
+
[]()
|
|
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.
|
|
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', '
|
|
249
|
-
'sei', 'plasma', 'sonic', '
|
|
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
|
-
|
|
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
|
|
623
|
-
return this.request('/api/
|
|
624
|
-
|
|
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',
|
|
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',
|
|
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
|
|
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'
|
|
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:
|
|
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: ['
|
|
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: ['
|
|
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', '
|
|
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: ['
|
|
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', '
|
|
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: ['
|
|
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'
|
|
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', '
|
|
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', '
|
|
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: ['
|
|
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: ['
|
|
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: ['
|
|
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: ['
|
|
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' },
|
|
170
|
-
returns: ['
|
|
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', '
|
|
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 (
|
|
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,
|
|
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... --
|
|
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,
|
|
493
|
-
plasma, sonic,
|
|
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': () =>
|
|
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
|
|
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,
|
|
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,
|
|
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.
|
|
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': () =>
|
|
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': () =>
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|