nansen-cli 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.js CHANGED
@@ -9,6 +9,102 @@ import { fileURLToPath } from 'url';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
 
12
+ // ============= Error Codes =============
13
+
14
+ /**
15
+ * Structured error codes for programmatic handling by AI agents
16
+ */
17
+ export const ErrorCode = {
18
+ // Authentication & Authorization
19
+ UNAUTHORIZED: 'UNAUTHORIZED', // 401 - Invalid or missing API key
20
+ FORBIDDEN: 'FORBIDDEN', // 403 - Valid key but insufficient permissions
21
+
22
+ // Rate Limiting
23
+ RATE_LIMITED: 'RATE_LIMITED', // 429 - Too many requests
24
+
25
+ // Validation Errors
26
+ INVALID_ADDRESS: 'INVALID_ADDRESS', // Address format validation failed
27
+ INVALID_TOKEN: 'INVALID_TOKEN', // Token address validation failed
28
+ INVALID_CHAIN: 'INVALID_CHAIN', // Unsupported or invalid chain
29
+ INVALID_PARAMS: 'INVALID_PARAMS', // Generic parameter validation error
30
+ MISSING_PARAM: 'MISSING_PARAM', // Required parameter not provided
31
+
32
+ // Resource Errors
33
+ NOT_FOUND: 'NOT_FOUND', // 404 - Resource not found
34
+ TOKEN_NOT_FOUND: 'TOKEN_NOT_FOUND', // Token doesn't exist
35
+ ADDRESS_NOT_FOUND: 'ADDRESS_NOT_FOUND', // Address has no data
36
+
37
+ // Server Errors
38
+ SERVER_ERROR: 'SERVER_ERROR', // 500+ - Nansen API internal error
39
+ SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', // 503 - API temporarily down
40
+
41
+ // Client Errors
42
+ NETWORK_ERROR: 'NETWORK_ERROR', // Connection failed
43
+ TIMEOUT: 'TIMEOUT', // Request timed out
44
+
45
+ // Generic
46
+ UNKNOWN: 'UNKNOWN', // Unclassified error
47
+ };
48
+
49
+ /**
50
+ * Custom error class with structured error codes
51
+ */
52
+ export class NansenError extends Error {
53
+ constructor(message, code = ErrorCode.UNKNOWN, status = null, data = null) {
54
+ super(message);
55
+ this.name = 'NansenError';
56
+ this.code = code;
57
+ this.status = status;
58
+ this.data = data;
59
+ }
60
+
61
+ toJSON() {
62
+ return {
63
+ error: this.message,
64
+ code: this.code,
65
+ status: this.status,
66
+ details: this.data,
67
+ };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Map HTTP status codes to error codes
73
+ */
74
+ function statusToErrorCode(status, data = {}) {
75
+ const message = data?.message || data?.error || '';
76
+ const messageLower = message.toLowerCase();
77
+
78
+ switch (status) {
79
+ case 400:
80
+ if (messageLower.includes('address')) return ErrorCode.INVALID_ADDRESS;
81
+ if (messageLower.includes('token')) return ErrorCode.INVALID_TOKEN;
82
+ if (messageLower.includes('chain')) return ErrorCode.INVALID_CHAIN;
83
+ return ErrorCode.INVALID_PARAMS;
84
+ case 401:
85
+ return ErrorCode.UNAUTHORIZED;
86
+ case 403:
87
+ return ErrorCode.FORBIDDEN;
88
+ case 404:
89
+ if (messageLower.includes('token')) return ErrorCode.TOKEN_NOT_FOUND;
90
+ if (messageLower.includes('address') || messageLower.includes('wallet')) return ErrorCode.ADDRESS_NOT_FOUND;
91
+ return ErrorCode.NOT_FOUND;
92
+ case 429:
93
+ return ErrorCode.RATE_LIMITED;
94
+ case 500:
95
+ case 502:
96
+ return ErrorCode.SERVER_ERROR;
97
+ case 503:
98
+ return ErrorCode.SERVICE_UNAVAILABLE;
99
+ case 504:
100
+ return ErrorCode.TIMEOUT;
101
+ default:
102
+ if (status >= 500) return ErrorCode.SERVER_ERROR;
103
+ if (status >= 400) return ErrorCode.INVALID_PARAMS;
104
+ return ErrorCode.UNKNOWN;
105
+ }
106
+ }
107
+
12
108
  // ============= Config Paths =============
13
109
 
14
110
  const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
@@ -70,26 +166,26 @@ const EVM_CHAINS = [
70
166
  * Validate address format for a given chain
71
167
  * @param {string} address - The address to validate
72
168
  * @param {string} chain - The blockchain (ethereum, solana, etc.)
73
- * @returns {{valid: boolean, error?: string}}
169
+ * @returns {{valid: boolean, error?: string, code?: string}}
74
170
  */
75
171
  export function validateAddress(address, chain = 'ethereum') {
76
172
  if (!address || typeof address !== 'string') {
77
- return { valid: false, error: 'Address is required' };
173
+ return { valid: false, error: 'Address is required', code: ErrorCode.MISSING_PARAM };
78
174
  }
79
175
 
80
176
  const trimmed = address.trim();
81
177
 
82
178
  if (EVM_CHAINS.includes(chain)) {
83
179
  if (!ADDRESS_PATTERNS.evm.test(trimmed)) {
84
- return { valid: false, error: `Invalid EVM address format. Expected 0x followed by 40 hex characters.` };
180
+ return { valid: false, error: `Invalid EVM address format. Expected 0x followed by 40 hex characters.`, code: ErrorCode.INVALID_ADDRESS };
85
181
  }
86
182
  } else if (chain === 'solana') {
87
183
  if (!ADDRESS_PATTERNS.solana.test(trimmed)) {
88
- return { valid: false, error: `Invalid Solana address format. Expected Base58 string (32-44 chars).` };
184
+ return { valid: false, error: `Invalid Solana address format. Expected Base58 string (32-44 chars).`, code: ErrorCode.INVALID_ADDRESS };
89
185
  }
90
186
  } else if (chain === 'bitcoin') {
91
187
  if (!ADDRESS_PATTERNS.bitcoin.test(trimmed)) {
92
- return { valid: false, error: `Invalid Bitcoin address format.` };
188
+ return { valid: false, error: `Invalid Bitcoin address format.`, code: ErrorCode.INVALID_ADDRESS };
93
189
  }
94
190
  }
95
191
  // For unknown chains, allow any non-empty string (API will validate)
@@ -139,38 +235,159 @@ function loadConfig() {
139
235
 
140
236
  const config = loadConfig();
141
237
 
238
+ // ============= Retry Configuration =============
239
+
240
+ const DEFAULT_RETRY_OPTIONS = {
241
+ maxRetries: 3,
242
+ baseDelayMs: 1000,
243
+ maxDelayMs: 30000,
244
+ retryOnStatus: [429, 500, 502, 503, 504],
245
+ };
246
+
247
+ /**
248
+ * Sleep for a given number of milliseconds
249
+ */
250
+ function sleep(ms) {
251
+ return new Promise(resolve => setTimeout(resolve, ms));
252
+ }
253
+
254
+ /**
255
+ * Calculate delay with exponential backoff and jitter
256
+ */
257
+ function calculateBackoff(attempt, baseDelayMs, maxDelayMs, retryAfterMs = null) {
258
+ // If server specifies retry-after, use it (with some jitter)
259
+ if (retryAfterMs) {
260
+ const jitter = Math.random() * 1000;
261
+ return Math.min(retryAfterMs + jitter, maxDelayMs);
262
+ }
263
+
264
+ // Exponential backoff: base * 2^attempt + random jitter
265
+ const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
266
+ const jitter = Math.random() * baseDelayMs;
267
+ return Math.min(exponentialDelay + jitter, maxDelayMs);
268
+ }
269
+
270
+ /**
271
+ * Parse retry-after header (supports seconds or HTTP date)
272
+ */
273
+ function parseRetryAfter(headerValue) {
274
+ if (!headerValue) return null;
275
+
276
+ // Try parsing as seconds
277
+ const seconds = parseInt(headerValue, 10);
278
+ if (!isNaN(seconds)) {
279
+ return seconds * 1000;
280
+ }
281
+
282
+ // Try parsing as HTTP date
283
+ const date = new Date(headerValue);
284
+ if (!isNaN(date.getTime())) {
285
+ return Math.max(0, date.getTime() - Date.now());
286
+ }
287
+
288
+ return null;
289
+ }
290
+
142
291
  export class NansenAPI {
143
- constructor(apiKey = config.apiKey, baseUrl = config.baseUrl) {
292
+ constructor(apiKey = config.apiKey, baseUrl = config.baseUrl, options = {}) {
144
293
  if (!apiKey) {
145
- throw new Error('API key required. Run `nansen login` or set NANSEN_API_KEY environment variable.');
294
+ throw new NansenError(
295
+ 'API key required. Run `nansen login` or set NANSEN_API_KEY environment variable.',
296
+ ErrorCode.UNAUTHORIZED
297
+ );
146
298
  }
147
299
  this.apiKey = apiKey;
148
300
  this.baseUrl = baseUrl;
301
+ this.retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options.retry };
149
302
  }
150
303
 
151
304
  async request(endpoint, body = {}, options = {}) {
152
305
  const url = `${this.baseUrl}${endpoint}`;
306
+ const { maxRetries, baseDelayMs, maxDelayMs, retryOnStatus } = this.retryOptions;
307
+ const shouldRetry = options.retry !== false; // Allow disabling retry per-request
153
308
 
154
- const response = await fetch(url, {
155
- method: 'POST',
156
- headers: {
157
- 'Content-Type': 'application/json',
158
- 'apikey': this.apiKey,
159
- ...options.headers
160
- },
161
- body: JSON.stringify(body)
162
- });
163
-
164
- const data = await response.json();
165
-
166
- if (!response.ok) {
167
- const error = new Error(data.message || data.error || `API error: ${response.status}`);
168
- error.status = response.status;
169
- error.data = data;
170
- throw error;
309
+ let lastError;
310
+
311
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
312
+ let response;
313
+ try {
314
+ response = await fetch(url, {
315
+ method: 'POST',
316
+ headers: {
317
+ 'Content-Type': 'application/json',
318
+ 'apikey': this.apiKey,
319
+ ...options.headers
320
+ },
321
+ body: JSON.stringify(body)
322
+ });
323
+ } catch (err) {
324
+ // Network-level errors - retry these too
325
+ lastError = new NansenError(
326
+ `Network error: ${err.message}`,
327
+ ErrorCode.NETWORK_ERROR,
328
+ null,
329
+ { originalError: err.message, attempt: attempt + 1 }
330
+ );
331
+
332
+ if (shouldRetry && attempt < maxRetries) {
333
+ const delayMs = calculateBackoff(attempt, baseDelayMs, maxDelayMs);
334
+ await sleep(delayMs);
335
+ continue;
336
+ }
337
+ throw lastError;
338
+ }
339
+
340
+ let data;
341
+ try {
342
+ data = await response.json();
343
+ } catch (err) {
344
+ // Non-JSON response (rare, usually server errors)
345
+ const error = new NansenError(
346
+ `Invalid response from API (status ${response.status})`,
347
+ response.status >= 500 ? ErrorCode.SERVER_ERROR : ErrorCode.UNKNOWN,
348
+ response.status,
349
+ { body: await response.text().catch(() => null), attempt: attempt + 1 }
350
+ );
351
+
352
+ if (shouldRetry && attempt < maxRetries && response.status >= 500) {
353
+ const delayMs = calculateBackoff(attempt, baseDelayMs, maxDelayMs);
354
+ await sleep(delayMs);
355
+ lastError = error;
356
+ continue;
357
+ }
358
+ throw error;
359
+ }
360
+
361
+ if (!response.ok) {
362
+ const message = data.message || data.error || `API error: ${response.status}`;
363
+ const code = statusToErrorCode(response.status, data);
364
+ const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
365
+
366
+ lastError = new NansenError(message, code, response.status, {
367
+ ...data,
368
+ attempt: attempt + 1,
369
+ retryAfterMs
370
+ });
371
+
372
+ // Retry on specific status codes
373
+ if (shouldRetry && attempt < maxRetries && retryOnStatus.includes(response.status)) {
374
+ const delayMs = calculateBackoff(attempt, baseDelayMs, maxDelayMs, retryAfterMs);
375
+ await sleep(delayMs);
376
+ continue;
377
+ }
378
+
379
+ throw lastError;
380
+ }
381
+
382
+ // Success - add retry metadata if we retried
383
+ if (attempt > 0) {
384
+ data._meta = { ...(data._meta || {}), retriedAttempts: attempt };
385
+ }
386
+ return data;
171
387
  }
172
-
173
- return data;
388
+
389
+ // Should not reach here, but just in case
390
+ throw lastError;
174
391
  }
175
392
 
176
393
  // ============= Smart Money Endpoints =============
@@ -242,7 +459,7 @@ export class NansenAPI {
242
459
  const { address, entityName, chain = 'ethereum', hideSpamToken = true, filters = {}, orderBy } = params;
243
460
  if (address) {
244
461
  const validation = validateAddress(address, chain);
245
- if (!validation.valid) throw new Error(validation.error);
462
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
246
463
  }
247
464
  return this.request('/api/v1/profiler/address/current-balance', {
248
465
  address,
@@ -258,7 +475,7 @@ export class NansenAPI {
258
475
  const { address, chain = 'ethereum', pagination = { page: 1, recordsPerPage: 100 } } = params;
259
476
  if (address) {
260
477
  const validation = validateAddress(address, chain);
261
- if (!validation.valid) throw new Error(validation.error);
478
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
262
479
  }
263
480
  return this.request('/api/beta/profiler/address/labels', {
264
481
  parameters: { address, chain },
@@ -270,7 +487,7 @@ export class NansenAPI {
270
487
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination } = params;
271
488
  if (address) {
272
489
  const validation = validateAddress(address, chain);
273
- if (!validation.valid) throw new Error(validation.error);
490
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
274
491
  }
275
492
  return this.request('/api/v1/profiler/address/transactions', {
276
493
  address,
@@ -285,7 +502,7 @@ export class NansenAPI {
285
502
  const { address, chain = 'ethereum' } = params;
286
503
  if (address) {
287
504
  const validation = validateAddress(address, chain);
288
- if (!validation.valid) throw new Error(validation.error);
505
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
289
506
  }
290
507
  return this.request('/api/v1/profiler/address/pnl-and-trade-performance', {
291
508
  address,
@@ -305,7 +522,7 @@ export class NansenAPI {
305
522
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
306
523
  if (address) {
307
524
  const validation = validateAddress(address, chain);
308
- if (!validation.valid) throw new Error(validation.error);
525
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
309
526
  }
310
527
  const to = new Date().toISOString().split('T')[0];
311
528
  const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -323,7 +540,7 @@ export class NansenAPI {
323
540
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination } = params;
324
541
  if (address) {
325
542
  const validation = validateAddress(address, chain);
326
- if (!validation.valid) throw new Error(validation.error);
543
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
327
544
  }
328
545
  return this.request('/api/v1/profiler/address/related-wallets', {
329
546
  address,
@@ -338,7 +555,7 @@ export class NansenAPI {
338
555
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
339
556
  if (address) {
340
557
  const validation = validateAddress(address, chain);
341
- if (!validation.valid) throw new Error(validation.error);
558
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
342
559
  }
343
560
  const to = new Date().toISOString().split('T')[0];
344
561
  const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -356,7 +573,7 @@ export class NansenAPI {
356
573
  const { address, chain = 'ethereum', filters = {}, orderBy, pagination, days = 30 } = params;
357
574
  if (address) {
358
575
  const validation = validateAddress(address, chain);
359
- if (!validation.valid) throw new Error(validation.error);
576
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
360
577
  }
361
578
  const to = new Date().toISOString().split('T')[0];
362
579
  const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -411,7 +628,7 @@ export class NansenAPI {
411
628
  const { tokenAddress, chain = 'solana', labelType = 'all_holders', filters = {}, orderBy, pagination } = params;
412
629
  if (tokenAddress) {
413
630
  const validation = validateTokenAddress(tokenAddress, chain);
414
- if (!validation.valid) throw new Error(validation.error);
631
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
415
632
  }
416
633
  return this.request('/api/v1/tgm/holders', {
417
634
  token_address: tokenAddress,
@@ -427,7 +644,7 @@ export class NansenAPI {
427
644
  const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination } = params;
428
645
  if (tokenAddress) {
429
646
  const validation = validateTokenAddress(tokenAddress, chain);
430
- if (!validation.valid) throw new Error(validation.error);
647
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
431
648
  }
432
649
  return this.request('/api/v1/tgm/flows', {
433
650
  token_address: tokenAddress,
@@ -442,7 +659,7 @@ export class NansenAPI {
442
659
  const { tokenAddress, chain = 'solana', onlySmartMoney = false, filters = {}, orderBy, pagination, days = 7 } = params;
443
660
  if (tokenAddress) {
444
661
  const validation = validateTokenAddress(tokenAddress, chain);
445
- if (!validation.valid) throw new Error(validation.error);
662
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
446
663
  }
447
664
  const to = new Date().toISOString().split('T')[0];
448
665
  const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -467,7 +684,7 @@ export class NansenAPI {
467
684
  const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination, days = 30 } = params;
468
685
  if (tokenAddress) {
469
686
  const validation = validateTokenAddress(tokenAddress, chain);
470
- if (!validation.valid) throw new Error(validation.error);
687
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
471
688
  }
472
689
  const to = new Date().toISOString().split('T')[0];
473
690
  const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -485,7 +702,7 @@ export class NansenAPI {
485
702
  const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination } = params;
486
703
  if (tokenAddress) {
487
704
  const validation = validateTokenAddress(tokenAddress, chain);
488
- if (!validation.valid) throw new Error(validation.error);
705
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
489
706
  }
490
707
  return this.request('/api/v1/tgm/who-bought-sold', {
491
708
  token_address: tokenAddress,
@@ -500,7 +717,7 @@ export class NansenAPI {
500
717
  const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination } = params;
501
718
  if (tokenAddress) {
502
719
  const validation = validateTokenAddress(tokenAddress, chain);
503
- if (!validation.valid) throw new Error(validation.error);
720
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
504
721
  }
505
722
  return this.request('/api/v1/tgm/flow-intelligence', {
506
723
  token_address: tokenAddress,
@@ -515,7 +732,7 @@ export class NansenAPI {
515
732
  const { tokenAddress, chain = 'solana', filters = {}, orderBy, pagination, days = 7 } = params;
516
733
  if (tokenAddress) {
517
734
  const validation = validateTokenAddress(tokenAddress, chain);
518
- if (!validation.valid) throw new Error(validation.error);
735
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
519
736
  }
520
737
  const to = new Date().toISOString().split('T')[0];
521
738
  const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
@@ -534,7 +751,7 @@ export class NansenAPI {
534
751
  // JUP DCA is Solana-only
535
752
  if (tokenAddress) {
536
753
  const validation = validateTokenAddress(tokenAddress, 'solana');
537
- if (!validation.valid) throw new Error(validation.error);
754
+ if (!validation.valid) throw new NansenError(validation.error, validation.code);
538
755
  }
539
756
  return this.request('/api/v1/tgm/jup-dca', {
540
757
  token_address: tokenAddress,