nansen-cli 1.37.0 → 1.39.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/CHANGELOG.md +36 -0
- package/README.md +58 -2
- package/package.json +1 -1
- package/skills/nansen-wallet-batch/SKILL.md +1 -1
- package/skills/nansen-wallet-keychain-migration/SKILL.md +14 -12
- package/skills/nansen-wallet-profiler/SKILL.md +1 -1
- package/src/api.js +4 -3
- package/src/cli.js +55 -13
- package/src/doctor.js +480 -0
- package/src/keychain.js +46 -0
- package/src/response-meta.js +2 -2
- package/src/rpc-urls.js +67 -0
- package/src/schema.json +68 -1
- package/src/swap-simulation.js +477 -0
- package/src/telemetry.js +9 -2
- package/src/trade-validation.js +653 -0
- package/src/trading.js +530 -20
- package/src/update-check.js +2 -1
- package/src/walletconnect-trading.js +11 -7
package/src/doctor.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nansen CLI - Offline diagnostics
|
|
3
|
+
*
|
|
4
|
+
* `nansen auth status` — where credentials come from, without any network call.
|
|
5
|
+
* `nansen doctor` — health checks over ~/.nansen, environment, and wallets.
|
|
6
|
+
*
|
|
7
|
+
* Everything in this module is offline by design: it reads local files, env
|
|
8
|
+
* vars, and (for the wallet password) the OS keychain — never the network.
|
|
9
|
+
* Paths are resolved lazily at call time so tests can point HOME at a temp dir.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { passwordSource } from './keychain.js';
|
|
16
|
+
import { isNewer } from './update-check.js';
|
|
17
|
+
import { isTelemetryDisabled } from './telemetry.js';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
const DEFAULT_BASE_URL = 'https://api.nansen.ai';
|
|
22
|
+
const COST_MAP_STALE_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
// ============= Lazy Paths =============
|
|
25
|
+
|
|
26
|
+
function getHome(env) {
|
|
27
|
+
return env.HOME || env.USERPROFILE || '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getConfigDir(env) {
|
|
31
|
+
return path.join(getHome(env), '.nansen');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getConfigFilePath(env) {
|
|
35
|
+
return path.join(getConfigDir(env), 'config.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getWalletsDir(env) {
|
|
39
|
+
return path.join(getConfigDir(env), 'wallets');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getCredentialsFilePath(env) {
|
|
43
|
+
return path.join(getWalletsDir(env), '.credentials');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============= Local Readers (never throw) =============
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read + parse a JSON file, distinguishing "cannot read it" (permissions, IO)
|
|
50
|
+
* from "read it but it is not JSON" — the two need different diagnostics and
|
|
51
|
+
* different fixes.
|
|
52
|
+
*/
|
|
53
|
+
function readJsonDetailed(filePath) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
return { data: null, error: 'unreadable' };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return { data: JSON.parse(raw), error: null };
|
|
62
|
+
} catch {
|
|
63
|
+
return { data: null, error: 'parse' };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJson(filePath) {
|
|
68
|
+
return readJsonDetailed(filePath).data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mask an API key for display: enough to recognise it, never enough to use it.
|
|
73
|
+
*/
|
|
74
|
+
export function maskKey(key) {
|
|
75
|
+
if (typeof key !== 'string' || key.length === 0) return null;
|
|
76
|
+
// Below 12 chars, first4+last4 would disclose most of the key
|
|
77
|
+
if (key.length < 12) return '****';
|
|
78
|
+
return `${key.slice(0, 4)}…${key.slice(-4)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEV_CONFIG_PATH = path.join(__dirname, '..', 'config.json');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the API key and base URL the way src/api.js loadConfig() does —
|
|
85
|
+
* ~/.nansen/config.json, then the repo-local dev config.json, then env
|
|
86
|
+
* overrides — but lazily and without secrets leaving this function unmasked.
|
|
87
|
+
*/
|
|
88
|
+
function resolveAuthConfig(env, devConfigPath = DEV_CONFIG_PATH) {
|
|
89
|
+
const userConfigPath = getConfigFilePath(env);
|
|
90
|
+
|
|
91
|
+
let config = null;
|
|
92
|
+
let configPath = null;
|
|
93
|
+
let configError = null;
|
|
94
|
+
|
|
95
|
+
if (fs.existsSync(userConfigPath)) {
|
|
96
|
+
const result = readJsonDetailed(userConfigPath);
|
|
97
|
+
config = result.data;
|
|
98
|
+
configPath = userConfigPath;
|
|
99
|
+
configError = result.error;
|
|
100
|
+
}
|
|
101
|
+
if (!config && fs.existsSync(devConfigPath)) {
|
|
102
|
+
config = readJson(devConfigPath);
|
|
103
|
+
if (config) configPath = devConfigPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let apiKey = config?.apiKey || null;
|
|
107
|
+
let apiKeySource = apiKey ? (configPath === devConfigPath ? 'dev-config' : 'config') : null;
|
|
108
|
+
if (env.NANSEN_API_KEY) {
|
|
109
|
+
apiKey = env.NANSEN_API_KEY;
|
|
110
|
+
apiKeySource = 'env';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let baseUrl = config?.baseUrl || DEFAULT_BASE_URL;
|
|
114
|
+
let baseUrlSource = config?.baseUrl ? 'config' : 'default';
|
|
115
|
+
if (env.NANSEN_BASE_URL) {
|
|
116
|
+
baseUrl = env.NANSEN_BASE_URL;
|
|
117
|
+
baseUrlSource = 'env';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
apiKey,
|
|
122
|
+
apiKeySource,
|
|
123
|
+
baseUrl,
|
|
124
|
+
baseUrlSource,
|
|
125
|
+
configPath,
|
|
126
|
+
configFileExists: fs.existsSync(userConfigPath),
|
|
127
|
+
configError,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Enumerate local wallets without side effects — unlike listWallets(), this
|
|
133
|
+
* never creates the wallets directory.
|
|
134
|
+
*/
|
|
135
|
+
function readWallets(env) {
|
|
136
|
+
const dir = getWalletsDir(env);
|
|
137
|
+
const result = {
|
|
138
|
+
dir,
|
|
139
|
+
dirExists: fs.existsSync(dir),
|
|
140
|
+
dirError: null,
|
|
141
|
+
wallets: [],
|
|
142
|
+
defaultWallet: null,
|
|
143
|
+
passwordHashSet: false,
|
|
144
|
+
configError: null,
|
|
145
|
+
};
|
|
146
|
+
if (!result.dirExists) return result;
|
|
147
|
+
|
|
148
|
+
const configFilePath = path.join(dir, 'config.json');
|
|
149
|
+
const walletConfig = fs.existsSync(configFilePath) ? readJsonDetailed(configFilePath) : { data: null, error: null };
|
|
150
|
+
result.configError = walletConfig.error;
|
|
151
|
+
result.defaultWallet = walletConfig.data?.defaultWallet || null;
|
|
152
|
+
result.passwordHashSet = Boolean(walletConfig.data?.passwordHash);
|
|
153
|
+
|
|
154
|
+
let entries = [];
|
|
155
|
+
try { entries = fs.readdirSync(dir); } catch { result.dirError = 'unreadable'; }
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (!entry.endsWith('.json') || entry === 'config.json') continue;
|
|
158
|
+
const wallet = readJsonDetailed(path.join(dir, entry));
|
|
159
|
+
result.wallets.push({
|
|
160
|
+
name: entry.replace(/\.json$/, ''),
|
|
161
|
+
provider: wallet.data?.provider || (wallet.data ? 'local' : null),
|
|
162
|
+
error: wallet.error,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fileMode(filePath) {
|
|
169
|
+
try {
|
|
170
|
+
return fs.statSync(filePath).mode & 0o777;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether an OS keychain is available for wallet password storage, mirroring
|
|
178
|
+
* the platform support in src/keychain.js. Checks tool presence only — never
|
|
179
|
+
* reads or writes an entry.
|
|
180
|
+
*/
|
|
181
|
+
function isKeychainAvailable(platform, env) {
|
|
182
|
+
if (platform === 'darwin') return fs.existsSync('/usr/bin/security');
|
|
183
|
+
if (platform === 'linux') {
|
|
184
|
+
return (env.PATH || '').split(path.delimiter).some(dir => {
|
|
185
|
+
try { return dir && fs.existsSync(path.join(dir, 'secret-tool')); } catch { return false; }
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return false; // Windows: keychain.js always falls back to the .credentials file
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isInsecureMode(mode) {
|
|
192
|
+
return mode !== null && (mode & 0o077) !== 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============= auth status =============
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Offline authentication status. Reads config files, env vars, and the wallet
|
|
199
|
+
* store; makes no network calls. Returns a data object (rendered as JSON by
|
|
200
|
+
* the CLI layer, like `account`).
|
|
201
|
+
*/
|
|
202
|
+
export function getAuthStatus(deps = {}) {
|
|
203
|
+
const {
|
|
204
|
+
env = process.env,
|
|
205
|
+
passwordSourceFn = passwordSource,
|
|
206
|
+
devConfigPath = DEV_CONFIG_PATH,
|
|
207
|
+
platform = process.platform,
|
|
208
|
+
} = deps;
|
|
209
|
+
|
|
210
|
+
const auth = resolveAuthConfig(env, devConfigPath);
|
|
211
|
+
const walletInfo = readWallets(env);
|
|
212
|
+
const pwSource = passwordSourceFn();
|
|
213
|
+
const defaultEntry = walletInfo.wallets.find(w => w.name === walletInfo.defaultWallet) || null;
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
logged_in: Boolean(auth.apiKey),
|
|
217
|
+
api_key: {
|
|
218
|
+
present: Boolean(auth.apiKey),
|
|
219
|
+
source: auth.apiKeySource,
|
|
220
|
+
masked: maskKey(auth.apiKey),
|
|
221
|
+
},
|
|
222
|
+
config_file: {
|
|
223
|
+
path: getConfigFilePath(env),
|
|
224
|
+
exists: auth.configFileExists,
|
|
225
|
+
// 'parse' (corrupt JSON) | 'unreadable' (permissions/IO) | null — without
|
|
226
|
+
// this, a broken config file is indistinguishable from "not logged in"
|
|
227
|
+
error: auth.configError,
|
|
228
|
+
},
|
|
229
|
+
base_url: {
|
|
230
|
+
value: auth.baseUrl,
|
|
231
|
+
source: auth.baseUrlSource,
|
|
232
|
+
},
|
|
233
|
+
x402: {
|
|
234
|
+
configured: walletInfo.wallets.length > 0,
|
|
235
|
+
wallets_dir: walletInfo.dir,
|
|
236
|
+
wallets_dir_error: walletInfo.dirError,
|
|
237
|
+
wallet_count: walletInfo.wallets.length,
|
|
238
|
+
default_wallet: walletInfo.defaultWallet,
|
|
239
|
+
default_wallet_provider: defaultEntry?.provider || null,
|
|
240
|
+
password: {
|
|
241
|
+
available: pwSource !== null,
|
|
242
|
+
source: pwSource,
|
|
243
|
+
keychain_available: isKeychainAvailable(platform, env),
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
offline: true,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============= doctor =============
|
|
251
|
+
|
|
252
|
+
function check(id, status, message, fix = null) {
|
|
253
|
+
const result = { id, status, message };
|
|
254
|
+
if (fix) result.fix = fix;
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseEngineMajor(requirement) {
|
|
259
|
+
const match = /(\d+)/.exec(requirement || '');
|
|
260
|
+
return match ? parseInt(match[1], 10) : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Run all offline diagnostics. Returns an array of
|
|
265
|
+
* { id, status: 'ok'|'warn'|'error'|'info', message, fix? } checks.
|
|
266
|
+
*/
|
|
267
|
+
export function runDoctorChecks(deps = {}) {
|
|
268
|
+
const {
|
|
269
|
+
env = process.env,
|
|
270
|
+
passwordSourceFn = passwordSource,
|
|
271
|
+
nodeVersion = process.version,
|
|
272
|
+
cliVersion = null,
|
|
273
|
+
engines = null,
|
|
274
|
+
devConfigPath = DEV_CONFIG_PATH,
|
|
275
|
+
platform = process.platform,
|
|
276
|
+
} = deps;
|
|
277
|
+
|
|
278
|
+
const checks = [];
|
|
279
|
+
const auth = resolveAuthConfig(env, devConfigPath);
|
|
280
|
+
|
|
281
|
+
// --- environment ---
|
|
282
|
+
const requiredMajor = parseEngineMajor(engines?.node) ?? 20;
|
|
283
|
+
const currentMajor = parseInt(nodeVersion.replace(/^v/, ''), 10);
|
|
284
|
+
checks.push(currentMajor >= requiredMajor
|
|
285
|
+
? check('node-version', 'ok', `Node ${nodeVersion} (>= ${requiredMajor} required)`)
|
|
286
|
+
: check('node-version', 'error', `Node ${nodeVersion} is below the required major version ${requiredMajor}`, `Install Node >= ${requiredMajor}: https://nodejs.org`));
|
|
287
|
+
|
|
288
|
+
// Report the URL the CLI will actually use — a config file can set a
|
|
289
|
+
// non-default base URL too, not just the env var
|
|
290
|
+
if (auth.baseUrlSource === 'env') {
|
|
291
|
+
checks.push(check('base-url', 'warn', `NANSEN_BASE_URL override active: ${auth.baseUrl}`, `Unset NANSEN_BASE_URL to use ${DEFAULT_BASE_URL}`));
|
|
292
|
+
} else if (auth.baseUrl !== DEFAULT_BASE_URL) {
|
|
293
|
+
checks.push(check('base-url', 'warn', `Non-default API base URL in ${auth.configPath}: ${auth.baseUrl}`, 'Run: nansen login (re-saves the default)'));
|
|
294
|
+
} else {
|
|
295
|
+
checks.push(check('base-url', 'ok', `API base URL: ${auth.baseUrl}`));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- auth ---
|
|
299
|
+
if (auth.configError === 'unreadable') {
|
|
300
|
+
checks.push(check('config-file', 'error', `${getConfigFilePath(env)} exists but cannot be read — permission problem?`, `Check ownership and mode: ls -l "${getConfigFilePath(env)}"`));
|
|
301
|
+
} else if (auth.configError === 'parse') {
|
|
302
|
+
checks.push(check('config-file', 'error', `${getConfigFilePath(env)} exists but is not valid JSON`, 'Run: nansen login (re-saves the file)'));
|
|
303
|
+
}
|
|
304
|
+
if (auth.apiKey) {
|
|
305
|
+
const sourceLabel = auth.apiKeySource === 'env'
|
|
306
|
+
? 'NANSEN_API_KEY env var'
|
|
307
|
+
: `config file ${auth.configPath}`;
|
|
308
|
+
checks.push(check('api-key', 'ok', `API key found (${maskKey(auth.apiKey)}, source: ${sourceLabel})`));
|
|
309
|
+
if (auth.apiKeySource === 'env' && auth.configFileExists && !auth.configError) {
|
|
310
|
+
const fileKey = readJson(getConfigFilePath(env))?.apiKey;
|
|
311
|
+
if (fileKey && fileKey !== auth.apiKey) {
|
|
312
|
+
checks.push(check('api-key-shadow', 'warn', 'NANSEN_API_KEY env var overrides a different key saved in the config file'));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
checks.push(check('api-key', auth.configFileExists ? 'error' : 'warn', 'No API key configured', 'Run: nansen login --api-key <key> (or fund an x402 wallet for pay-per-call access)'));
|
|
317
|
+
}
|
|
318
|
+
// POSIX modes are meaningless on Windows — fs.stat reports 0o666 for every
|
|
319
|
+
// file there, which would warn on all of them
|
|
320
|
+
const posixModes = platform !== 'win32';
|
|
321
|
+
if (posixModes) {
|
|
322
|
+
const configMode = fileMode(getConfigFilePath(env));
|
|
323
|
+
if (isInsecureMode(configMode)) {
|
|
324
|
+
checks.push(check('config-perms', 'warn', `${getConfigFilePath(env)} has insecure permissions (${configMode.toString(8)})`, `Run: chmod 600 "${getConfigFilePath(env)}"`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- wallets / x402 ---
|
|
329
|
+
const keychainAvailable = isKeychainAvailable(platform, env);
|
|
330
|
+
checks.push(keychainAvailable
|
|
331
|
+
? check('keychain', 'ok', 'OS keychain available for wallet password storage')
|
|
332
|
+
: check('keychain', 'info', 'No OS keychain on this platform — wallet passwords fall back to the .credentials file'));
|
|
333
|
+
|
|
334
|
+
const walletInfo = readWallets(env);
|
|
335
|
+
if (walletInfo.configError) {
|
|
336
|
+
const problem = walletInfo.configError === 'unreadable' ? 'cannot be read — permission problem?' : 'is not valid JSON';
|
|
337
|
+
checks.push(check('wallet-config', 'error', `${path.join(walletInfo.dir, 'config.json')} exists but ${problem}`));
|
|
338
|
+
}
|
|
339
|
+
if (walletInfo.dirError) {
|
|
340
|
+
checks.push(check('wallets', 'error', `${walletInfo.dir} exists but cannot be read — permission problem?`, `Check ownership and mode: ls -ld "${walletInfo.dir}"`));
|
|
341
|
+
} else if (!walletInfo.dirExists || walletInfo.wallets.length === 0) {
|
|
342
|
+
checks.push(check('wallets', 'info', 'No local wallets (only needed for trading and x402 micropayments)', 'Run: nansen wallet create <name>'));
|
|
343
|
+
} else {
|
|
344
|
+
const defaultNote = walletInfo.defaultWallet ? `default: ${walletInfo.defaultWallet}` : 'no default set';
|
|
345
|
+
checks.push(check('wallets', 'ok', `${walletInfo.wallets.length} wallet${walletInfo.wallets.length === 1 ? '' : 's'} in ${walletInfo.dir} (${defaultNote})`));
|
|
346
|
+
if (walletInfo.defaultWallet && !walletInfo.wallets.some(w => w.name === walletInfo.defaultWallet)) {
|
|
347
|
+
checks.push(check('default-wallet', 'error', `Default wallet "${walletInfo.defaultWallet}" has no wallet file`, 'Run: nansen wallet default <name>'));
|
|
348
|
+
}
|
|
349
|
+
for (const w of walletInfo.wallets.filter(w => w.error)) {
|
|
350
|
+
const problem = w.error === 'unreadable' ? 'cannot be read — permission problem?' : 'is not valid JSON';
|
|
351
|
+
checks.push(check('wallet-file', 'error', `Wallet file ${path.join(walletInfo.dir, `${w.name}.json`)} ${problem}`));
|
|
352
|
+
}
|
|
353
|
+
if (posixModes) {
|
|
354
|
+
const walletsDirMode = fileMode(walletInfo.dir);
|
|
355
|
+
if (isInsecureMode(walletsDirMode)) {
|
|
356
|
+
checks.push(check('wallets-perms', 'warn', `${walletInfo.dir} has insecure permissions (${walletsDirMode.toString(8)})`, `Run: chmod 700 "${walletInfo.dir}"`));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Password storage — only relevant once wallets exist. Metadata-only:
|
|
361
|
+
// the secret itself is never retrieved.
|
|
362
|
+
const pwSource = passwordSourceFn();
|
|
363
|
+
if (pwSource === 'file') {
|
|
364
|
+
checks.push(check('wallet-password', 'warn', 'Wallet password stored in the insecure .credentials file', 'Run: nansen wallet secure (migrates it to the OS keychain)'));
|
|
365
|
+
} else if (pwSource) {
|
|
366
|
+
checks.push(check('wallet-password', 'ok', `Wallet password available (source: ${pwSource})`));
|
|
367
|
+
} else if (walletInfo.passwordHashSet) {
|
|
368
|
+
checks.push(check('wallet-password', 'warn', 'Wallets are password-protected but no password is stored — x402 payments and trading will fail non-interactively', 'Set NANSEN_WALLET_PASSWORD, or store it: nansen wallet secure'));
|
|
369
|
+
}
|
|
370
|
+
if (pwSource !== 'file' && fs.existsSync(getCredentialsFilePath(env))) {
|
|
371
|
+
checks.push(check('stale-credentials', 'warn', `${getCredentialsFilePath(env)} still exists but is not the active password source`, `Delete it: rm "${getCredentialsFilePath(env)}"`));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Privy wallets need API credentials from the environment
|
|
375
|
+
const hasPrivyWallet = walletInfo.wallets.some(w => w.provider === 'privy');
|
|
376
|
+
if (hasPrivyWallet && (!env.PRIVY_APP_ID || !env.PRIVY_APP_SECRET)) {
|
|
377
|
+
checks.push(check('privy-env', 'warn', 'Privy wallet present but PRIVY_APP_ID / PRIVY_APP_SECRET are not set', 'Export both env vars to use Privy wallets'));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- caches ---
|
|
382
|
+
const cacheDir = path.join(getConfigDir(env), 'cache');
|
|
383
|
+
let cacheCount = 0;
|
|
384
|
+
try { cacheCount = fs.readdirSync(cacheDir).filter(f => f.endsWith('.json')).length; } catch { /* missing dir */ }
|
|
385
|
+
checks.push(check('response-cache', 'info', `Response cache: ${cacheCount} entr${cacheCount === 1 ? 'y' : 'ies'} (${cacheDir})`, cacheCount > 0 ? 'Clear with: nansen cache clear' : null));
|
|
386
|
+
|
|
387
|
+
const costMap = readJson(path.join(getConfigDir(env), 'cost-map.json'));
|
|
388
|
+
if (costMap?.fetchedAt) {
|
|
389
|
+
// Clamp: a future fetchedAt (clock skew, corrupt cache) is not "fetched -5h ago"
|
|
390
|
+
const ageMs = Math.max(0, Date.now() - costMap.fetchedAt);
|
|
391
|
+
const fresh = ageMs < COST_MAP_STALE_MS;
|
|
392
|
+
checks.push(check('cost-map', 'info', `Credit cost map: ${fresh ? 'fresh' : 'stale'} (fetched ${Math.round(ageMs / 3600000)}h ago)`));
|
|
393
|
+
} else {
|
|
394
|
+
checks.push(check('cost-map', 'info', 'Credit cost map not cached yet (refreshes on next nansen help)'));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (cliVersion) {
|
|
398
|
+
const updateCache = readJson(path.join(getConfigDir(env), 'update-check.json'));
|
|
399
|
+
if (updateCache?.latest) {
|
|
400
|
+
checks.push(isNewer(updateCache.latest, cliVersion)
|
|
401
|
+
? check('cli-version', 'warn', `Update available: ${cliVersion} → ${updateCache.latest}`, 'Run: npm i -g nansen-cli')
|
|
402
|
+
: check('cli-version', 'ok', `nansen-cli ${cliVersion} is up to date`));
|
|
403
|
+
} else {
|
|
404
|
+
checks.push(check('cli-version', 'info', `nansen-cli ${cliVersion} (no update-check cache yet)`));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const loAuth = readJson(path.join(getConfigDir(env), 'limit-order-auth.json'));
|
|
409
|
+
if (loAuth?.expiresAt) {
|
|
410
|
+
const valid = loAuth.expiresAt > Date.now() + 300_000;
|
|
411
|
+
checks.push(check('limit-order-jwt', 'info', `Limit-order session: ${valid ? 'valid' : 'expired'} (re-authenticates automatically)`));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// --- telemetry ---
|
|
415
|
+
checks.push(check('telemetry', 'info', isTelemetryDisabled(env) ? 'Telemetry disabled' : 'Anonymous telemetry enabled (disable: DO_NOT_TRACK=1)'));
|
|
416
|
+
|
|
417
|
+
return checks;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Safe connectivity check: an unauthenticated GET against the configured API
|
|
422
|
+
* base URL. No API key is sent and no credits are consumed. Any HTTP response
|
|
423
|
+
* proves reachability; only a network failure or timeout is a problem — so
|
|
424
|
+
* `doctor` stays useful for diagnosing exactly the "API is unavailable" case.
|
|
425
|
+
*/
|
|
426
|
+
export async function runConnectivityChecks(deps = {}) {
|
|
427
|
+
const {
|
|
428
|
+
env = process.env,
|
|
429
|
+
fetchFn = fetch,
|
|
430
|
+
timeoutMs = 5000,
|
|
431
|
+
devConfigPath = DEV_CONFIG_PATH,
|
|
432
|
+
} = deps;
|
|
433
|
+
|
|
434
|
+
const { baseUrl } = resolveAuthConfig(env, devConfigPath);
|
|
435
|
+
const started = Date.now();
|
|
436
|
+
try {
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
439
|
+
let response;
|
|
440
|
+
try {
|
|
441
|
+
response = await fetchFn(`${baseUrl}/openapi.json`, { method: 'GET', signal: controller.signal });
|
|
442
|
+
} finally {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
}
|
|
445
|
+
const ms = Date.now() - started;
|
|
446
|
+
return [check('api-reachable', 'ok', `API reachable: ${baseUrl} (HTTP ${response.status}, ${ms}ms)`)];
|
|
447
|
+
} catch (error) {
|
|
448
|
+
const reason = error.name === 'AbortError'
|
|
449
|
+
? `timed out after ${timeoutMs}ms`
|
|
450
|
+
: (error.cause?.code || error.message);
|
|
451
|
+
return [check('api-reachable', 'error', `API unreachable: ${baseUrl} (${reason})`, 'Check your network/proxy. The offline checks above remain valid.')];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const STATUS_ICONS = { ok: '✓', warn: '⚠️ ', error: '❌', info: 'ℹ' };
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Render doctor checks as human-readable lines with a summary tail.
|
|
459
|
+
*/
|
|
460
|
+
export function formatDoctorReport(checks, { cliVersion = null, offline = false } = {}) {
|
|
461
|
+
const lines = [];
|
|
462
|
+
const mode = offline
|
|
463
|
+
? 'offline diagnostics (no network calls)'
|
|
464
|
+
: 'diagnostics (local checks + a credit-free connectivity probe; --offline to skip network)';
|
|
465
|
+
lines.push(`Nansen CLI doctor${cliVersion ? ` v${cliVersion}` : ''} — ${mode}`);
|
|
466
|
+
lines.push('');
|
|
467
|
+
for (const c of checks) {
|
|
468
|
+
lines.push(`${STATUS_ICONS[c.status] || ' '} ${c.message}`);
|
|
469
|
+
if (c.fix) lines.push(` ${c.fix}`);
|
|
470
|
+
}
|
|
471
|
+
const warnings = checks.filter(c => c.status === 'warn').length;
|
|
472
|
+
const errors = checks.filter(c => c.status === 'error').length;
|
|
473
|
+
lines.push('');
|
|
474
|
+
if (warnings === 0 && errors === 0) {
|
|
475
|
+
lines.push('No problems found.');
|
|
476
|
+
} else {
|
|
477
|
+
lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} found.`);
|
|
478
|
+
}
|
|
479
|
+
return lines.join('\n');
|
|
480
|
+
}
|
package/src/keychain.js
CHANGED
|
@@ -169,6 +169,52 @@ function credentialsFileDelete() {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// ============= Metadata-Only Checks =============
|
|
173
|
+
|
|
174
|
+
function keychainHasEntry() {
|
|
175
|
+
try {
|
|
176
|
+
if (process.platform === 'darwin') {
|
|
177
|
+
// Without -w this prints attributes only — the secret never leaves the keychain
|
|
178
|
+
execFileSync('/usr/bin/security', [
|
|
179
|
+
'find-generic-password',
|
|
180
|
+
'-s', SERVICE,
|
|
181
|
+
'-a', ACCOUNT,
|
|
182
|
+
], { timeout: TIMEOUT_MS, stdio: 'pipe' });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (process.platform === 'linux') {
|
|
187
|
+
// `search` prints attributes, unlike `lookup` which prints the secret
|
|
188
|
+
const result = execFileSync('secret-tool', [
|
|
189
|
+
'search',
|
|
190
|
+
'service', SERVICE,
|
|
191
|
+
'account', ACCOUNT,
|
|
192
|
+
], { timeout: TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
193
|
+
return result.toString().trim().length > 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return false;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Where a wallet password is stored, without ever materializing the secret:
|
|
204
|
+
* env presence, keychain attribute search, and a regex test on the
|
|
205
|
+
* credentials file (the base64 value is never decoded).
|
|
206
|
+
* @returns {'env'|'keychain'|'file'|null}
|
|
207
|
+
*/
|
|
208
|
+
export function passwordSource() {
|
|
209
|
+
if (process.env.NANSEN_WALLET_PASSWORD) return 'env';
|
|
210
|
+
if (keychainHasEntry()) return 'keychain';
|
|
211
|
+
try {
|
|
212
|
+
const content = fs.readFileSync(getCredentialsPath(), 'utf8');
|
|
213
|
+
if (/^NANSEN_WALLET_PASSWORD(_B64)?=.+$/m.test(content)) return 'file';
|
|
214
|
+
} catch { /* missing or unreadable */ }
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
172
218
|
// ============= Public API =============
|
|
173
219
|
|
|
174
220
|
/**
|
package/src/response-meta.js
CHANGED
|
@@ -118,12 +118,12 @@ export function creditWarning(meta) {
|
|
|
118
118
|
const { used, remaining, cost } = credits;
|
|
119
119
|
if (remaining === null) return null;
|
|
120
120
|
if (remaining === 0) {
|
|
121
|
-
return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api';
|
|
121
|
+
return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api?tab=api';
|
|
122
122
|
}
|
|
123
123
|
// The cost header is the authoritative charge; used is the fallback.
|
|
124
124
|
const charged = cost ?? used;
|
|
125
125
|
if (charged !== null && charged > 0 && remaining < charged) {
|
|
126
|
-
return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${charged}). Top up at https://app.nansen.ai/api`;
|
|
126
|
+
return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${charged}). Top up at https://app.nansen.ai/api?tab=api`;
|
|
127
127
|
}
|
|
128
128
|
return null;
|
|
129
129
|
}
|
package/src/rpc-urls.js
CHANGED
|
@@ -11,6 +11,23 @@
|
|
|
11
11
|
* NANSEN_BSC_RPC Custom BNB Smart Chain RPC
|
|
12
12
|
* NANSEN_XLAYER_RPC Custom X Layer RPC
|
|
13
13
|
* NANSEN_SOLANA_RPC Custom Solana RPC
|
|
14
|
+
* NANSEN_BASE_SIM_RPC Custom Base simulation RPC (see SIMULATION_RPCS below)
|
|
15
|
+
*
|
|
16
|
+
* Simulation RPCs (SIMULATION_RPCS) are a SEPARATE registry from the cheap
|
|
17
|
+
* defaults above. Swap-outcome verification (src/swap-simulation.js) needs an
|
|
18
|
+
* endpoint that supports state-changing simulation with asset-transfer tracing
|
|
19
|
+
* (`eth_simulateV1` / `debug_traceCall`), which the free public defaults in
|
|
20
|
+
* CHAIN_RPCS deliberately DISABLE. Keeping the two registries apart means only
|
|
21
|
+
* the (pricey) simulation calls hit the trace-capable endpoint; ordinary reads
|
|
22
|
+
* (nonce, balance, allowance, eth_call revert check) stay on the cheap default.
|
|
23
|
+
*
|
|
24
|
+
* The shipped simulation endpoint is a Nansen-hosted service authenticated with
|
|
25
|
+
* the user's existing Nansen API key (no secret in this public package): the
|
|
26
|
+
* trace-capable upstream is reached server-side, so the baked default carries no
|
|
27
|
+
* credential. With no NANSEN_BASE_SIM_RPC override, swap-outcome verification
|
|
28
|
+
* uses this default; if the service is ever unreachable it degrades with a
|
|
29
|
+
* warning rather than blocking the trade. To use your own endpoint (or for local
|
|
30
|
+
* dev/e2e), point NANSEN_BASE_SIM_RPC at any trace-capable RPC in a gitignored .env.
|
|
14
31
|
*
|
|
15
32
|
* Backward-compat aliases (deprecated — prefer the forms above):
|
|
16
33
|
* NANSEN_RPC_BASE Old name for NANSEN_BASE_RPC; trading.js previously read this
|
|
@@ -40,3 +57,53 @@ export const CHAIN_RPCS = {
|
|
|
40
57
|
polygon: process.env.NANSEN_POLYGON_RPC || DEFAULT_POLYGON_RPC,
|
|
41
58
|
bnb: process.env.NANSEN_BNB_RPC || DEFAULT_BNB_RPC,
|
|
42
59
|
};
|
|
60
|
+
|
|
61
|
+
// Zero-config default for the shipped Nansen-hosted simulation endpoint. It
|
|
62
|
+
// authenticates with the user's existing Nansen API key (attached automatically
|
|
63
|
+
// by swap-simulation.js), and the trace-capable upstream is reached server-side —
|
|
64
|
+
// so this URL carries no secret and is safe to bake into a public package. Never
|
|
65
|
+
// embed an RPC URL that carries an inline token here; any embedded secret would
|
|
66
|
+
// leak on publish.
|
|
67
|
+
const DEFAULT_BASE_SIM_RPC = 'https://api.nansen.ai/api/v1/trade/simulate-swap';
|
|
68
|
+
|
|
69
|
+
// Separate registry for swap-outcome simulation (src/swap-simulation.js). These
|
|
70
|
+
// endpoints must support state-changing simulation with asset-transfer tracing
|
|
71
|
+
// (`eth_simulateV1` / `debug_traceCall`), which the CHAIN_RPCS public defaults
|
|
72
|
+
// disable. Only outcome verification reads this registry; every other RPC call
|
|
73
|
+
// stays on the cheap CHAIN_RPCS default. A null entry (no baked default and no
|
|
74
|
+
// override) signals "no sim-capable endpoint" to the caller, which degrades.
|
|
75
|
+
//
|
|
76
|
+
// Intentionally a mutable export: unit tests override an entry in-place (e.g.
|
|
77
|
+
// `SIMULATION_RPCS.base = ...`) to point at a mock or to null out the endpoint,
|
|
78
|
+
// restoring it in afterEach. Runtime code only ever reads it.
|
|
79
|
+
export const SIMULATION_RPCS = {
|
|
80
|
+
base: process.env.NANSEN_BASE_SIM_RPC || DEFAULT_BASE_SIM_RPC,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Nansen hosts the API key may be forwarded to. Kept to an explicit allowlist
|
|
84
|
+
// (not a `*.nansen.ai` wildcard): the key only ever authenticates the sim proxy
|
|
85
|
+
// on api.nansen.ai, and a wildcard would forward it to any subdomain that
|
|
86
|
+
// resolves — including a misconfigured or compromised one. Add new sim hosts
|
|
87
|
+
// here deliberately if one is ever introduced.
|
|
88
|
+
const NANSEN_HOSTED_SIM_HOSTS = new Set(['api.nansen.ai']);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether a simulation URL is a Nansen-hosted endpoint that may receive the
|
|
92
|
+
* user's Nansen API key. The key authenticates the shipped default proxy
|
|
93
|
+
* (DEFAULT_BASE_SIM_RPC); a NANSEN_BASE_SIM_RPC override can point at ANY host
|
|
94
|
+
* (dev node, third-party trace RPC), and forwarding the credential there would
|
|
95
|
+
* leak it. So the key is attached ONLY when this returns true — every other
|
|
96
|
+
* endpoint is called anonymously.
|
|
97
|
+
*
|
|
98
|
+
* Trust is: https + hostname is one of NANSEN_HOSTED_SIM_HOSTS. Anything else
|
|
99
|
+
* (http, other host, unparseable) is untrusted and gets no key.
|
|
100
|
+
*/
|
|
101
|
+
export function isNansenHostedUrl(url) {
|
|
102
|
+
try {
|
|
103
|
+
const u = new URL(url);
|
|
104
|
+
if (u.protocol !== 'https:') return false;
|
|
105
|
+
return NANSEN_HOSTED_SIM_HOSTS.has(u.hostname.toLowerCase());
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|