nansen-cli 1.13.1 → 1.14.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#231](https://github.com/nansen-ai/nansen-cli/pull/231) [`c3968da`](https://github.com/nansen-ai/nansen-cli/commit/c3968dacb52521235ad6502321650148ac825d01) Thanks [@araa47](https://github.com/araa47)! - Agent-first secure wallet flow — OS keychain persistence, no interactive prompts
8
+
9
+ - **New `src/keychain.js`**: Password persistence via OS keychain (macOS Keychain / Linux secret-tool), with base64-encoded `.credentials` file fallback for containers/CI. Zero npm dependencies.
10
+ - **Non-interactive by default**: All readline prompts removed. Agents get structured JSON errors (`PASSWORD_REQUIRED`, `API_KEY_REQUIRED`) with actionable instructions. `--human` flag re-enables interactive mode.
11
+ - **Two-step wallet creation**: Agent asks user for password, runs `NANSEN_WALLET_PASSWORD=<pw> nansen wallet create`. Password auto-persists to keychain — all future operations are passwordless.
12
+ - **New commands**: `wallet secure` (migrate to keychain), `wallet forget-password` (clear from all stores).
13
+ - **Bug fixes**: Clear `passwordHash` on last wallet delete, verify password before keychain writes, exit non-zero when keychain migration fails, source-aware error messages.
14
+ - **New skill**: `nansen-wallet-migration` for migrating from old `~/.nansen/.env` storage to keychain.
15
+
3
16
  ## 1.13.1
4
17
 
