nansen-cli 1.13.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
 
@@ -238,8 +239,8 @@ function getWalletFile(name) {
238
239
  * Verify the global password against stored hash.
239
240
  */
240
241
  export function verifyPassword(password, config) {
242
+ if (password == null) return false;
241
243
  if (!config.passwordHash) return true; // No password set yet
242
- if (password === null || password === undefined) return false;
243
244
  const { salt, hash } = config.passwordHash;
244
245
  const derived = crypto.scryptSync(password, Buffer.from(salt, 'hex'), 32, {
245
246
  N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: 256 * 1024 * 1024,
@@ -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
  /**
@@ -312,6 +367,7 @@ export function listWallets() {
312
367
  const data = JSON.parse(fs.readFileSync(path.join(getWalletsDir(), f), 'utf8'));
313
368
  return {
314
369
  name: data.name,
370
+ provider: data.provider || 'local',
315
371
  evm: data.evm?.address || null,
316
372
  solana: data.solana?.address || null,
317
373
  createdAt: data.createdAt,
@@ -342,10 +398,18 @@ export function createWallet(name, password) {
342
398
  } else {
343
399
  // Encrypted mode
344
400
  if (!config.passwordHash) {
345
- // First encrypted wallet: reject if passwordless wallets exist
346
- const existingWallets = fs.readdirSync(getWalletsDir())
347
- .filter(f => f.endsWith('.json') && f !== 'config.json');
348
- if (existingWallets.length > 0) {
401
+ // First encrypted wallet: reject if passwordless local wallets exist
402
+ // (non-local wallets like Privy don't contain private keys, so skip them)
403
+ const walletsDir = getWalletsDir();
404
+ const existingLocalWallets = fs.readdirSync(walletsDir)
405
+ .filter(f => f.endsWith('.json') && f !== 'config.json')
406
+ .filter(f => {
407
+ try {
408
+ const data = JSON.parse(fs.readFileSync(path.join(walletsDir, f), 'utf8'));
409
+ return !data.provider || data.provider === 'local';
410
+ } catch { return true; }
411
+ });
412
+ if (existingLocalWallets.length > 0) {
349
413
  throw new Error('Existing wallets are passwordless. Cannot mix encrypted and unencrypted wallets.');
350
414
  }
351
415
  config.passwordHash = hashPassword(password);
@@ -397,13 +461,21 @@ export function showWallet(name) {
397
461
 
398
462
  const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
399
463
  const config = getWalletConfig();
464
+ const isPrivy = data.provider === 'privy';
400
465
 
401
466
  return {
402
467
  name: data.name,
468
+ provider: data.provider || 'local',
403
469
  evm: data.evm?.address || null,
404
470
  solana: data.solana?.address || null,
405
471
  createdAt: data.createdAt,
406
472
  isDefault: data.name === config.defaultWallet,
473
+ ...(isPrivy ? {
474
+ privyWalletIds: {
475
+ evm: data.evm?.privyWalletId,
476
+ solana: data.solana?.privyWalletId,
477
+ }
478
+ } : {}),
407
479
  };
408
480
  }
409
481
 
@@ -416,13 +488,16 @@ export function exportWallet(name, password) {
416
488
  throw new Error(`Wallet "${name}" not found`);
417
489
  }
418
490
 
491
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
492
+ if (data.provider && data.provider !== 'local') {
493
+ throw new Error(`${data.provider} wallets don't support key export. Keys are managed by the provider.`);
494
+ }
495
+
419
496
  const config = getWalletConfig();
420
497
  if (config.passwordHash && !verifyPassword(password, config)) {
421
498
  throw new Error('Incorrect password');
422
499
  }
423
500
 
424
- const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
425
-
426
501
  return {
427
502
  name: data.name,
428
503
  evm: {
@@ -455,41 +530,37 @@ export function setDefaultWallet(name) {
455
530
  /**
456
531
  * Delete a wallet.
457
532
  */
458
- export function deleteWallet(name, password) {
533
+ export async function deleteWallet(name, password) {
459
534
  const walletFile = getWalletFile(name);
460
535
  if (!fs.existsSync(walletFile)) {
461
536
  throw new Error(`Wallet "${name}" not found`);
462
537
  }
463
538
 
539
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
464
540
  const config = getWalletConfig();
465
- if (config.passwordHash && !verifyPassword(password, config)) {
466
- throw new Error('Incorrect password');
541
+
542
+ if (data.provider && data.provider !== 'local') {
543
+ // Non-local wallets: just remove local reference, no password needed
544
+ } else {
545
+ if (config.passwordHash && !verifyPassword(password, config)) {
546
+ throw new Error('Incorrect password');
547
+ }
467
548
  }
468
549
 
469
550
  fs.unlinkSync(walletFile);
470
551
 
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);
476
- }
552
+ const remaining = fs.readdirSync(getWalletsDir()).filter(f => f.endsWith('.json') && f !== 'config.json');
477
553
 
478
- return { deleted: name, newDefault: config.defaultWallet };
479
- }
480
-
481
- /**
482
- * Get the default wallet's address for a given chain type.
483
- */
484
- export function getDefaultAddress(chainType = 'evm') {
485
- const config = getWalletConfig();
486
- if (!config.defaultWallet) {
487
- throw new Error('No default wallet set. Run: nansen wallet create');
554
+ if (remaining.length === 0) {
555
+ config.defaultWallet = null;
556
+ config.passwordHash = null;
557
+ deletePassword();
558
+ } else if (config.defaultWallet === name) {
559
+ config.defaultWallet = remaining[0].replace('.json', '');
488
560
  }
561
+ saveWalletConfig(config);
489
562
 
490
- const wallet = showWallet(config.defaultWallet);
491
- const field = chainType === 'solana' ? 'solana' : 'evm';
492
- return wallet[field];
563
+ return { deleted: name, newDefault: config.defaultWallet };
493
564
  }
494
565
 
495
566
  // ============= CLI Command Builder =============
@@ -504,6 +575,27 @@ export function buildWalletCommands(deps = {}) {
504
575
  'wallet': async (args, apiInstance, flags, options) => {
505
576
  const subcommand = args[0] || 'help';
506
577
 
578
+ // Privy-specific: only 'create' and policy commands need --provider privy
579
+ if (options.provider === 'privy' || process.env.NANSEN_WALLET_PROVIDER === 'privy') {
580
+ if (subcommand === 'create') {
581
+ const { createPrivyWalletPair } = await import('./privy.js');
582
+ const name = options.name || args[1] || 'default';
583
+ try {
584
+ const result = await createPrivyWalletPair(name);
585
+ log(`\n✓ Privy wallet "${result.name}" created\n`);
586
+ log(` EVM: ${result.evm.address}`);
587
+ log(` Solana: ${result.solana.address}`);
588
+ log('');
589
+ return;
590
+ } catch (err) {
591
+ log(`❌ ${err.message}`);
592
+ exit(1);
593
+ return;
594
+ }
595
+ }
596
+ // All other subcommands fall through to unified handlers below
597
+ }
598
+
507
599
  const handlers = {
508
600
  'create': async () => {
509
601
  const name = options.name || args[1] || 'default';
@@ -512,32 +604,74 @@ export function buildWalletCommands(deps = {}) {
512
604
  if (flags['unsafe-no-password']) {
513
605
  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
606
  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
607
  } 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');
608
+ // Step 1: Check env var and keychain
609
+ password = resolveWalletPassword();
610
+
611
+ // Step 2: If --human flag, allow interactive prompt (requires TTY)
612
+ if (!password && flags.human && !process.stdin.isTTY && !deps.promptFn) {
613
+ log(JSON.stringify({
614
+ error: 'NOT_A_TTY',
615
+ message: '--human requires an interactive terminal. Set NANSEN_WALLET_PASSWORD env var instead.',
616
+ }));
525
617
  exit(1);
526
618
  return;
527
619
  }
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');
620
+ if (!password && flags.human && (process.stdin.isTTY || deps.promptFn)) {
621
+ password = await promptPassword('Enter wallet password: ', deps);
622
+ if (password && password.length < 12) {
623
+ log('❌ Password must be at least 12 characters');
535
624
  exit(1);
536
625
  return;
537
626
  }
627
+ if (password) {
628
+ const config = getWalletConfig();
629
+ if (!config.passwordHash) {
630
+ const confirm = await promptPassword('Confirm password: ', deps);
631
+ if (password !== confirm) {
632
+ log('❌ Passwords do not match');
633
+ exit(1);
634
+ return;
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ // Step 3: No password available — return structured error for agents
641
+ if (!password) {
642
+ log(JSON.stringify({
643
+ error: 'PASSWORD_REQUIRED',
644
+ message: 'A wallet password is required. Ask the user to provide one.',
645
+ instructions: 'Re-run with: NANSEN_WALLET_PASSWORD=<password> nansen wallet create',
646
+ 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.',
647
+ }));
648
+ exit(1);
649
+ return;
650
+ }
651
+
652
+ if (password.length < 12) {
653
+ log('❌ Password must be at least 12 characters');
654
+ exit(1);
655
+ return;
656
+ }
657
+ }
658
+
659
+ // Verify password matches existing wallets BEFORE touching keychain
660
+ if (password !== null) {
661
+ const config = getWalletConfig();
662
+ if (config.passwordHash && !verifyPassword(password, config)) {
663
+ log('❌ Incorrect password — does not match existing wallets.');
664
+ exit(1);
665
+ return;
538
666
  }
539
667
  }
540
668
 
669
+ // Persist password BEFORE creating wallet so we know the storage situation
670
+ let storageResult = { stored: false, method: 'none' };
671
+ if (password !== null) {
672
+ storageResult = storePassword(password);
673
+ }
674
+
541
675
  try {
542
676
  const result = createWallet(name, password);
543
677
  log(`\n✓ Wallet "${result.name}" created\n`);
@@ -551,9 +685,24 @@ export function buildWalletCommands(deps = {}) {
551
685
  log('');
552
686
  if (password === null) {
553
687
  log(' ⚠️ This is an UNENCRYPTED hot wallet — private keys are stored in plaintext on disk.');
688
+ } else if (storageResult.stored && storageResult.method === 'keychain') {
689
+ log(' ✓ Password saved to system keychain (secure).');
690
+ log(' Future wallet operations will retrieve the password automatically.');
691
+ } else if (storageResult.stored && storageResult.method === 'file') {
692
+ log(' ⚠️ No OS keychain available. Password saved to ~/.nansen/wallets/.credentials (insecure — plaintext on disk).');
693
+ log(' Future wallet operations will retrieve the password automatically.');
694
+ log(' To improve security: migrate to OS keychain with `nansen wallet secure`,');
695
+ log(' or set NANSEN_WALLET_PASSWORD via a secrets manager.');
554
696
  } 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.');
697
+ log(' ⚠️ CRITICAL: Password could not be saved anywhere (no keychain, no writable filesystem).');
698
+ log(' You MUST set NANSEN_WALLET_PASSWORD in your environment for ALL future wallet operations.');
699
+ log(' If you lose this password, your funds are UNRECOVERABLE.');
700
+ }
701
+ if (password !== null) {
702
+ log('');
703
+ log(' IMPORTANT: Back up your password separately (e.g. password manager).');
704
+ log(' If you lose access to this machine AND forget the password, funds are unrecoverable.');
705
+ log(' This is a hot wallet — do not deposit more than you can afford to lose.');
557
706
  }
558
707
  log('');
559
708
  return;
@@ -572,7 +721,8 @@ export function buildWalletCommands(deps = {}) {
572
721
  log('');
573
722
  for (const w of result.wallets) {
574
723
  const star = w.isDefault ? ' ★' : '';
575
- log(` ${w.name}${star}`);
724
+ const providerTag = w.provider === 'privy' ? ' (privy)' : '';
725
+ log(` ${w.name}${star}${providerTag}`);
576
726
  log(` EVM: ${w.evm}`);
577
727
  log(` Solana: ${w.solana}`);
578
728
  log('');
@@ -589,7 +739,8 @@ export function buildWalletCommands(deps = {}) {
589
739
  try {
590
740
  const result = showWallet(name);
591
741
  const star = result.isDefault ? ' ★' : '';
592
- log(`\n ${result.name}${star}`);
742
+ const providerTag = result.provider === 'privy' ? ' (privy)' : '';
743
+ log(`\n ${result.name}${star}${providerTag}`);
593
744
  log(` EVM: ${result.evm}`);
594
745
  log(` Solana: ${result.solana}`);
595
746
  log(` Created: ${result.createdAt}\n`);
@@ -607,10 +758,14 @@ export function buildWalletCommands(deps = {}) {
607
758
  exit(1);
608
759
  return;
609
760
  }
761
+
610
762
  const config = getWalletConfig();
611
- const password = config.passwordHash
612
- ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
613
- : null;
763
+ const { password, error } = await resolvePasswordForCommand(config, flags, deps);
764
+ if (error) {
765
+ log(error);
766
+ exit(1);
767
+ return;
768
+ }
614
769
  try {
615
770
  const result = exportWallet(name, password);
616
771
  log(`\n⚠️ Private keys for "${result.name}" — do not share!\n`);
@@ -652,13 +807,33 @@ export function buildWalletCommands(deps = {}) {
652
807
  exit(1);
653
808
  return;
654
809
  }
655
- const config = getWalletConfig();
656
- const password = config.passwordHash
657
- ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
658
- : null;
810
+
811
+ // Check if this is a Privy wallet (no password needed)
812
+ let isPrivy = false;
659
813
  try {
660
- const result = deleteWallet(name, password);
814
+ const walletFile = path.join(getWalletsDir(), `${name}.json`);
815
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
816
+ if (data.provider === 'privy') isPrivy = true;
817
+ } catch { /* file might not exist, deleteWallet will throw */ }
818
+
819
+ let password = null;
820
+ if (!isPrivy) {
821
+ const config = getWalletConfig();
822
+ const resolved = await resolvePasswordForCommand(config, flags, deps);
823
+ if (resolved.error) {
824
+ log(resolved.error);
825
+ exit(1);
826
+ return;
827
+ }
828
+ password = resolved.password;
829
+ }
830
+
831
+ try {
832
+ const result = await deleteWallet(name, password);
661
833
  log(`✓ Wallet "${result.deleted}" deleted`);
834
+ if (isPrivy) {
835
+ log(` Note: server-side wallet still exists on Privy`);
836
+ }
662
837
  if (result.newDefault) {
663
838
  log(` New default: ${result.newDefault}`);
664
839
  }
@@ -698,14 +873,32 @@ export function buildWalletCommands(deps = {}) {
698
873
  }
699
874
 
700
875
  const isWalletConnect = options.wallet === 'walletconnect' || options.wallet === 'wc';
876
+
877
+ // Check if the wallet is Privy (no password needed)
878
+ let isPrivyWallet = false;
879
+ if (!isWalletConnect) {
880
+ try {
881
+ const walletName = options.wallet || getWalletConfig().defaultWallet;
882
+ if (walletName) {
883
+ const walletFile = path.join(getWalletsDir(), `${walletName}.json`);
884
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
885
+ if (data.provider === 'privy') isPrivyWallet = true;
886
+ }
887
+ } catch { /* ignore */ }
888
+ }
889
+
701
890
  let password;
702
- if (isWalletConnect) {
891
+ if (isWalletConnect || isPrivyWallet) {
703
892
  password = null;
704
893
  } else {
705
894
  const sendConfig = getWalletConfig();
706
- password = sendConfig.passwordHash
707
- ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
708
- : null;
895
+ const resolved = await resolvePasswordForCommand(sendConfig, flags, deps);
896
+ if (resolved.error) {
897
+ log(resolved.error);
898
+ exit(1);
899
+ return;
900
+ }
901
+ password = resolved.password;
709
902
  }
710
903
  const dryRun = flags['dry-run'] || flags.dryRun;
711
904
 
@@ -723,7 +916,6 @@ export function buildWalletCommands(deps = {}) {
723
916
  };
724
917
 
725
918
  if (dryRun) {
726
- // Build the transaction but don't broadcast
727
919
  const result = await sendTokens(sendOpts);
728
920
  log(`\nDry run — transaction not broadcast\n`);
729
921
  log(` From: ${result.from}`);
@@ -755,46 +947,138 @@ export function buildWalletCommands(deps = {}) {
755
947
  }
756
948
  },
757
949
 
950
+ 'forget-password': async () => {
951
+ const result = deletePassword();
952
+ if (result.keychain || result.file) {
953
+ log('✓ Password removed from:');
954
+ if (result.keychain) log(' - System keychain');
955
+ if (result.file) log(' - Credentials file (~/.nansen/wallets/.credentials)');
956
+ } else {
957
+ log('No saved password found (keychain or credentials file).');
958
+ }
959
+ },
960
+
961
+ 'secure': async () => {
962
+ const { password, source } = retrievePassword();
963
+ if (!password) {
964
+ log(JSON.stringify({
965
+ error: 'NO_PASSWORD_FOUND',
966
+ message: 'No wallet password found in any store.',
967
+ resolution: [
968
+ 'Set NANSEN_WALLET_PASSWORD and run: nansen wallet secure',
969
+ 'This will store it in the OS keychain.',
970
+ ],
971
+ }));
972
+ exit(1);
973
+ return;
974
+ }
975
+
976
+ if (source === 'keychain') {
977
+ log('✓ Password is already stored in the OS keychain (secure).');
978
+ return;
979
+ }
980
+
981
+ // Verify password actually decrypts wallets before overwriting keychain
982
+ const walletConfig = getWalletConfig();
983
+ if (walletConfig.passwordHash && !verifyPassword(password, walletConfig)) {
984
+ log(JSON.stringify({
985
+ error: 'INCORRECT_PASSWORD',
986
+ message: `Password from '${source}' does not match the wallet's stored hash.`,
987
+ resolution: source === 'file'
988
+ ? [
989
+ 'The password in ~/.nansen/wallets/.credentials is incorrect.',
990
+ 'Run: nansen wallet forget-password then re-run with the correct password: NANSEN_WALLET_PASSWORD=<pw> nansen wallet secure',
991
+ ]
992
+ : [
993
+ 'Unset NANSEN_WALLET_PASSWORD if it is stale, then re-run: nansen wallet secure',
994
+ ],
995
+ }));
996
+ exit(1);
997
+ return;
998
+ }
999
+
1000
+ // Try to migrate to keychain
1001
+ const { stored, method } = storePassword(password);
1002
+ if (stored && method === 'keychain') {
1003
+ const fileRemoved = deleteCredentialsFile();
1004
+ const fromLabel = source === 'file'
1005
+ ? '~/.nansen/wallets/.credentials file'
1006
+ : 'NANSEN_WALLET_PASSWORD env var';
1007
+ log(`✓ Password migrated from ${fromLabel} → OS keychain (secure).`);
1008
+ if (fileRemoved) {
1009
+ log(' Removed ~/.nansen/wallets/.credentials.');
1010
+ }
1011
+ } else {
1012
+ log(JSON.stringify({
1013
+ error: 'KEYCHAIN_UNAVAILABLE',
1014
+ message: source === 'file'
1015
+ ? 'OS keychain is not available. Password remains in ~/.nansen/wallets/.credentials (insecure).'
1016
+ : 'OS keychain is not available. Password is only in the NANSEN_WALLET_PASSWORD env var (not persisted).',
1017
+ resolution: [
1018
+ 'Set NANSEN_WALLET_PASSWORD in a secrets manager or system keyring',
1019
+ 'Use a containerized secrets agent (e.g. Vault, 1Password CLI)',
1020
+ ],
1021
+ }));
1022
+ exit(1);
1023
+ return;
1024
+ }
1025
+ },
1026
+
758
1027
  'help': async () => {
759
1028
  log(`
760
- Wallet Management - Local key storage for EVM and Solana
1029
+ Wallet Management - EVM and Solana wallets (local or Privy server-side)
761
1030
 
762
1031
  USAGE:
763
1032
  nansen wallet <command> [options]
764
1033
 
765
1034
  COMMANDS:
766
- create [--name <label>] [--unsafe-no-password]
1035
+ create [--name <label>] [--provider <local|privy>] [--unsafe-no-password]
767
1036
  Create a new wallet pair (EVM + Solana)
768
1037
  list List all wallets
769
1038
  show <name> Show wallet addresses
770
- export <name> Export private keys (requires password)
1039
+ export <name> Export private keys (local wallets only, requires password)
771
1040
  default <name> Set the default wallet
772
- delete <name> Delete a wallet (requires password)
1041
+ delete <name> Delete a wallet
773
1042
  send --to <address> --amount <number> --chain <evm|solana> [--token <address>] [--wallet <name>] [--max] [--dry-run]
774
1043
  Send tokens or native currency (--max sends entire balance, --dry-run previews without sending)
1044
+ forget-password Remove saved password from all stores
1045
+ secure Migrate password from insecure storage to OS keychain
775
1046
 
776
1047
  OPTIONS:
777
1048
  --name <label> Wallet name (default: "default")
1049
+ --provider <local|privy> Wallet provider: "local" (default) stores encrypted keys on disk,
1050
+ "privy" creates server-side wallets via Privy API
778
1051
  --to <address> Recipient address (required for send)
779
1052
  --amount <number> Amount to send in human-readable format (required unless --max)
780
1053
  --chain <evm|solana> Blockchain to use (required for send)
781
1054
  --token <address> Token contract/mint address (optional, sends native if omitted)
782
1055
  --wallet <name> Wallet to use (optional, uses default if omitted; use "walletconnect" or "wc" for WalletConnect, EVM only)
783
1056
  --max Send entire balance (deducts gas for native transfers)
784
- --unsafe-no-password Skip encryption — private keys stored UNENCRYPTED on disk (create only)
1057
+ --unsafe-no-password Skip encryption — private keys stored UNENCRYPTED on disk (local only)
1058
+ --human Enable interactive prompts (for human terminal use only)
1059
+
1060
+ PASSWORD RESOLUTION (automatic, in order):
1061
+ 1. NANSEN_WALLET_PASSWORD env var
1062
+ 2. OS keychain (saved automatically on wallet create)
1063
+ 3. Interactive prompt (only with --human flag)
785
1064
 
786
1065
  ENVIRONMENT:
787
- NANSEN_WALLET_PASSWORD Password for non-interactive use (e.g. CI/scripts)
1066
+ NANSEN_WALLET_PASSWORD Wallet encryption password
1067
+ PRIVY_APP_ID Privy application ID (required for --provider privy)
1068
+ PRIVY_APP_SECRET Privy application secret (required for --provider privy)
1069
+ NANSEN_WALLET_PROVIDER Default provider for wallet create ("local" or "privy")
788
1070
  NANSEN_EVM_RPC Custom EVM RPC endpoint
789
1071
  NANSEN_SOLANA_RPC Custom Solana RPC endpoint
790
1072
 
791
1073
  EXAMPLES:
792
- nansen wallet create --name trading
1074
+ NANSEN_WALLET_PASSWORD=mypass nansen wallet create --name trading
1075
+ nansen wallet create --name agent-wallet --provider privy
793
1076
  nansen wallet list
794
1077
  nansen wallet export trading
795
1078
  nansen wallet default trading
796
1079
  nansen wallet send --to 0x742d35Cc... --amount 1.5 --chain evm
797
1080
  nansen wallet send --to 9WzDXw... --amount 0.1 --chain solana --token So11...
1081
+ nansen wallet forget-password
798
1082
  `);
799
1083
  return;
800
1084
  },