nansen-cli 1.37.0 → 1.38.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.38.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#486](https://github.com/nansen-ai/nansen-cli/pull/486) [`b752d81`](https://github.com/nansen-ai/nansen-cli/commit/b752d81336a5d9bda3ad85e62a4d42d98c069e58) Thanks [@gulshngill](https://github.com/gulshngill)! - Add `nansen auth status` and `nansen doctor`. `auth status` is fully offline: it reports whether an API key is configured and where it comes from (env var vs config file, masked), the active base URL, x402 wallet readiness, and OS keychain availability. `doctor` runs health checks over the whole setup — Node version, config file validity and permissions, wallet storage and password hygiene (flags the insecure `.credentials` file), keychain availability, Privy env credentials, caches, and telemetry — with an actionable fix per finding, plus a safe unauthenticated connectivity probe (no credits consumed; skip it with `--offline`). `--json` returns machine-readable checks.
8
+
9
+ - [#494](https://github.com/nansen-ai/nansen-cli/pull/494) [`67027e6`](https://github.com/nansen-ai/nansen-cli/commit/67027e6a0faefcc797ec9407f199bc985dbbfc56) Thanks [@kome12](https://github.com/kome12)! - Harden EVM swap signing: scope ERC-20 approvals to the trade amount instead of granting an unlimited allowance, and validate the swap target before signing (reject an empty/zero address, a non-contract target, or a target equal to the token being sold). As a result, ERC-20 sells on Base now include a per-swap approval transaction. Native ETH swaps and all Solana swaps are unaffected. Note: this scopes approvals granted from now on; a pre-existing unlimited approval from an earlier version is not automatically reduced.
10
+
11
+ Also tightens the input validation on the quote a swap is signed from. Every approval-signing path (local, Privy, WalletConnect) now shares one encoder that requires a well-formed 20-byte spender, keeps the approved amount bounded (never unlimited) and within the request cap, and produces fixed-width approval calldata. EVM execution now requires complete request intent persisted by the quote command and revalidates each quote against it (chain, wallet, token pair, mode, and amount), so the signed transaction remains bound to what was requested. A same-chain swap whose transaction is a bare ERC-20 transfer/approve rather than a routed swap is refused (bridge routes excluded).
12
+
13
+ The swap-target contract check now fails closed: it retries and, if it still can't confirm the target carries contract code, refuses to sign rather than proceeding on an unverified target.
14
+
15
+ EVM (Base) exactOut swaps now require an explicit maximum input (spend ceiling) via `--max-input` in base units of the sell token. The quote persists that `maxInputAmount`, and the execute path refuses to sign, approve, or broadcast any quote whose input exceeds it, for native and ERC-20 swaps across all three EVM signing paths. Solana exactOut is unaffected and does not require the flag (there is no ERC-20 approval to scope on that path). Quotes already above the cap are dropped at quote time (and, when none fit, a clear `MAX_INPUT_EXCEEDED` error is returned) rather than saved and rejected only at execute. Relatedly, a quote missing a field the request-intent check needs (sell/buy token address or the bound amount) is now rejected rather than skipped, and the exactOut output binding accepts more-than-requested output (only a shortfall is rejected, since the input is independently capped).
16
+
17
+ The execute path also binds the signer to the wallet the quote was built for: it now refuses to sign a quote whose persisted wallet doesn't match the current signer (e.g. the default wallet changed between quote and execute), since the quoted transaction is constructed for a specific sender.
18
+
19
+ ### Patch Changes
20
+
21
+ - [#494](https://github.com/nansen-ai/nansen-cli/pull/494) [`54b9d41`](https://github.com/nansen-ai/nansen-cli/commit/54b9d41fc9f99cd68bce416b95a129fe9e858981) Thanks [@kome12](https://github.com/kome12)! - Fix exactOut `--max-input` so it bounds the slippage-buffered approval, not the bare quote input. Previously an exactOut ERC-20 quote whose raw input equalled the cap (e.g. 1,000,000 at 3% slippage) passed the max-input filter and was saved, but execution scoped a larger approval (1,030,000) that the approval encoder then rejected for exceeding the cap — bricking the trade across local, Privy, and WalletConnect flows. Both the quote-time filter and the execute-time spend check now measure the same buffered amount the approval encoder does, so a quote that clears the cap can always be signed.
22
+
3
23
  ## 1.37.0
4
24
 
5
25
  ### Minor Changes
package/README.md CHANGED
@@ -238,7 +238,8 @@ Any field may be absent or `null`, meaning unknown — never assume zero. A low-
238
238
  |---------|-----|
239
239
  | `command not found` | `npm install -g nansen-cli` |
240
240
  | Global install reports an older version | `npm i -g nansen-cli@latest --registry=https://registry.npmjs.org/ --prefer-online`, then check `which -a nansen` for stale binaries |
241
- | `UNAUTHORIZED` after login | `cat ~/.nansen/config.json` or set `NANSEN_API_KEY` |
241
+ | `UNAUTHORIZED` after login | `nansen auth status` shows which key is active and where it comes from; re-run `nansen login` or set `NANSEN_API_KEY` |
242
+ | Anything else misbehaving | `nansen doctor` checks your whole setup (auth, wallets, caches, connectivity) with a fix per finding |
242
243
  | Empty perp _research_ results | Use `--symbol BTC`, not `--token`. Perps are Hyperliquid-only. |
243
244
  | `perp` _trading_ prints the usage banner | Trading needs `--coin BTC` (`--symbol` also works); see the Perpetuals section. |
244
245
  | `UNSUPPORTED_FILTER` on token holders | Remove `--smart-money` — not all tokens have that data. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -31,24 +31,26 @@ Use this skill when a user already has a nansen-cli wallet set up with the
31
31
  ## Detect current state
32
32
 
33
33
  `wallet show` only displays addresses and does NOT load or check the password.
34
- To detect the actual password situation, check for stored password sources:
34
+ To detect the actual password situation, check the stored password source directly:
35
35
 
36
36
  ```bash
37
- # 1. Check if a wallet exists at all
38
- nansen wallet list 2>&1
37
+ # 1. Where is the password stored? Offline, decrypts nothing, prints no secrets
38
+ nansen auth status --pretty # → x402.password.source: "env" | "keychain" | "file" | null
39
39
 
40
- # 2. Check for insecure password stores
41
- ls -la ~/.nansen/.env 2>/dev/null && echo "FOUND: ~/.nansen/.env (insecure)"
42
- ls -la ~/.nansen/wallets/.credentials 2>/dev/null && echo "FOUND: .credentials file (insecure)"
40
+ # 2. Full setup check — flags the insecure .credentials file with a fix
41
+ nansen doctor --offline
43
42
 
44
- # 3. Try an operation that requires the password (without setting env var)
45
- nansen wallet export default 2>&1
43
+ # 3. Legacy pattern doctor does not cover: password written to ~/.nansen/.env
44
+ ls -la ~/.nansen/.env 2>/dev/null && echo "FOUND: ~/.nansen/.env (insecure)"
46
45
  ```
47
46
 
48
- Interpret the `export` output:
49
- - `⚠ Password loaded from ~/.nansen/wallets/.credentials` on stderr → needs migration (Path B)
50
- - Export succeeds silently password is in keychain, no migration needed
51
- - `PASSWORD_REQUIRED` JSON error → password not persisted anywhere (Path C or D)
47
+ Interpret `x402.password.source`:
48
+ - `"file"` password in `.credentials` file, needs migration (Path B)
49
+ - `"keychain"`already secure, no migration needed
50
+ - `null` → password not persisted anywhere (Path C or D)
51
+ - `"env"` → `NANSEN_WALLET_PASSWORD` is set; check where it is being exported from (a `.env` file → Path A)
52
+
53
+ Do NOT use `nansen wallet export` to probe the password state — it prints private keys.
52
54
 
53
55
  ## Migration paths
54
56
 
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './comman
15
15
  import { resolveAddress, isEnsName } from './ens.js';
16
16
  import fs from 'fs';
17
17
  import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
18
+ import { getAuthStatus, runDoctorChecks, runConnectivityChecks, formatDoctorReport } from './doctor.js';
18
19
  import { refreshCostMapIfStale, getCostForEndpoint, creditsCharged } from './cost-cache.js';
19
20
  import { creditWarning, noticeWarnings } from './response-meta.js';
20
21
  import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
@@ -22,7 +23,7 @@ import { createRequire } from 'module';
22
23
  import * as readline from 'readline';
23
24
 
24
25
  const require = createRequire(import.meta.url);
25
- const { version: VERSION } = require('../package.json');
26
+ const { version: VERSION, engines: ENGINES } = require('../package.json');
26
27
 
27
28
  // ============= Schema Definition =============
28
29
 
@@ -189,7 +190,7 @@ export function parseArgs(args) {
189
190
  const key = arg.slice(2);
190
191
  const next = args[i + 1];
191
192
 
192
- if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human' || key === 'enabled' || key === 'disabled' || key === 'expert' || key === 'json') {
193
+ if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human' || key === 'enabled' || key === 'disabled' || key === 'expert' || key === 'json' || key === 'offline') {
193
194
  result.flags[key] = true;
194
195
  } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
195
196
  // Try to parse as JSON first (for objects/arrays/booleans),
@@ -729,8 +730,10 @@ COMMANDS:
729
730
  alerts list, create, update, toggle, delete
730
731
  web search, fetch
731
732
  account Show API key status, plan, and remaining credits
733
+ auth status — offline auth status: key source, wallets (no network)
732
734
  login Save API key (--api-key <key>, --human, or NANSEN_API_KEY env var)
733
735
  logout Remove saved API key
736
+ doctor Diagnostics: auth, wallets, caches, connectivity (--offline --json)
734
737
  schema JSON schema for all commands (use "nansen schema <cmd>" for one)
735
738
  cache clear
736
739
  changelog --since <version> to filter
@@ -886,6 +889,31 @@ export function buildCommands(deps = {}) {
886
889
  return apiInstance.getAccount();
887
890
  },
888
891
 
892
+ 'auth': async (args, _apiInstance, _flags, _options) => {
893
+ const subcommand = args[0] || 'status';
894
+ if (subcommand !== 'status') {
895
+ throw new NansenError(`Unknown auth subcommand: ${subcommand}. Available: status`, ErrorCode.UNKNOWN);
896
+ }
897
+ return getAuthStatus();
898
+ },
899
+
900
+ 'doctor': async (_args, _apiInstance, flags, _options) => {
901
+ const checks = runDoctorChecks({ cliVersion: VERSION, engines: ENGINES });
902
+ if (!flags.offline) {
903
+ checks.push(...await runConnectivityChecks());
904
+ }
905
+ if (flags.json) {
906
+ return {
907
+ version: VERSION,
908
+ offline: Boolean(flags.offline),
909
+ checks,
910
+ errors: checks.filter(c => c.status === 'error').length,
911
+ warnings: checks.filter(c => c.status === 'warn').length,
912
+ };
913
+ }
914
+ log(formatDoctorReport(checks, { cliVersion: VERSION, offline: Boolean(flags.offline) }));
915
+ },
916
+
889
917
  'web': async (args, apiInstance, flags, options) => {
890
918
  const subcommand = args[0] || 'help';
891
919
  const subArgs = args.slice(1);
@@ -1815,10 +1843,17 @@ export async function runCLI(rawArgs, deps = {}) {
1815
1843
  const stream = flags.stream || flags.s;
1816
1844
  const csv = options.format === 'csv';
1817
1845
 
1846
+ // `auth` and `doctor --offline` promise zero network activity — that
1847
+ // contract covers the background update-check fetch and telemetry too,
1848
+ // not just the command's own requests.
1849
+ const isOfflineCommand = command === 'auth' || (command === 'doctor' && flags.offline);
1850
+ const trackSucceeded = isOfflineCommand ? async () => {} : trackCommandSucceeded;
1851
+ const trackFailed = isOfflineCommand ? async () => {} : trackCommandFailed;
1852
+
1818
1853
  // Update check (read cached result + schedule background refresh)
1819
1854
  const updateNotification = getUpdateNotification(VERSION);
1820
1855
  const upgradeNotice = getUpgradeNotice(VERSION);
1821
- scheduleUpdateCheck();
1856
+ if (!isOfflineCommand) scheduleUpdateCheck();
1822
1857
  const notify = () => {
1823
1858
  if (upgradeNotice) errorOutput(upgradeNotice);
1824
1859
  if (updateNotification) errorOutput(updateNotification);
@@ -1839,7 +1874,9 @@ export async function runCLI(rawArgs, deps = {}) {
1839
1874
  }
1840
1875
 
1841
1876
  if (command === 'help' || flags.help || flags.h) {
1842
- await refreshCostMapIfStale();
1877
+ // Help for an offline command still owes the zero-network contract: the
1878
+ // cost-map refresh fetches the OpenAPI spec and writes ~/.nansen/cost-map.json.
1879
+ if (!isOfflineCommand) await refreshCostMapIfStale();
1843
1880
  // Check for subcommand-specific help: nansen <command> <subcommand> --help
1844
1881
  if (flags.help || flags.h) {
1845
1882
  // Handle 'research <category> <sub> --help' (3-level)
@@ -1972,7 +2009,7 @@ export async function runCLI(rawArgs, deps = {}) {
1972
2009
  };
1973
2010
  const formatted = formatOutput(errorData, { pretty, table });
1974
2011
  output(formatted.text);
1975
- await trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
2012
+ await trackFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1976
2013
  exit(1);
1977
2014
  return { type: 'error', data: errorData };
1978
2015
  }
@@ -2025,7 +2062,7 @@ export async function runCLI(rawArgs, deps = {}) {
2025
2062
 
2026
2063
  // Commands that handle their own output return undefined
2027
2064
  if (result === undefined) {
2028
- await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
2065
+ await trackSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
2029
2066
  return { type: 'no-output', command };
2030
2067
  }
2031
2068
 
@@ -2033,7 +2070,7 @@ export async function runCLI(rawArgs, deps = {}) {
2033
2070
  if (command === 'schema') {
2034
2071
  const formatted = formatOutput(result, { pretty, table: false });
2035
2072
  output(formatted.text);
2036
- await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
2073
+ await trackSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
2037
2074
  return { type: 'schema', data: result };
2038
2075
  }
2039
2076
 
@@ -2046,7 +2083,7 @@ export async function runCLI(rawArgs, deps = {}) {
2046
2083
  // Alerts list with --table uses custom table format
2047
2084
  if (command === 'alerts' && subcommand === 'list' && table) {
2048
2085
  output(formatAlertsTable(result));
2049
- await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
2086
+ await trackSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
2050
2087
  return { type: 'success', data: result };
2051
2088
  }
2052
2089
 
@@ -2057,14 +2094,14 @@ export async function runCLI(rawArgs, deps = {}) {
2057
2094
  if (streamOutput) {
2058
2095
  output(streamOutput);
2059
2096
  }
2060
- await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
2097
+ await trackSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
2061
2098
  return { type: 'stream', data: result };
2062
2099
  }
2063
2100
 
2064
2101
  const successData = { success: true, data: result };
2065
2102
  const formatted = formatOutput(successData, { pretty, table, csv });
2066
2103
  output(formatted.text);
2067
- await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
2104
+ await trackSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
2068
2105
  return { type: csv ? 'csv' : 'success', data: result };
2069
2106
  } catch (error) {
2070
2107
  // Unified error envelope across all command families (perp/bridge/trade):
@@ -2079,7 +2116,7 @@ export async function runCLI(rawArgs, deps = {}) {
2079
2116
  const formatted = formatOutput(errorData, { pretty, table, csv });
2080
2117
  output(formatted.text);
2081
2118
  }
2082
- await trackCommandFailed({
2119
+ await trackFailed({
2083
2120
  command: fullCommand,
2084
2121
  duration_ms: Date.now() - startTime,
2085
2122
  error_code: error.code || 'UNKNOWN',