5
18
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -163,7 +163,7 @@ export function parseArgs(args) {
163
163
  const key = arg.slice(2);
164
164
  const next = args[i + 1];
165
165
 
166
- if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full') {
166
+ 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') {
167
167
  result.flags[key] = true;
168
168
  } else if (next && !next.startsWith('-')) {
169
169
  // Try to parse as JSON first (for objects/arrays/booleans),
@@ -669,8 +669,8 @@ USAGE: nansen <command> [subcommand] [options]
669
669
  COMMANDS:
670
670
  research smart-money, profiler, token, search, perp, portfolio, points
671
671
  trade quote, execute
672
- wallet create, list, show, export, default, delete
673
- login Save API key (--api-key <key> or interactive)
672
+ wallet create, list, show, export, default, delete, forget-password
673
+ login Save API key (--api-key <key> or NANSEN_API_KEY env var)
674
674
  logout Remove saved API key
675
675
  schema JSON schema for all commands (use "nansen schema <cmd>" for one)
676
676
  cache clear
@@ -684,7 +684,7 @@ EXAMPLES:
684
684
  nansen research smart-money netflow --chain solana
685
685
  nansen research token screener --chain solana --timeframe 24h
686
686
  nansen research profiler balance --address 0x... --chain ethereum
687
- nansen trade quote --chain base --from ETH --to USDC --amount 1
687
+ nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
688
688
 
689
689
  Research chains: ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, zksync, mantle, ronin, sei, plasma, sonic, unichain, monad, hyperevm, iotaevm
690
690
  Trade chains: solana, base
@@ -761,43 +761,51 @@ export function buildCommands(deps = {}) {
761
761
  if (flags.help || flags.h) {
762
762
  log('nansen login - Save your Nansen API key\n');
763
763
  log('USAGE:');
764
- log(' nansen login (interactive)');
765
- log(' nansen login --api-key <key> (non-interactive)\n');
764
+ log(' nansen login --api-key <key>');
765
+ log(' NANSEN_API_KEY=<key> nansen login');
766
+ log(' nansen login --human (interactive prompt)\n');
766
767
  log('OPTIONS:');
767
768
  log(' --api-key <key> Your Nansen API key');
769
+ log(' --human Enable interactive prompt');
768
770
  log(' --help Show this help\n');
769
771
  log('Get your API key at: https://app.nansen.ai/api');
770
772
  return;
771
773
  }
772
774
 
773
- // Support non-interactive: nansen login --api-key <key>
774
775
  let apiKey = options['api-key'] || options.apiKey;
775
776
 
776
777
  if (!apiKey) {
777
- if (!isTTY) {
778
- // Non-interactive mode: check env var fallback
779
- apiKey = process.env.NANSEN_API_KEY;
780
- if (!apiKey) {
781
- log('❌ No API key provided. Use: nansen login --api-key <key>\n Or set NANSEN_API_KEY environment variable.\n Get your API key at: https://app.nansen.ai/api');
782
- exit(1);
783
- return;
784
- }
785
- } else {
786
- log('Nansen CLI Login\n');
787
- log('Get your API key at: https://app.nansen.ai/api\n');
788
- log('Tip: For non-interactive use: nansen login --api-key <key>\n');
778
+ apiKey = process.env.NANSEN_API_KEY;
779
+ }
789
780
 
790
- apiKey = await promptFn('Enter your API key: ', true);
781
+ if (!apiKey && flags.human) {
782
+ if (!isTTY) {
783
+ log(JSON.stringify({
784
+ error: 'NOT_A_TTY',
785
+ message: '--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.',
786
+ }));
787
+ exit(1);
788
+ return;
791
789
  }
790
+ log('Nansen CLI Login\n');
791
+ log('Get your API key at: https://app.nansen.ai/api\n');
792
+ apiKey = await promptFn('Enter your API key: ', true);
792
793
  }
793
794
 
794
795
  if (!apiKey || apiKey.trim().length === 0) {
795
- log('\n❌ No API key provided');
796
+ log(JSON.stringify({
797
+ error: 'API_KEY_REQUIRED',
798
+ message: 'No API key provided.',
799
+ resolution: [
800
+ 'Run: nansen login --api-key <key>',
801
+ 'Or set NANSEN_API_KEY environment variable',
802
+ 'Get your API key at: https://app.nansen.ai/api',
803
+ ],
804
+ }));
796
805
  exit(1);
797
806
  return;
798
807
  }
799
808
 
800
- // Save the config without validation
801
809
  saveConfigFn({
802
810
  apiKey: apiKey.trim(),
803
811
  baseUrl: 'https://api.nansen.ai'
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Nansen CLI - Password Persistence
3
+ * Stores/retrieves the wallet password, preferring the native OS credential
4
+ * store and falling back to a base64-encoded credentials file (not encrypted).
5
+ *
6
+ * Resolution order (read):
7
+ * 1. NANSEN_WALLET_PASSWORD env var
8
+ * 2. OS keychain (macOS Keychain / Linux secret-tool / Windows cmdkey)
9
+ * 3. ~/.nansen/wallets/.credentials file (insecure fallback)
10
+ *
11
+ * Storage order (write):
12
+ * 1. Try OS keychain first
13
+ * 2. Fall back to ~/.nansen/wallets/.credentials (chmod 600)
14
+ *
15
+ * Zero npm dependencies — uses native OS commands via child_process.
16
+ */
17
+
18
+ import { execFileSync } from 'child_process';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+
22
+ const SERVICE = 'nansen-cli';
23
+ const ACCOUNT = 'wallet-password';
24
+ const TIMEOUT_MS = 5000;
25
+
26
+ function getCredentialsPath() {
27
+ const home = process.env.HOME || process.env.USERPROFILE || '';
28
+ return path.join(home, '.nansen', 'wallets', '.credentials');
29
+ }
30
+
31
+ // ============= OS Keychain =============
32
+
33
+ function keychainStore(password) {
34
+ try {
35
+ if (process.platform === 'darwin') {
36
+ // macOS `security` CLI requires -w <password> as argv — no stdin mode.
37
+ // Unlike secret-tool, omitting the value prompts from the TTY, not stdin.
38
+ // Exposure in process listings is brief (sub-second, execFileSync is synchronous).
39
+ execFileSync('/usr/bin/security', [
40
+ 'add-generic-password',
41
+ '-s', SERVICE,
42
+ '-a', ACCOUNT,
43
+ '-w', password,
44
+ '-U',
45
+ ], { timeout: TIMEOUT_MS, stdio: 'pipe' });
46
+ return true;
47
+ }
48
+
49
+ if (process.platform === 'linux') {
50
+ execFileSync('secret-tool', [
51
+ 'store',
52
+ '--label', SERVICE,
53
+ 'service', SERVICE,
54
+ 'account', ACCOUNT,
55
+ ], { input: password, timeout: TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] });
56
+ return true;
57
+ }
58
+
59
+ // Windows: no reliable built-in CLI for credential read-back.
60
+ // cmdkey stores but can't retrieve passwords. Falls through to .credentials file.
61
+ return false;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function keychainRetrieve() {
68
+ try {
69
+ if (process.platform === 'darwin') {
70
+ const result = execFileSync('/usr/bin/security', [
71
+ 'find-generic-password',
72
+ '-s', SERVICE,
73
+ '-a', ACCOUNT,
74
+ '-w',
75
+ ], { timeout: TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] });
76
+ const pw = result.toString().trim();
77
+ return pw || null;
78
+ }
79
+
80
+ if (process.platform === 'linux') {
81
+ const result = execFileSync('secret-tool', [
82
+ 'lookup',
83
+ 'service', SERVICE,
84
+ 'account', ACCOUNT,
85
+ ], { timeout: TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] });
86
+ const pw = result.toString().trim();
87
+ return pw || null;
88
+ }
89
+
90
+ // Windows: no reliable built-in CLI for credential read-back.
91
+ return null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function keychainDeleteEntry() {
98
+ try {
99
+ if (process.platform === 'darwin') {
100
+ execFileSync('/usr/bin/security', [
101
+ 'delete-generic-password',
102
+ '-s', SERVICE,
103
+ '-a', ACCOUNT,
104
+ ], { timeout: TIMEOUT_MS, stdio: 'pipe' });
105
+ return true;
106
+ }
107
+
108
+ if (process.platform === 'linux') {
109
+ execFileSync('secret-tool', [
110
+ 'clear',
111
+ 'service', SERVICE,
112
+ 'account', ACCOUNT,
113
+ ], { timeout: TIMEOUT_MS, stdio: 'pipe' });
114
+ return true;
115
+ }
116
+
117
+ // Windows: no keychain entries to delete (uses .credentials file).
118
+ return false;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ // ============= Credentials File Fallback =============
125
+
126
+ function credentialsFileRead() {
127
+ try {
128
+ const filePath = getCredentialsPath();
129
+ if (!fs.existsSync(filePath)) return null;
130
+ const content = fs.readFileSync(filePath, 'utf8').trim();
131
+ // New format: base64-encoded (handles passwords with newlines/special chars)
132
+ const b64Match = content.match(/^NANSEN_WALLET_PASSWORD_B64=(.+)$/m);
133
+ if (b64Match) return Buffer.from(b64Match[1].trim(), 'base64').toString('utf8');
134
+ // Legacy format: plain text (backward compat)
135
+ const match = content.match(/^NANSEN_WALLET_PASSWORD=(.+)$/m);
136
+ return match ? match[1].trim() : null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function credentialsFileWrite(password) {
143
+ try {
144
+ const filePath = getCredentialsPath();
145
+ const dir = path.dirname(filePath);
146
+ if (!fs.existsSync(dir)) {
147
+ fs.mkdirSync(dir, { mode: 0o700, recursive: true });
148
+ }
149
+ const encoded = Buffer.from(password, 'utf8').toString('base64');
150
+ fs.writeFileSync(filePath, `NANSEN_WALLET_PASSWORD_B64=${encoded}\n`, { mode: 0o600 });
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function credentialsFileDelete() {
158
+ try {
159
+ const filePath = getCredentialsPath();
160
+ if (!fs.existsSync(filePath)) return false;
161
+ fs.unlinkSync(filePath);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ // ============= Public API =============
169
+
170
+ /**
171
+ * Store a password. Tries OS keychain first, falls back to .credentials file.
172
+ * @param {string} password
173
+ * @returns {{ stored: boolean, method: 'keychain'|'file'|'none' }}
174
+ */
175
+ export function storePassword(password) {
176
+ if (keychainStore(password)) {
177
+ return { stored: true, method: 'keychain' };
178
+ }
179
+ if (credentialsFileWrite(password)) {
180
+ return { stored: true, method: 'file' };
181
+ }
182
+ return { stored: false, method: 'none' };
183
+ }
184
+
185
+ /**
186
+ * Retrieve the wallet password from OS keychain or .credentials file.
187
+ * @returns {{ password: string|null, source: 'env'|'keychain'|'file'|null }}
188
+ */
189
+ export function retrievePassword() {
190
+ const envPw = process.env.NANSEN_WALLET_PASSWORD;
191
+ if (envPw) return { password: envPw, source: 'env' };
192
+
193
+ const keychainPw = keychainRetrieve();
194
+ if (keychainPw) return { password: keychainPw, source: 'keychain' };
195
+
196
+ const filePw = credentialsFileRead();
197
+ if (filePw) return { password: filePw, source: 'file' };
198
+
199
+ return { password: null, source: null };
200
+ }
201
+
202
+ /**
203
+ * Delete the wallet password from all stores.
204
+ * @returns {{ keychain: boolean, file: boolean }}
205
+ */
206
+ export function deletePassword() {
207
+ return {
208
+ keychain: keychainDeleteEntry(),
209
+ file: credentialsFileDelete(),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Delete only the .credentials file (not the keychain entry).
215
+ * Used by `wallet secure` after migrating to keychain.
216
+ * @returns {boolean}
217
+ */
218
+ export function deleteCredentialsFile() {
219
+ return credentialsFileDelete();
220
+ }
221
+
222
+ /**
223
+ * Resolve the wallet password from available sources.
224
+ * Order: env var → OS keychain → .credentials file → null
225
+ * @returns {string|null}
226
+ */
227
+ export function resolvePassword() {
228
+ return retrievePassword().password;
229
+ }
package/src/trading.js CHANGED
@@ -12,6 +12,7 @@ import { exportWallet, getDefaultAddress, showWallet, listWallets, getWalletConf
12
12
  import { base58Decode } from './transfer.js';
13
13
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
14
14
  import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
15
+ import { retrievePassword } from './keychain.js';
15
16
 
16
17
  // ============= Constants =============
17
18
 
@@ -652,31 +653,15 @@ export function getWalletChainType(chainName) {
652
653
 
653
654
  // ============= CLI Helpers =============
654
655
 
655
- async function promptPassword(prompt, deps = {}) {
656
- if (deps.promptFn) return deps.promptFn(prompt);
657
- const readline = await import('readline');
658
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
659
- return new Promise((resolve) => {
660
- process.stderr.write(prompt);
661
- let input = '';
662
- const stdin = process.stdin;
663
- const wasRaw = stdin.isRaw;
664
- if (stdin.setRawMode) stdin.setRawMode(true);
665
- stdin.resume();
666
- const onData = (ch) => {
667
- const c = ch.toString();
668
- if (c === '\n' || c === '\r') {
669
- if (stdin.setRawMode) stdin.setRawMode(wasRaw || false);
670
- stdin.removeListener('data', onData);
671
- process.stderr.write('\n');
672
- rl.close();
673
- resolve(input);
674
- } else if (c === '\u0003') { rl.close(); process.exit(1); }
675
- else if (c === '\u007f' || c === '\b') { input = input.slice(0, -1); }
676
- else { input += c; }
677
- };
678
- stdin.on('data', onData);
679
- });
656
+ function resolveTradePassword() {
657
+ const { password, source } = retrievePassword();
658
+ if (source === 'file') {
659
+ process.stderr.write(
660
+ '⚠️ Password loaded from ~/.nansen/wallets/.credentials (insecure — plaintext on disk).\n' +
661
+ ' For better security, migrate to OS keychain: nansen wallet secure\n'
662
+ );
663
+ }
664
+ return password;
680
665
  }
681
666
 
682
667
  function isNativeToken(mintAddress) {
@@ -958,9 +943,22 @@ EXAMPLES:
958
943
  if (!isWalletConnect) {
959
944
  // Get wallet credentials once (before the loop)
960
945
  const walletConfig = getWalletConfig();
961
- const password = walletConfig.passwordHash
962
- ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
963
- : null;
946
+ let password = null;
947
+ if (walletConfig.passwordHash) {
948
+ password = resolveTradePassword();
949
+ if (!password) {
950
+ log(JSON.stringify({
951
+ error: 'PASSWORD_REQUIRED',
952
+ message: 'Wallet is encrypted and no password was found.',
953
+ resolution: [
954
+ 'Set NANSEN_WALLET_PASSWORD environment variable',
955
+ 'Or run: nansen wallet create (password is saved to OS keychain automatically)',
956
+ ],
957
+ }));
958
+ exit(1);
959
+ return;
960
+ }
961
+ }
964
962
 
965
963
  let effectiveWalletName = walletName;
966
964
  if (!effectiveWalletName) {
package/src/wallet.js CHANGED
@@ -8,6 +8,7 @@ import fs from 'fs';
8
8
  import path from 'path';
9
9
  import * as readline from 'readline';
10
10
  import { base58 } from '@scure/base';
11
+ import { storePassword, retrievePassword, deletePassword, deleteCredentialsFile } from './keychain.js';
11
12
 
12
13
  // ============= Constants =============
13
14
 
@@ -265,7 +266,7 @@ async function promptPassword(question, deps = {}) {
265
266
  if (promptFn) {
266
267
  return promptFn(question, true);
267
268
  }
268
- // Fallback to readline
269
+ // Fallback to readline (only available in --human mode)
269
270
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
270
271
  return new Promise((resolve) => {
271
272
  if (process.stdout.isTTY) {
@@ -298,6 +299,60 @@ async function promptPassword(question, deps = {}) {
298
299
  });
299
300
  }
300
301
 
302
+ /**
303
+ * Resolve wallet password from all available sources (non-interactive).
304
+ * Order: NANSEN_WALLET_PASSWORD env var → OS keychain → .credentials file → null
305
+ * Emits a warning to stderr when using the insecure .credentials file.
306
+ * @returns {string|null}
307
+ */
308
+ function resolveWalletPassword() {
309
+ const { password, source } = retrievePassword();
310
+ if (source === 'file') {
311
+ process.stderr.write(
312
+ '⚠️ Password loaded from ~/.nansen/wallets/.credentials (insecure — plaintext on disk).\n' +
313
+ ' For better security, migrate to OS keychain: nansen wallet secure\n' +
314
+ ' Or set NANSEN_WALLET_PASSWORD via a secrets manager.\n'
315
+ );
316
+ }
317
+ return password;
318
+ }
319
+
320
+ /**
321
+ * Resolve wallet password for a command. If --human flag is set and no
322
+ * password found, falls back to interactive prompt. Otherwise returns
323
+ * structured error info for agents.
324
+ *
325
+ * @param {object} config - wallet config (needs config.passwordHash)
326
+ * @param {object} flags - CLI flags
327
+ * @param {object} deps - { promptFn, log, exit }
328
+ * @returns {{ password: string|null, error: string|null }}
329
+ */
330
+ async function resolvePasswordForCommand(config, flags, deps) {
331
+ if (!config.passwordHash) {
332
+ return { password: null, error: null };
333
+ }
334
+
335
+ const password = resolveWalletPassword();
336
+ if (password) return { password, error: null };
337
+
338
+ if (flags.human && (process.stdin.isTTY || deps.promptFn)) {
339
+ const prompted = await promptPassword('Enter wallet password: ', deps);
340
+ if (prompted) return { password: prompted, error: null };
341
+ }
342
+
343
+ return {
344
+ password: null,
345
+ error: JSON.stringify({
346
+ error: 'PASSWORD_REQUIRED',
347
+ message: 'Wallet is encrypted and no password was found.',
348
+ resolution: [
349
+ 'Set NANSEN_WALLET_PASSWORD environment variable',
350
+ 'Or re-run wallet create with the password (it will be persisted for future use)',
351
+ ],
352
+ }),
353
+ };
354
+ }
355
+
301
356
  // ============= Public API =============
302
357
 
303
358
  /**
@@ -468,12 +523,16 @@ export function deleteWallet(name, password) {
468
523
 
469
524
  fs.unlinkSync(walletFile);
470
525
 
471
- if (config.defaultWallet === name) {
472
- // Pick another wallet as default, or null
473
- const remaining = fs.readdirSync(getWalletsDir()).filter(f => f.endsWith('.json') && f !== 'config.json');
474
- config.defaultWallet = remaining.length > 0 ? remaining[0].replace('.json', '') : null;
475
- saveWalletConfig(config);
526
+ const remaining = fs.readdirSync(getWalletsDir()).filter(f => f.endsWith('.json') && f !== 'config.json');
527
+
528
+ if (remaining.length === 0) {
529
+ config.defaultWallet = null;
530
+ config.passwordHash = null;
531
+ deletePassword();
532
+ } else if (config.defaultWallet === name) {
533
+ config.defaultWallet = remaining[0].replace('.json', '');
476
534
  }
535
+ saveWalletConfig(config);
477
536
 
478
537
  return { deleted: name, newDefault: config.defaultWallet };
479
538
  }
@@ -512,32 +571,74 @@ export function buildWalletCommands(deps = {}) {
512
571
  if (flags['unsafe-no-password']) {
513
572
  process.stderr.write('WARNING: --unsafe-no-password is set. Private keys will be stored UNENCRYPTED on disk.\nAnyone with access to this machine can steal your funds.\n');
514
573
  password = null;
515
- } else if (!process.env.NANSEN_WALLET_PASSWORD && !process.stdin.isTTY && !deps.promptFn) {
516
- log('❌ No password provided. Either:');
517
- log(' set NANSEN_WALLET_PASSWORD, or');
518
- log(' use --unsafe-no-password (WARNING: Private keys will be stored UNENCRYPTED on disk. Anyone with access to this machine can steal your funds.)');
519
- exit(1);
520
- return;
521
574
  } else {
522
- password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
523
- if (!password || password.length < 12) {
524
- log('❌ Password must be at least 12 characters');
575
+ // Step 1: Check env var and keychain
576
+ password = resolveWalletPassword();
577
+
578
+ // Step 2: If --human flag, allow interactive prompt (requires TTY)
579
+ if (!password && flags.human && !process.stdin.isTTY && !deps.promptFn) {
580
+ log(JSON.stringify({
581
+ error: 'NOT_A_TTY',
582
+ message: '--human requires an interactive terminal. Set NANSEN_WALLET_PASSWORD env var instead.',
583
+ }));
525
584
  exit(1);
526
585
  return;
527
586
  }
528
-
529
- // Confirm password for first wallet (skip if set via env var)
530
- const config = getWalletConfig();
531
- if (!config.passwordHash && !process.env.NANSEN_WALLET_PASSWORD) {
532
- const confirm = await promptPassword('Confirm password: ', deps);
533
- if (password !== confirm) {
534
- log('❌ Passwords do not match');
587
+ if (!password && flags.human && (process.stdin.isTTY || deps.promptFn)) {
588
+ password = await promptPassword('Enter wallet password: ', deps);
589
+ if (password && password.length < 12) {
590
+ log('❌ Password must be at least 12 characters');
535
591
  exit(1);
536
592
  return;
537
593
  }
594
+ if (password) {
595
+ const config = getWalletConfig();
596
+ if (!config.passwordHash) {
597
+ const confirm = await promptPassword('Confirm password: ', deps);
598
+ if (password !== confirm) {
599
+ log('❌ Passwords do not match');
600
+ exit(1);
601
+ return;
602
+ }
603
+ }
604
+ }
605
+ }
606
+
607
+ // Step 3: No password available — return structured error for agents
608
+ if (!password) {
609
+ log(JSON.stringify({
610
+ error: 'PASSWORD_REQUIRED',
611
+ message: 'A wallet password is required. Ask the user to provide one.',
612
+ instructions: 'Re-run with: NANSEN_WALLET_PASSWORD=<password> nansen wallet create',
613
+ note: 'Password must be at least 12 characters. After creation, the password is saved to the OS keychain automatically — future operations will not require it.',
614
+ }));
615
+ exit(1);
616
+ return;
617
+ }
618
+
619
+ if (password.length < 12) {
620
+ log('❌ Password must be at least 12 characters');
621
+ exit(1);
622
+ return;
538
623
  }
539
624
  }
540
625
 
626
+ // Verify password matches existing wallets BEFORE touching keychain
627
+ if (password !== null) {
628
+ const config = getWalletConfig();
629
+ if (config.passwordHash && !verifyPassword(password, config)) {
630
+ log('❌ Incorrect password — does not match existing wallets.');
631
+ exit(1);
632
+ return;
633
+ }
634
+ }
635
+
636
+ // Persist password BEFORE creating wallet so we know the storage situation
637
+ let storageResult = { stored: false, method: 'none' };
638
+ if (password !== null) {
639
+ storageResult = storePassword(password);
640
+ }
641
+
541
642
  try {
542
643
  const result = createWallet(name, password);
543
644
  log(`\n✓ Wallet "${result.name}" created\n`);
@@ -551,9 +652,24 @@ export function buildWalletCommands(deps = {}) {
551
652
  log('');
552
653
  if (password === null) {
553
654
  log(' ⚠️ This is an UNENCRYPTED hot wallet — private keys are stored in plaintext on disk.');
655
+ } else if (storageResult.stored && storageResult.method === 'keychain') {
656
+ log(' ✓ Password saved to system keychain (secure).');
657
+ log(' Future wallet operations will retrieve the password automatically.');
658
+ } else if (storageResult.stored && storageResult.method === 'file') {
659
+ log(' ⚠️ No OS keychain available. Password saved to ~/.nansen/wallets/.credentials (insecure — plaintext on disk).');
660
+ log(' Future wallet operations will retrieve the password automatically.');
661
+ log(' To improve security: migrate to OS keychain with `nansen wallet secure`,');
662
+ log(' or set NANSEN_WALLET_PASSWORD via a secrets manager.');
554
663
  } else {
555
- log(' ⚠️ This is a hot wallet and is fundamentally insecure — do not deposit more than you can afford to lose.');
556
- log(' Store and handle your password securely, e.g. using a secrets manager or system keychain.');
664
+ log(' ⚠️ CRITICAL: Password could not be saved anywhere (no keychain, no writable filesystem).');
665
+ log(' You MUST set NANSEN_WALLET_PASSWORD in your environment for ALL future wallet operations.');
666
+ log(' If you lose this password, your funds are UNRECOVERABLE.');
667
+ }
668
+ if (password !== null) {
669
+ log('');
670
+ log(' IMPORTANT: Back up your password separately (e.g. password manager).');
671
+ log(' If you lose access to this machine AND forget the password, funds are unrecoverable.');
672
+ log(' This is a hot wallet — do not deposit more than you can afford to lose.');
557
673
  }
558
674
  log('');
559
675
  return;
@@ -608,9 +724,12 @@ export function buildWalletCommands(deps = {}) {
608
724
  return;
609
725
  }
610
726
  const config = getWalletConfig();
611
- const password = config.passwordHash
612
- ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
613
- : null;
727
+ const { password, error } = await resolvePasswordForCommand(config, flags, deps);
728
+ if (error) {
729
+ log(error);
730
+ exit(1);
731
+ return;
732
+ }
614
733
  try {
615
734
  const result = exportWallet(name, password);
616
735
  log(`\n⚠️ Private keys for "${result.name}" — do not share!\n`);
@@ -653,9 +772,12 @@ export function buildWalletCommands(deps = {}) {
653
772
  return;
654
773
  }
655
774
  const config = getWalletConfig();
656
- const password = config.passwordHash
657
- ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
658
- : null;
775
+ const { password, error } = await resolvePasswordForCommand(config, flags, deps);
776
+ if (error) {
777
+ log(error);
778
+ exit(1);
779
+ return;
780
+ }
659
781
  try {
660
782
  const result = deleteWallet(name, password);
661
783
  log(`✓ Wallet "${result.deleted}" deleted`);
@@ -703,9 +825,13 @@ export function buildWalletCommands(deps = {}) {
703
825
  password = null;
704
826
  } else {
705
827
  const sendConfig = getWalletConfig();
706
- password = sendConfig.passwordHash
707
- ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
708
- : null;
828
+ const resolved = await resolvePasswordForCommand(sendConfig, flags, deps);
829
+ if (resolved.error) {
830
+ log(resolved.error);
831
+ exit(1);
832
+ return;
833
+ }
834
+ password = resolved.password;
709
835
  }
710
836
  const dryRun = flags['dry-run'] || flags.dryRun;
711
837
 
@@ -723,7 +849,6 @@ export function buildWalletCommands(deps = {}) {
723
849
  };
724
850
 
725
851
  if (dryRun) {
726
- // Build the transaction but don't broadcast
727
852
  const result = await sendTokens(sendOpts);
728
853
  log(`\nDry run — transaction not broadcast\n`);
729
854
  log(` From: ${result.from}`);
@@ -755,6 +880,83 @@ export function buildWalletCommands(deps = {}) {
755
880
  }
756
881
  },
757
882
 
883
+ 'forget-password': async () => {
884
+ const result = deletePassword();
885
+ if (result.keychain || result.file) {
886
+ log('✓ Password removed from:');
887
+ if (result.keychain) log(' - System keychain');
888
+ if (result.file) log(' - Credentials file (~/.nansen/wallets/.credentials)');
889
+ } else {
890
+ log('No saved password found (keychain or credentials file).');
891
+ }
892
+ },
893
+
894
+ 'secure': async () => {
895
+ const { password, source } = retrievePassword();
896
+ if (!password) {
897
+ log(JSON.stringify({
898
+ error: 'NO_PASSWORD_FOUND',
899
+ message: 'No wallet password found in any store.',
900
+ resolution: [
901
+ 'Set NANSEN_WALLET_PASSWORD and run: nansen wallet secure',
902
+ 'This will store it in the OS keychain.',
903
+ ],
904
+ }));
905
+ exit(1);
906
+ return;
907
+ }
908
+
909
+ if (source === 'keychain') {
910
+ log('✓ Password is already stored in the OS keychain (secure).');
911
+ return;
912
+ }
913
+
914
+ // Verify password actually decrypts wallets before overwriting keychain
915
+ const walletConfig = getWalletConfig();
916
+ if (walletConfig.passwordHash && !verifyPassword(password, walletConfig)) {
917
+ log(JSON.stringify({
918
+ error: 'INCORRECT_PASSWORD',
919
+ message: `Password from '${source}' does not match the wallet's stored hash.`,
920
+ resolution: source === 'file'
921
+ ? [
922
+ 'The password in ~/.nansen/wallets/.credentials is incorrect.',
923
+ 'Run: nansen wallet forget-password then re-run with the correct password: NANSEN_WALLET_PASSWORD=<pw> nansen wallet secure',
924
+ ]
925
+ : [
926
+ 'Unset NANSEN_WALLET_PASSWORD if it is stale, then re-run: nansen wallet secure',
927
+ ],
928
+ }));
929
+ exit(1);
930
+ return;
931
+ }
932
+
933
+ // Try to migrate to keychain
934
+ const { stored, method } = storePassword(password);
935
+ if (stored && method === 'keychain') {
936
+ const fileRemoved = deleteCredentialsFile();
937
+ const fromLabel = source === 'file'
938
+ ? '~/.nansen/wallets/.credentials file'
939
+ : 'NANSEN_WALLET_PASSWORD env var';
940
+ log(`✓ Password migrated from ${fromLabel} → OS keychain (secure).`);
941
+ if (fileRemoved) {
942
+ log(' Removed ~/.nansen/wallets/.credentials.');
943
+ }
944
+ } else {
945
+ log(JSON.stringify({
946
+ error: 'KEYCHAIN_UNAVAILABLE',
947
+ message: source === 'file'
948
+ ? 'OS keychain is not available. Password remains in ~/.nansen/wallets/.credentials (insecure).'
949
+ : 'OS keychain is not available. Password is only in the NANSEN_WALLET_PASSWORD env var (not persisted).',
950
+ resolution: [
951
+ 'Set NANSEN_WALLET_PASSWORD in a secrets manager or system keyring',
952
+ 'Use a containerized secrets agent (e.g. Vault, 1Password CLI)',
953
+ ],
954
+ }));
955
+ exit(1);
956
+ return;
957
+ }
958
+ },
959
+
758
960
  'help': async () => {
759
961
  log(`
760
962
  Wallet Management - Local key storage for EVM and Solana
@@ -772,6 +974,8 @@ COMMANDS:
772
974
  delete <name> Delete a wallet (requires password)
773
975
  send --to <address> --amount <number> --chain <evm|solana> [--token <address>] [--wallet <name>] [--max] [--dry-run]
774
976
  Send tokens or native currency (--max sends entire balance, --dry-run previews without sending)
977
+ forget-password Remove saved password from all stores
978
+ secure Migrate password from insecure storage to OS keychain
775
979
 
776
980
  OPTIONS:
777
981
  --name <label> Wallet name (default: "default")
@@ -782,19 +986,26 @@ OPTIONS:
782
986
  --wallet <name> Wallet to use (optional, uses default if omitted; use "walletconnect" or "wc" for WalletConnect, EVM only)
783
987
  --max Send entire balance (deducts gas for native transfers)
784
988
  --unsafe-no-password Skip encryption — private keys stored UNENCRYPTED on disk (create only)
989
+ --human Enable interactive prompts (for human terminal use only)
990
+
991
+ PASSWORD RESOLUTION (automatic, in order):
992
+ 1. NANSEN_WALLET_PASSWORD env var
993
+ 2. OS keychain (saved automatically on wallet create)
994
+ 3. Interactive prompt (only with --human flag)
785
995
 
786
996
  ENVIRONMENT:
787
- NANSEN_WALLET_PASSWORD Password for non-interactive use (e.g. CI/scripts)
997
+ NANSEN_WALLET_PASSWORD Wallet encryption password
788
998
  NANSEN_EVM_RPC Custom EVM RPC endpoint
789
999
  NANSEN_SOLANA_RPC Custom Solana RPC endpoint
790
1000
 
791
1001
  EXAMPLES:
792
- nansen wallet create --name trading
1002
+ NANSEN_WALLET_PASSWORD=mypass nansen wallet create --name trading
793
1003
  nansen wallet list
794
1004
  nansen wallet export trading
795
1005
  nansen wallet default trading
796
1006
  nansen wallet send --to 0x742d35Cc... --amount 1.5 --chain evm
797
1007
  nansen wallet send --to 9WzDXw... --amount 0.1 --chain solana --token So11...
1008
+ nansen wallet forget-password
798
1009
  `);
799
1010
  return;
800
1011
  },
package/src/x402.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  fetchRecentBlockhash,
12
12
  getSolanaRpcUrl,
13
13
  } from './x402-svm.js';
14
+ import { resolvePassword } from './keychain.js';
14
15
 
15
16
  /**
16
17
  * Parse PaymentRequirements from a 402 response.
@@ -108,9 +109,8 @@ export async function* createPaymentSignatures(response, url, options = {}) {
108
109
 
109
110
  const walletConfig = getWalletConfig();
110
111
  const password = walletConfig.passwordHash
111
- ? (options.password || process.env.NANSEN_WALLET_PASSWORD || null)
112
+ ? (options.password || resolvePassword() || null)
112
113
  : null;
113
- // Encrypted wallets need a password -- silently skip if unavailable
114
114
  if (walletConfig.passwordHash && password === null) return;
115
115
 
116
116
  const wallets = listWallets();