nansen-cli 1.16.1 → 1.18.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +2 -1
  3. package/skills/nansen-alerts/SKILL.md +137 -0
  4. package/skills/nansen-alpha-discovery/SKILL.md +43 -0
  5. package/skills/nansen-batch-wallet/SKILL.md +26 -0
  6. package/skills/nansen-cross-chain-flow/SKILL.md +27 -0
  7. package/skills/nansen-dca-watch/SKILL.md +38 -0
  8. package/skills/nansen-defi-exposure/SKILL.md +37 -0
  9. package/skills/nansen-exit-signal/SKILL.md +39 -0
  10. package/skills/nansen-fund-watch/SKILL.md +35 -0
  11. package/skills/nansen-holder-quality/SKILL.md +38 -0
  12. package/skills/nansen-perp-scan/SKILL.md +32 -0
  13. package/skills/nansen-perp-trader/SKILL.md +39 -0
  14. package/skills/nansen-pm-deep-dive/SKILL.md +50 -0
  15. package/skills/nansen-pm-insider-scan/SKILL.md +62 -0
  16. package/skills/nansen-polymarket-trader/SKILL.md +43 -0
  17. package/skills/nansen-portfolio-history/SKILL.md +36 -0
  18. package/skills/nansen-prediction-market/SKILL.md +47 -0
  19. package/skills/nansen-profiler/SKILL.md +98 -0
  20. package/skills/nansen-search/SKILL.md +34 -0
  21. package/skills/nansen-sm-trend/SKILL.md +30 -0
  22. package/skills/nansen-smart-money/SKILL.md +71 -0
  23. package/skills/nansen-token/SKILL.md +90 -0
  24. package/skills/nansen-token-discovery/SKILL.md +54 -0
  25. package/skills/nansen-token-forensics/SKILL.md +40 -0
  26. package/skills/nansen-trade/SKILL.md +100 -0
  27. package/skills/nansen-wallet/SKILL.md +140 -0
  28. package/skills/nansen-wallet-analysis/SKILL.md +45 -0
  29. package/skills/nansen-wallet-attribution/REFERENCE.md +43 -0
  30. package/skills/nansen-wallet-attribution/SKILL.md +46 -0
  31. package/skills/nansen-wallet-migration/SKILL.md +183 -0
  32. package/skills/nansen-web-fetch/SKILL.md +50 -0
  33. package/skills/nansen-web-search/SKILL.md +39 -0
  34. package/src/api.js +144 -73
  35. package/src/cli.js +181 -14
  36. package/src/rpc-urls.js +29 -0
  37. package/src/schema.json +401 -1448
  38. package/src/telemetry.js +237 -0
  39. package/src/trading.js +26 -12
  40. package/src/transfer.js +1 -11
  41. package/src/update-check.js +2 -2
  42. package/src/wallet.js +2 -1
  43. package/src/x402.js +3 -2
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Lightweight CLI telemetry.
3
+ *
4
+ * Sends anonymous usage events so we can understand which commands are used,
5
+ * how long they take, and where errors occur. Events are fire-and-forget —
6
+ * failures are silently ignored and never block the CLI.
7
+ *
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import crypto from 'crypto';
13
+ import os from 'os';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ const { version: cliVersion } = JSON.parse(
19
+ fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
20
+ );
21
+
22
+ const TELEMETRY_URL =
23
+ 'https://bi-data-sources.nansen.ai/events-service-68ifmnpsx2uq7cgab8dw/v2/event';
24
+
25
+ const TIMEOUT_MS = 2000;
26
+
27
+ // ─── opt-out ──────────────────────────────────────────────
28
+
29
+ export const TELEMETRY_DISABLED =
30
+ process.env.DO_NOT_TRACK === '1' || process.env.NANSEN_NO_TELEMETRY === '1';
31
+
32
+ // ─── environment ──────────────────────────────────────────
33
+
34
+ /**
35
+ * Infer prod vs dev from NANSEN_BASE_URL env var.
36
+ * Only engineers pointing at a local/staging API will have this set.
37
+ */
38
+ function getEventSource() {
39
+ const baseUrl = process.env.NANSEN_BASE_URL || '';
40
+ return baseUrl && !baseUrl.includes('api.nansen.ai') ? 'cli_dev' : 'cli_prod';
41
+ }
42
+
43
+ // ─── system info ──────────────────────────────────────────
44
+
45
+ const SYSTEM_NAMES = { Darwin: 'macos', Linux: 'linux', Windows_NT: 'windows' };
46
+
47
+ function getSystemName() {
48
+ return SYSTEM_NAMES[os.type()] || os.type().toLowerCase();
49
+ }
50
+
51
+ // ─── identity ──────────────────────────────────────────────
52
+
53
+ const TELEMETRY_ID_FILE = path.join(
54
+ process.env.HOME || process.env.USERPROFILE || '',
55
+ '.nansen',
56
+ 'telemetry-id'
57
+ );
58
+
59
+ /**
60
+ * Get or create a persistent random anonymous_id stored in ~/.nansen/telemetry-id.
61
+ */
62
+ let _anonymousId;
63
+ export function getAnonymousId() {
64
+ if (_anonymousId === undefined) {
65
+ try {
66
+ _anonymousId = fs.readFileSync(TELEMETRY_ID_FILE, 'utf8').trim();
67
+ } catch {
68
+ _anonymousId = crypto.randomUUID();
69
+ try {
70
+ fs.mkdirSync(path.dirname(TELEMETRY_ID_FILE), { recursive: true });
71
+ fs.writeFileSync(TELEMETRY_ID_FILE, _anonymousId, 'utf8');
72
+ } catch { /* best-effort persist */ }
73
+ }
74
+ }
75
+ return _anonymousId;
76
+ }
77
+
78
+ // ─── session ───────────────────────────────────────────────
79
+
80
+ const SESSION_FILE = path.join(
81
+ process.env.HOME || process.env.USERPROFILE || '',
82
+ '.nansen',
83
+ 'session'
84
+ );
85
+
86
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
87
+
88
+ /**
89
+ * Get or create a session ID. The session rotates after 30 min of inactivity.
90
+ * Callers can override via NANSEN_SESSION_ID env var.
91
+ */
92
+ let _sessionId;
93
+ export function getSessionId() {
94
+ if (_sessionId !== undefined) return _sessionId;
95
+
96
+ if (process.env.NANSEN_SESSION_ID) {
97
+ _sessionId = process.env.NANSEN_SESSION_ID;
98
+ return _sessionId;
99
+ }
100
+
101
+ const now = Date.now();
102
+ try {
103
+ const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
104
+ if (raw.id && raw.ts && now - raw.ts < SESSION_TIMEOUT_MS) {
105
+ _sessionId = raw.id;
106
+ // touch timestamp, but only if >1 min elapsed to reduce writes
107
+ if (now - raw.ts > 60_000) {
108
+ try { fs.writeFileSync(SESSION_FILE, JSON.stringify({ id: _sessionId, ts: now }), 'utf8'); } catch { /* best-effort touch */ }
109
+ }
110
+ return _sessionId;
111
+ }
112
+ } catch { /* missing or corrupt → new session */ }
113
+
114
+ _sessionId = crypto.randomUUID();
115
+ try {
116
+ fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true });
117
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({ id: _sessionId, ts: now }), 'utf8');
118
+ } catch { /* best-effort */ }
119
+ return _sessionId;
120
+ }
121
+
122
+ // ─── send ──────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Send a telemetry event. Fire-and-forget — never throws.
126
+ */
127
+ function sendEvent(event) {
128
+ if (TELEMETRY_DISABLED) return;
129
+ const controller = new AbortController();
130
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
131
+ timer.unref();
132
+
133
+ fetch(TELEMETRY_URL, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify(event),
137
+ signal: controller.signal,
138
+ })
139
+ .catch(() => {}) // swallow errors
140
+ .finally(() => clearTimeout(timer));
141
+ }
142
+
143
+ // ─── context ───────────────────────────────────────────────
144
+
145
+ function buildContext() {
146
+ return {
147
+ client_type: 'nansen-cli',
148
+ client_version: cliVersion,
149
+ system_name: getSystemName(),
150
+ system_version: os.release(),
151
+ node_version: process.version,
152
+ };
153
+ }
154
+
155
+ // ─── public API ────────────────────────────────────────────
156
+
157
+ /**
158
+ * Convert a command string like "smart-money netflow" to a path like "/smart-money/netflow".
159
+ */
160
+ function commandToPath(command) {
161
+ return '/' + command.replace(/\s+/g, '/');
162
+ }
163
+
164
+ /**
165
+ * Track a CLI command that completed successfully.
166
+ *
167
+ * @param {object} opts
168
+ * @param {string} opts.command - Full command string, e.g. "smart-money netflow"
169
+ * @param {number} opts.duration_ms - Wall-clock execution time
170
+ * @param {boolean} [opts.from_cache] - Whether result was served from cache
171
+ * @param {string[]} [opts.flags] - Flag names used (no values), e.g. ["--chain", "--pretty"]
172
+ * @param {string|null} [opts.chain] - Chain name if specified, e.g. "ethereum", "solana"
173
+ */
174
+ export function trackCommandSucceeded({
175
+ command,
176
+ duration_ms,
177
+ from_cache = false,
178
+ flags = [],
179
+ chain = null,
180
+ }) {
181
+ sendEvent({
182
+ event: 'cli_command_succeeded',
183
+ event_source: getEventSource(),
184
+ event_id: crypto.randomUUID(),
185
+ user_id: null,
186
+ anonymous_id: getAnonymousId(),
187
+ session_id: getSessionId(),
188
+ timestamp: new Date().toISOString(),
189
+ path: commandToPath(command),
190
+ properties: {
191
+ latency: duration_ms / 1000,
192
+ from_cache,
193
+ flags,
194
+ ...(chain ? { chain } : {}),
195
+ },
196
+ context: buildContext(),
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Track a CLI command that failed.
202
+ *
203
+ * @param {object} opts
204
+ * @param {string} opts.command - Full command string
205
+ * @param {number} opts.duration_ms - Wall-clock execution time
206
+ * @param {string} opts.error_code - Structured error code (from ErrorCode or custom)
207
+ * @param {number|null} [opts.status] - HTTP status if the error came from the API
208
+ * @param {string[]} [opts.flags] - Flag names used
209
+ * @param {string|null} [opts.chain] - Chain name if specified
210
+ */
211
+ export function trackCommandFailed({
212
+ command,
213
+ duration_ms,
214
+ error_code,
215
+ status = null,
216
+ flags = [],
217
+ chain = null,
218
+ }) {
219
+ sendEvent({
220
+ event: 'cli_command_failed',
221
+ event_source: getEventSource(),
222
+ event_id: crypto.randomUUID(),
223
+ user_id: null,
224
+ anonymous_id: getAnonymousId(),
225
+ session_id: getSessionId(),
226
+ timestamp: new Date().toISOString(),
227
+ path: commandToPath(command),
228
+ properties: {
229
+ latency: duration_ms / 1000,
230
+ error_code,
231
+ status,
232
+ flags,
233
+ ...(chain ? { chain } : {}),
234
+ },
235
+ context: buildContext(),
236
+ });
237
+ }
package/src/trading.js CHANGED
@@ -13,6 +13,7 @@ import { base58Decode } from './transfer.js';
13
13
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
14
14
  import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
15
15
  import { retrievePassword } from './keychain.js';
16
+ import { CHAIN_RPCS } from './rpc-urls.js';
16
17
 
17
18
  // ============= Constants =============
18
19
 
@@ -63,21 +64,17 @@ export function resolveTokenAddress(symbolOrAddress, chainName) {
63
64
  return resolved || symbolOrAddress;
64
65
  }
65
66
 
66
- // Default public RPC endpoints (used for nonce fetching)
67
- const EVM_RPC_URLS = {
68
- base: process.env.NANSEN_RPC_BASE || 'https://mainnet.base.org',
69
- };
70
-
71
67
  /**
72
68
  * Make a JSON-RPC call to an EVM RPC endpoint.
73
- * @param {string} chain - Chain name (key into EVM_RPC_URLS)
69
+ * RPC URLs come from the shared CHAIN_RPCS registry in rpc-urls.js.
70
+ * @param {string} chain - Chain name (key into CHAIN_RPCS)
74
71
  * @param {string} method - JSON-RPC method name
75
72
  * @param {Array} params - Method parameters
76
73
  * @returns {Promise<*>} Parsed result value
77
74
  * @throws {Error} If chain has no configured RPC or the RPC returns an error
78
75
  */
79
76
  async function evmRpcCall(chain, method, params = []) {
80
- const rpcUrl = EVM_RPC_URLS[chain];
77
+ const rpcUrl = CHAIN_RPCS[chain];
81
78
  if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
82
79
  const res = await fetch(rpcUrl, {
83
80
  method: 'POST',
@@ -406,7 +403,7 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs =
406
403
  * Returns { success: true } or { success: false, reason: string }.
407
404
  */
408
405
  export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
409
- if (!EVM_RPC_URLS[chain]) return { success: true }; // Can't simulate, skip
406
+ if (!CHAIN_RPCS[chain]) return { success: true }; // Can't simulate, skip
410
407
 
411
408
  try {
412
409
  const callObj = { from, to, data, value: value || '0x0' };
@@ -417,7 +414,24 @@ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
417
414
  const msg = e.message || 'unknown';
418
415
  // Only block on actual contract-level revert errors from the RPC
419
416
  if (msg.startsWith('RPC error (eth_call):')) {
420
- return { success: false, reason: msg.replace(/^RPC error \(eth_call\): /, '') };
417
+ const rawReason = msg.replace(/^RPC error \(eth_call\): /, '');
418
+ // Convert raw "insufficient funds" RPC errors (amounts in wei) into a human-readable message.
419
+ // EVM nodes emit: "insufficient funds for gas * price + value: address 0x... have X want Y (supplied gas Z)"
420
+ // TODO: full fix would be a pre-flight eth_getBalance check at quote-fetch time so the error
421
+ // surfaces before simulation with an estimated ETH requirement — see PR for this fix.
422
+ const m = rawReason.match(/insufficient funds[\s\S]*?\bhave (\d+)\s+want (\d+)/i);
423
+ if (m) {
424
+ const haveWei = BigInt(m[1]);
425
+ const wantWei = BigInt(m[2]);
426
+ const haveEth = (Number(haveWei) / 1e18).toFixed(6);
427
+ const wantEth = (Number(wantWei) / 1e18).toFixed(6);
428
+ const fundHint = from ? ` Send ETH to ${from} before trading.` : '';
429
+ return {
430
+ success: false,
431
+ reason: `Insufficient ETH: wallet has ${haveEth} ETH but this trade needs ~${wantEth} ETH (amount + gas).${fundHint}`,
432
+ };
433
+ }
434
+ return { success: false, reason: rawReason };
421
435
  }
422
436
  // Network/infrastructure errors (fetch failure, rate limit, non-JSON response) → non-blocking
423
437
  return { success: true };
@@ -429,7 +443,7 @@ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
429
443
  * Used to fix under-gassed quotes from aggregators.
430
444
  */
431
445
  export async function estimateEvmGas(chain, { from, to, data, value }) {
432
- if (!EVM_RPC_URLS[chain]) return null;
446
+ if (!CHAIN_RPCS[chain]) return null;
433
447
 
434
448
  try {
435
449
  const result = await evmRpcCall(chain, 'eth_estimateGas', [{ from, to, data, value: value || '0x0' }]);
@@ -444,7 +458,7 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
444
458
  * Returns the allowance as a BigInt, or 0n on failure.
445
459
  */
446
460
  export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spenderAddress) {
447
- if (!EVM_RPC_URLS[chain]) return 0n;
461
+ if (!CHAIN_RPCS[chain]) return 0n;
448
462
 
449
463
  try {
450
464
  // allowance(address,address) selector = 0xdd62ed3e
@@ -1123,7 +1137,7 @@ EXAMPLES:
1123
1137
  let finalGas = apiGas > 0 ? apiGas : txGas;
1124
1138
  if (finalGas === 0) {
1125
1139
  try {
1126
- const rpcUrl = EVM_RPC_URLS[chain];
1140
+ const rpcUrl = CHAIN_RPCS[chain];
1127
1141
  const estRes = await fetch(rpcUrl, {
1128
1142
  method: 'POST',
1129
1143
  headers: { 'Content-Type': 'application/json' },
package/src/transfer.js CHANGED
@@ -9,26 +9,16 @@ import { base58Encode, exportWallet, getWalletConfig, verifyPassword, showWallet
9
9
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
10
10
  import { getWalletConnectAddress, sendTransactionViaWalletConnect } from './walletconnect-trading.js';
11
11
  import { EVM_CHAIN_IDS } from './chain-ids.js';
12
+ import { CHAIN_RPCS } from './rpc-urls.js';
12
13
 
13
14
  // ============= Constants =============
14
15
 
15
- const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com';
16
- const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
17
-
18
16
  const PRIORITY_FEE_DEFAULTS = { base: 100000000n, ethereum: 1500000000n, evm: 1500000000n };
19
17
 
20
18
  const ERC20_TRANSFER_SELECTOR = 'a9059cbb'; // transfer(address,uint256)
21
19
  const SYSTEM_PROGRAM = '11111111111111111111111111111111'; // 32 zero bytes in base58
22
20
  const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
23
21
 
24
- // Chain-specific RPC endpoints
25
- const CHAIN_RPCS = {
26
- 'ethereum': process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
27
- 'evm': process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
28
- 'base': process.env.NANSEN_BASE_RPC || 'https://mainnet.base.org',
29
- 'solana': process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC,
30
- };
31
-
32
22
  // Alias: buildEvmTransaction uses 'evm' as a generic fallback
33
23
  const CHAIN_IDS = { ...EVM_CHAIN_IDS, evm: 1 };
34
24
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import { spawn } from 'child_process';
10
+ import childProcess from 'child_process';
11
11
  import { fileURLToPath } from 'url';
12
12
 
13
13
  const __filename = fileURLToPath(import.meta.url);
@@ -119,7 +119,7 @@ export function scheduleUpdateCheck() {
119
119
  req.setTimeout(5000, () => req.destroy());
120
120
  `;
121
121
 
122
- const child = spawn(process.execPath, ['-e', script], {
122
+ const child = childProcess.spawn(process.execPath, ['-e', script], {
123
123
  detached: true,
124
124
  stdio: 'ignore'
125
125
  });
package/src/wallet.js CHANGED
@@ -1067,7 +1067,8 @@ ENVIRONMENT:
1067
1067
  PRIVY_APP_ID Privy application ID (required for --provider privy)
1068
1068
  PRIVY_APP_SECRET Privy application secret (required for --provider privy)
1069
1069
  NANSEN_WALLET_PROVIDER Default provider for wallet create ("local" or "privy")
1070
- NANSEN_EVM_RPC Custom EVM RPC endpoint
1070
+ NANSEN_EVM_RPC Custom Ethereum RPC endpoint (also generic EVM fallback)
1071
+ NANSEN_BASE_RPC Custom Base RPC endpoint
1071
1072
  NANSEN_SOLANA_RPC Custom Solana RPC endpoint
1072
1073
 
1073
1074
  EXAMPLES:
package/src/x402.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getSolanaRpcUrl,
13
13
  } from './x402-svm.js';
14
14
  import { resolvePassword } from './keychain.js';
15
+ import { CHAIN_RPCS } from './rpc-urls.js';
15
16
 
16
17
  /**
17
18
  * Parse PaymentRequirements from a 402 response.
@@ -187,10 +188,10 @@ export async function checkX402Balance(network) {
187
188
  }
188
189
 
189
190
  if (network.startsWith('eip155:')) {
190
- // Base USDC balance check
191
+ // Base USDC balance check — RPC URL from shared registry so NANSEN_BASE_RPC override applies
191
192
  const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
192
193
  const addr = walletInfo.evm.replace('0x', '').toLowerCase().padStart(64, '0');
193
- const resp = await fetch('https://mainnet.base.org', {
194
+ const resp = await fetch(CHAIN_RPCS.base, {
194
195
  method: 'POST',
195
196
  headers: { 'Content-Type': 'application/json' },
196
197
  body: JSON.stringify({