nansen-cli 1.26.0 → 1.27.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/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Extracted from index.js for coverage
4
4
  */
5
5
 
6
- import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js';
6
+ import { NansenAPI, NansenError, CommandError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js';
7
7
  import { buildWalletCommands } from './wallet.js';
8
8
  import { buildTradingCommands } from './trading.js';
9
9
  import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
@@ -793,7 +793,6 @@ export function buildCommands(deps = {}) {
793
793
  saveConfigFn = saveConfig,
794
794
  deleteConfigFn = deleteConfig,
795
795
  getConfigFileFn = getConfigFile,
796
- exit = process.exit,
797
796
  isTTY = process.stdin.isTTY
798
797
  } = deps;
799
798
 
@@ -895,12 +894,10 @@ export function buildCommands(deps = {}) {
895
894
 
896
895
  if (!apiKey && flags.human) {
897
896
  if (!isTTY) {
898
- log(JSON.stringify({
897
+ throw new CommandError('--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.', 'NOT_A_TTY', {
899
898
  error: 'NOT_A_TTY',
900
899
  message: '--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.',
901
- }));
902
- exit(1);
903
- return;
900
+ });
904
901
  }
905
902
  log('Nansen CLI Login\n');
906
903
  log('Get your API key at: https://app.nansen.ai/auth/agent-setup\n');
@@ -908,7 +905,7 @@ export function buildCommands(deps = {}) {
908
905
  }
909
906
 
910
907
  if (!apiKey || apiKey.trim().length === 0) {
911
- log(JSON.stringify({
908
+ throw new CommandError('No API key provided.', 'API_KEY_REQUIRED', {
912
909
  error: 'API_KEY_REQUIRED',
913
910
  message: 'No API key provided.',
914
911
  resolution: [
@@ -916,9 +913,7 @@ export function buildCommands(deps = {}) {
916
913
  'Or set NANSEN_API_KEY environment variable',
917
914
  'Get your API key at: https://app.nansen.ai/auth/agent-setup',
918
915
  ],
919
- }));
920
- exit(1);
921
- return;
916
+ });
922
917
  }
923
918
 
924
919
  // Verify API key before saving
@@ -933,20 +928,17 @@ export function buildCommands(deps = {}) {
933
928
  accountInfo = await testApi.getAccount();
934
929
  } catch (error) {
935
930
  if (error.code === ErrorCode.UNAUTHORIZED) {
936
- log(JSON.stringify({
931
+ throw new CommandError('The API key is not valid.', 'INVALID_API_KEY', {
937
932
  error: 'INVALID_API_KEY',
938
933
  message: 'The API key is not valid.',
939
- resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup']
940
- }));
941
- } else {
942
- log(JSON.stringify({
943
- error: 'VERIFICATION_FAILED',
944
- message: `Could not verify API key: ${error.message}`,
945
- resolution: ['Check your internet connection', 'Try again']
946
- }));
934
+ resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup'],
935
+ });
947
936
  }
948
- exit(1);
949
- return;
937
+ throw new CommandError(`Could not verify API key: ${error.message}`, 'VERIFICATION_FAILED', {
938
+ error: 'VERIFICATION_FAILED',
939
+ message: `Could not verify API key: ${error.message}`,
940
+ resolution: ['Check your internet connection', 'Try again'],
941
+ });
950
942
  }
951
943
 
952
944
  // Key is valid - now save
@@ -1415,23 +1407,40 @@ export function buildCommands(deps = {}) {
1415
1407
  const sortBy = options['sort-by'];
1416
1408
  const query = options.query;
1417
1409
  const status = options.status;
1418
- const sort = parseSort(options.sort);
1410
+ const orderBy = parseSort(options.sort, options['order-by']);
1419
1411
  const pagination = buildPagination(options);
1420
1412
 
1413
+ // Screener-specific filter options
1414
+ const tags = options.tags ? options.tags.split(',').map(t => t.trim()) : undefined;
1415
+ const minLiquidity = options['min-liquidity'] != null ? Number(options['min-liquidity']) : undefined;
1416
+ const maxLiquidity = options['max-liquidity'] != null ? Number(options['max-liquidity']) : undefined;
1417
+ const minUniqueTraders24h = options['min-unique-traders-24h'] != null ? Number(options['min-unique-traders-24h']) : undefined;
1418
+ const maxUniqueTraders24h = options['max-unique-traders-24h'] != null ? Number(options['max-unique-traders-24h']) : undefined;
1419
+ const minVolume24hr = options['min-volume-24hr'] != null ? Number(options['min-volume-24hr']) : undefined;
1420
+ const maxVolume24hr = options['max-volume-24hr'] != null ? Number(options['max-volume-24hr']) : undefined;
1421
+ const negRisk = options['neg-risk'] != null ? options['neg-risk'] === 'true' : undefined;
1422
+ const minOpenInterest = options['min-open-interest'] != null ? Number(options['min-open-interest']) : undefined;
1423
+ const maxOpenInterest = options['max-open-interest'] != null ? Number(options['max-open-interest']) : undefined;
1424
+ const endDateBefore = options['end-date-before'];
1425
+ const endDateAfter = options['end-date-after'];
1426
+ const minPrice = options['min-price'] != null ? Number(options['min-price']) : undefined;
1427
+ const maxPrice = options['max-price'] != null ? Number(options['max-price']) : undefined;
1428
+
1421
1429
  const handlers = {
1422
- 'ohlcv': () => apiInstance.pmOhlcv({ marketId, sort, pagination }),
1430
+ 'ohlcv': () => apiInstance.pmOhlcv({ marketId, orderBy, pagination }),
1423
1431
  'orderbook': () => apiInstance.pmOrderbook({ marketId, pagination }),
1424
- 'top-holders': () => apiInstance.pmTopHolders({ marketId, sort, pagination }),
1425
- 'trades-by-market': () => apiInstance.pmTradesByMarket({ marketId, pagination }),
1426
- 'trades-by-address': () => apiInstance.pmTradesByAddress({ address, pagination }),
1427
- 'market-screener': () => apiInstance.pmMarketScreener({ sortBy, query, status, pagination }),
1428
- 'event-screener': () => apiInstance.pmEventScreener({ sortBy, query, status, pagination }),
1429
- 'pnl-by-market': () => apiInstance.pmPnlByMarket({ marketId, pagination }),
1430
- 'pnl-by-address': () => apiInstance.pmPnlByAddress({ address, pagination }),
1432
+ 'top-holders': () => apiInstance.pmTopHolders({ marketId, orderBy, pagination }),
1433
+ 'trades-by-market': () => apiInstance.pmTradesByMarket({ marketId, orderBy, pagination }),
1434
+ 'trades-by-address': () => apiInstance.pmTradesByAddress({ address, orderBy, pagination }),
1435
+ 'market-screener': () => apiInstance.pmMarketScreener({ orderBy, sortBy, query, status, tags, minLiquidity, maxLiquidity, minUniqueTraders24h, maxUniqueTraders24h, minVolume24hr, maxVolume24hr, negRisk, minOpenInterest, maxOpenInterest, endDateBefore, endDateAfter, minPrice, maxPrice, pagination }),
1436
+ 'event-screener': () => apiInstance.pmEventScreener({ orderBy, sortBy, query, status, tags, minLiquidity, maxLiquidity, minUniqueTraders24h, maxUniqueTraders24h, minVolume24hr, maxVolume24hr, negRisk, minOpenInterest, maxOpenInterest, endDateBefore, endDateAfter, pagination }),
1437
+ 'pnl-by-market': () => apiInstance.pmPnlByMarket({ marketId, orderBy, pagination }),
1438
+ 'pnl-by-address': () => apiInstance.pmPnlByAddress({ address, orderBy, pagination }),
1431
1439
  'position-detail': () => apiInstance.pmPositionDetail({ marketId, pagination }),
1432
1440
  'categories': () => apiInstance.pmCategories({ pagination }),
1441
+ 'address-summary': () => apiInstance.pmAddressSummary({ address, pagination }),
1433
1442
  'help': () => ({
1434
- commands: ['ohlcv', 'orderbook', 'top-holders', 'trades-by-market', 'trades-by-address', 'market-screener', 'event-screener', 'pnl-by-market', 'pnl-by-address', 'position-detail', 'categories'],
1443
+ commands: ['ohlcv', 'orderbook', 'top-holders', 'trades-by-market', 'trades-by-address', 'market-screener', 'event-screener', 'pnl-by-market', 'pnl-by-address', 'position-detail', 'categories', 'address-summary'],
1435
1444
  description: 'Polymarket prediction market analytics',
1436
1445
  example: 'nansen research pm market-screener --sort-by volume_24hr --limit 20'
1437
1446
  })
@@ -1732,7 +1741,7 @@ export async function runCLI(rawArgs, deps = {}) {
1732
1741
  };
1733
1742
  const formatted = formatOutput(errorData, { pretty, table });
1734
1743
  output(formatted.text);
1735
- trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1744
+ await trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1736
1745
  exit(1);
1737
1746
  return { type: 'error', data: errorData };
1738
1747
  }
@@ -1759,7 +1768,7 @@ export async function runCLI(rawArgs, deps = {}) {
1759
1768
 
1760
1769
  // Commands that handle their own output return undefined
1761
1770
  if (result === undefined) {
1762
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1771
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1763
1772
  return { type: 'no-output', command };
1764
1773
  }
1765
1774
 
@@ -1767,7 +1776,7 @@ export async function runCLI(rawArgs, deps = {}) {
1767
1776
  if (command === 'schema') {
1768
1777
  const formatted = formatOutput(result, { pretty, table: false });
1769
1778
  output(formatted.text);
1770
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1779
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1771
1780
  return { type: 'schema', data: result };
1772
1781
  }
1773
1782
 
@@ -1780,6 +1789,7 @@ export async function runCLI(rawArgs, deps = {}) {
1780
1789
  // Alerts list with --table uses custom table format
1781
1790
  if (command === 'alerts' && subcommand === 'list' && table) {
1782
1791
  output(formatAlertsTable(result));
1792
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1783
1793
  return { type: 'success', data: result };
1784
1794
  }
1785
1795
 
@@ -1790,20 +1800,26 @@ export async function runCLI(rawArgs, deps = {}) {
1790
1800
  if (streamOutput) {
1791
1801
  output(streamOutput);
1792
1802
  }
1793
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1803
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1794
1804
  return { type: 'stream', data: result };
1795
1805
  }
1796
1806
 
1797
1807
  const successData = { success: true, data: result };
1798
1808
  const formatted = formatOutput(successData, { pretty, table, csv });
1799
1809
  output(formatted.text);
1800
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1810
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1801
1811
  return { type: csv ? 'csv' : 'success', data: result };
1802
1812
  } catch (error) {
1803
- const errorData = formatError(error);
1804
- const formatted = formatOutput(errorData, { pretty, table, csv });
1805
- output(formatted.text);
1806
- trackCommandFailed({
1813
+ let errorData;
1814
+ if (error instanceof CommandError) {
1815
+ output(error.data ? JSON.stringify(error.data) : error.message);
1816
+ errorData = { error: error.message, code: error.code };
1817
+ } else {
1818
+ errorData = formatError(error);
1819
+ const formatted = formatOutput(errorData, { pretty, table, csv });
1820
+ output(formatted.text);
1821
+ }
1822
+ await trackCommandFailed({
1807
1823
  command: fullCommand,
1808
1824
  duration_ms: Date.now() - startTime,
1809
1825
  error_code: error.code || 'UNKNOWN',
package/src/index.js CHANGED
@@ -14,4 +14,4 @@
14
14
  import { runCLI } from './cli.js';
15
15
 
16
16
  // Main entry point
17
- runCLI(process.argv.slice(2));
17
+ await runCLI(process.argv.slice(2));
package/src/schema.json CHANGED
@@ -588,18 +588,42 @@
588
588
  "endpoint": "/api/v1/prediction-market/market-screener",
589
589
  "description": "Get Prediction Market Screener",
590
590
  "options": {
591
- "query": {
592
- "default": ""
593
- }
591
+ "query": { "default": "" },
592
+ "sort-by": { "description": "Deprecated: use --sort field:dir instead" },
593
+ "tags": {},
594
+ "min-liquidity": {},
595
+ "max-liquidity": {},
596
+ "min-unique-traders-24h": {},
597
+ "max-unique-traders-24h": {},
598
+ "min-volume-24hr": {},
599
+ "max-volume-24hr": {},
600
+ "neg-risk": {},
601
+ "min-open-interest": {},
602
+ "max-open-interest": {},
603
+ "end-date-before": {},
604
+ "end-date-after": {},
605
+ "min-price": {},
606
+ "max-price": {}
594
607
  }
595
608
  },
596
609
  "event-screener": {
597
610
  "endpoint": "/api/v1/prediction-market/event-screener",
598
611
  "description": "Get Prediction Market Event Screener",
599
612
  "options": {
600
- "query": {
601
- "default": ""
602
- }
613
+ "query": { "default": "" },
614
+ "sort-by": { "description": "Deprecated: use --sort field:dir instead" },
615
+ "tags": {},
616
+ "min-liquidity": {},
617
+ "max-liquidity": {},
618
+ "min-unique-traders-24h": {},
619
+ "max-unique-traders-24h": {},
620
+ "min-volume-24hr": {},
621
+ "max-volume-24hr": {},
622
+ "neg-risk": {},
623
+ "min-open-interest": {},
624
+ "max-open-interest": {},
625
+ "end-date-before": {},
626
+ "end-date-after": {}
603
627
  }
604
628
  },
605
629
  "pnl-by-market": {
@@ -641,6 +665,15 @@
641
665
  "categories": {
642
666
  "endpoint": "/api/v1/prediction-market/categories",
643
667
  "description": "Get Prediction Market Categories"
668
+ },
669
+ "address-summary": {
670
+ "endpoint": "/api/v1/prediction-market/address-summary",
671
+ "description": "Get wallet-level PnL summary for a Polymarket address",
672
+ "options": {
673
+ "address": {
674
+ "required": true
675
+ }
676
+ }
644
677
  }
645
678
  },
646
679
  "description": "Polymarket prediction market analytics"
package/src/telemetry.js CHANGED
@@ -22,7 +22,7 @@ const { version: cliVersion } = JSON.parse(
22
22
  const TELEMETRY_URL =
23
23
  'https://bi-data-sources.nansen.ai/events-service-68ifmnpsx2uq7cgab8dw/v2/event';
24
24
 
25
- const TIMEOUT_MS = 2000;
25
+ const TIMEOUT_MS = 1000;
26
26
 
27
27
  // ─── opt-out ──────────────────────────────────────────────
28
28
 
@@ -122,15 +122,17 @@ export function getSessionId() {
122
122
  // ─── send ──────────────────────────────────────────────────
123
123
 
124
124
  /**
125
- * Send a telemetry event. Fire-and-forget never throws.
125
+ * Send a telemetry event. Returns a promise that resolves when the request
126
+ * completes (or fails/times out). Never rejects — errors are swallowed.
127
+ * Callers that need to ensure delivery before process.exit can await this.
126
128
  */
127
129
  function sendEvent(event) {
128
- if (TELEMETRY_DISABLED) return;
130
+ if (TELEMETRY_DISABLED) return Promise.resolve();
129
131
  const controller = new AbortController();
130
132
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
131
133
  timer.unref();
132
134
 
133
- fetch(TELEMETRY_URL, {
135
+ return fetch(TELEMETRY_URL, {
134
136
  method: 'POST',
135
137
  headers: { 'Content-Type': 'application/json' },
136
138
  body: JSON.stringify(event),
@@ -178,7 +180,7 @@ export function trackCommandSucceeded({
178
180
  flags = [],
179
181
  chain = null,
180
182
  }) {
181
- sendEvent({
183
+ return sendEvent({
182
184
  event: 'cli_command_succeeded',
183
185
  event_source: getEventSource(),
184
186
  event_id: crypto.randomUUID(),
@@ -216,7 +218,7 @@ export function trackCommandFailed({
216
218
  flags = [],
217
219
  chain = null,
218
220
  }) {
219
- sendEvent({
221
+ return sendEvent({
220
222
  event: 'cli_command_failed',
221
223
  event_source: getEventSource(),
222
224
  event_id: crypto.randomUUID(),
@@ -61,6 +61,16 @@ export function validateQuoteInput({ chain, toChain, from, to, amount }) {
61
61
  );
62
62
  }
63
63
  }
64
+
65
+ // 5. At least one side must be USDC or the native token
66
+ const fromIsAnchor = isUsdcOrNative(from, normalizedChain);
67
+ const toIsAnchor = isUsdcOrNative(to, normalizedToChain);
68
+ if (!fromIsAnchor && !toIsAnchor) {
69
+ const anchorDesc = normalizedChain === normalizedToChain
70
+ ? `USDC or the native token (${NATIVE_SYMBOLS[normalizedChain] ?? normalizedChain})`
71
+ : `USDC or the native token on either side (${NATIVE_SYMBOLS[normalizedChain] ?? normalizedChain} on ${normalizedChain}, ${NATIVE_SYMBOLS[normalizedToChain] ?? normalizedToChain} on ${normalizedToChain})`;
72
+ throw new Error(`Invalid swap: at least one token must be ${anchorDesc}. Got: ${from} → ${to}.`);
73
+ }
64
74
  }
65
75
 
66
76
  // Native token decimals per chain (for converting balance from base units)
@@ -112,13 +122,35 @@ const NATIVE_TOKEN_ADDRESSES = {
112
122
  base: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
113
123
  };
114
124
 
125
+ // USDC contract addresses per chain.
126
+ const USDC_ADDRESSES = {
127
+ solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
128
+ base: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
129
+ };
130
+
115
131
  // Native token symbols for error messages.
116
132
  const NATIVE_SYMBOLS = { solana: 'SOL', base: 'ETH' };
117
133
 
134
+ const MIN_GAS_AMOUNTS = { solana: 0.01, base: 0.000024 };
118
135
  const FEE_BUFFER = { solana: 0.005, base: 0.00004 };
119
136
  const HIGH_PERCENTAGE_THRESHOLD = 95;
120
137
  const AUTO_ADJUST_THRESHOLD_PERCENT = 2;
121
138
 
139
+ /**
140
+ * Check if an address is USDC or the native token for a chain (case-insensitive for EVM).
141
+ */
142
+ function isUsdcOrNative(address, chain) {
143
+ const usdc = USDC_ADDRESSES[chain];
144
+ const native = NATIVE_TOKEN_ADDRESSES[chain];
145
+ if (!usdc && !native) return false;
146
+ if (chain === 'solana') {
147
+ return address === usdc || address === native;
148
+ }
149
+ // EVM: case-insensitive
150
+ const lower = address.toLowerCase();
151
+ return (usdc && lower === usdc.toLowerCase()) || (native && lower === native.toLowerCase());
152
+ }
153
+
122
154
  /**
123
155
  * Check if an address is the native token for a chain (case-insensitive for EVM).
124
156
  */
@@ -269,6 +301,32 @@ export async function resolvePercentAmount({ chain, from, walletAddress, percent
269
301
  return String(parseFloat(tokenAmount.toFixed(decimals)));
270
302
  }
271
303
 
304
+ /**
305
+ * Validate that the wallet has enough native token for gas fees.
306
+ *
307
+ * Returns { hasSufficientNative } or throws on validation failure.
308
+ * Best-effort: if RPC fails, returns passing result.
309
+ */
310
+ export async function validateGasBalance({ chain, walletAddress }) {
311
+ const normalizedChain = chain.toLowerCase();
312
+ const minGas = MIN_GAS_AMOUNTS[normalizedChain];
313
+ if (minGas === undefined) return { hasSufficientNative: true };
314
+
315
+ const balance = await fetchNativeBalance(normalizedChain, walletAddress);
316
+
317
+ // RPC failure — proceed without validation.
318
+ if (balance === null) return { hasSufficientNative: true };
319
+
320
+ if (balance >= minGas) {
321
+ return { hasSufficientNative: true };
322
+ }
323
+
324
+ const symbol = NATIVE_SYMBOLS[normalizedChain] || 'native token';
325
+ throw new Error(
326
+ `Insufficient ${symbol} for gas fees. Wallet has ${balance} ${symbol} but needs at least ${minGas} ${symbol}. Fund the wallet before trading.`
327
+ );
328
+ }
329
+
272
330
  /**
273
331
  * Fetch an ERC-20 or SPL token balance for a wallet.
274
332
  * Returns balance in human-readable token units, or null on RPC failure.