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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#216](https://github.com/nansen-ai/nansen-cli/pull/216) [`5b88241`](https://github.com/nansen-ai/nansen-cli/commit/5b882411134efc5ece44d640adc105c8dd8c5771) Thanks [@TimNooren](https://github.com/TimNooren)! - Unified wallet abstraction: Privy server wallets are first-class citizens.
8
+
9
+ - `wallet create --provider privy` creates EVM + Solana wallets via Privy and stores a local reference
10
+ - All wallet commands (list, show, delete, default, send) work by name regardless of provider
11
+ - Trading (quote + execute) supports Privy wallets with sign-only + Trading API broadcast
12
+ - x402 auto-payment routes through Privy when credentials are configured
13
+
14
+ ### Patch Changes
15
+
16
+ - [#232](https://github.com/nansen-ai/nansen-cli/pull/232) [`443aaad`](https://github.com/nansen-ai/nansen-cli/commit/443aaad15da051ac65e0999b4c4b09436050d0fe) Thanks [@kome12](https://github.com/kome12)! - Remove unsupported chains (zksync, unichain) from CLI
17
+
18
+ ## 1.14.0
19
+
20
+ ### Minor Changes
21
+
22
+ - [#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
23
+
24
+ - **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.
25
+ - **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.
26
+ - **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.
27
+ - **New commands**: `wallet secure` (migrate to keychain), `wallet forget-password` (clear from all stores).
28
+ - **Bug fixes**: Clear `passwordHash` on last wallet delete, verify password before keychain writes, exit non-zero when keychain migration fails, source-aware error messages.
29
+ - **New skill**: `nansen-wallet-migration` for migrating from old `~/.nansen/.env` storage to keychain.
30
+
3
31
  ## 1.13.1
4
32
 
5
33
  ### Patch Changes
package/README.md CHANGED
@@ -55,7 +55,7 @@ Run `nansen schema --pretty` for the full subcommand and field reference.
55
55
 
56
56
  ## Supported Chains
57
57
 
58
- `ethereum` `solana` `base` `bnb` `arbitrum` `polygon` `optimism` `avalanche` `linea` `scroll` `zksync` `mantle` `ronin` `sei` `plasma` `sonic` `unichain` `monad` `hyperevm` `iotaevm`
58
+ `ethereum` `solana` `base` `bnb` `arbitrum` `polygon` `optimism` `avalanche` `linea` `scroll` `mantle` `ronin` `sei` `plasma` `sonic` `monad` `hyperevm` `iotaevm`
59
59
 
60
60
  > Run `nansen schema` to get the current chain list (source of truth).
61
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.13.1",
3
+ "version": "1.15.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "test:live": "NANSEN_LIVE_TEST=1 vitest run",
22
22
  "test:trade": "vitest run --config vitest.e2e.config.js src/__tests__/trade.e2e.test.js",
23
23
  "test:send": "vitest run --config vitest.e2e.config.js src/__tests__/send.e2e.test.js",
24
+ "test:privy": "vitest run --config vitest.e2e.config.js src/__tests__/privy.e2e.test.js",
24
25
  "lint": "eslint .",
25
26
  "lint:fix": "eslint . --fix",
26
27
  "changeset": "changeset",
package/src/api.js CHANGED
@@ -504,90 +504,133 @@ export class NansenAPI {
504
504
  const hasManualSignature = !!(this.defaultHeaders['Payment-Signature'] || options.headers?.['Payment-Signature']);
505
505
 
506
506
  if (!hasManualSignature) {
507
- // 1. Try local wallet with fallback across payment networks
507
+ // Determine payment method from default wallet's provider
508
+ let defaultWalletProvider = 'local';
509
+ let defaultWalletName = 'unknown';
508
510
  try {
509
- const { createPaymentSignatures } = await import('./x402.js');
510
- for await (const { signature, network } of createPaymentSignatures(response, url)) {
511
- const paidResponse = await fetch(url, {
512
- method: 'POST',
513
- headers: {
514
- 'Content-Type': 'application/json',
515
- 'X-Client-Type': 'nansen-cli',
516
- 'X-Client-Version': packageVersion,
517
- 'Payment-Signature': signature,
518
- ...this.defaultHeaders,
519
- ...options.headers,
520
- },
521
- body: JSON.stringify(NansenAPI.cleanBody(body)),
522
- });
523
- if (paidResponse.ok) {
524
- const chain = network.startsWith('solana:') ? 'Solana' : 'Base';
525
- console.error(`[x402] Paid via ${chain} USDC`);
526
- // Check remaining balance and warn if low
527
- try {
528
- const { checkX402Balance } = await import('./x402.js');
529
- const balance = await checkX402Balance(network);
530
- if (balance !== null && balance < 0.25) {
531
- console.error(`[x402] Warning: USDC balance low ($${balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
532
- }
533
- } catch { /* balance check is best-effort */ }
534
- return await paidResponse.json();
535
- }
536
- // This payment option was rejected, try next
511
+ const { getWalletConfig, showWallet } = await import('./wallet.js');
512
+ const walletConfig = getWalletConfig();
513
+ if (walletConfig.defaultWallet) {
514
+ defaultWalletName = walletConfig.defaultWallet;
515
+ const wallet = showWallet(walletConfig.defaultWallet);
516
+ defaultWalletProvider = wallet.provider || 'local';
537
517
  }
538
- } catch { /* local wallet unavailable, try WalletConnect */ }
539
-
540
- // 2. Fall back to WalletConnect (walletconnect-x402.js)
541
- // (local wallet returns early on success above, so we always reach here if it failed)
542
- {
543
- let paymentRequirements;
544
- const paymentHeader = response.headers.get('payment-required');
545
- if (paymentHeader) {
546
- try {
547
- paymentRequirements = JSON.parse(atob(paymentHeader));
548
- } catch {
549
- data.paymentRequiredRaw = paymentHeader;
518
+ } catch (err) {
519
+ if (process.env.DEBUG) console.error(`[x402] Failed to detect wallet provider: ${err.message}`);
520
+ }
521
+
522
+ if (defaultWalletProvider === 'privy') {
523
+ // Default wallet is Privy: sign via Privy
524
+ try {
525
+ const { createPrivyPaymentSignatures } = await import('./privy.js');
526
+ for await (const { signature, network } of createPrivyPaymentSignatures(response, url)) {
527
+ const paidResponse = await fetch(url, {
528
+ method: 'POST',
529
+ headers: {
530
+ 'Content-Type': 'application/json',
531
+ 'X-Client-Type': 'nansen-cli',
532
+ 'X-Client-Version': packageVersion,
533
+ 'Payment-Signature': signature,
534
+ ...this.defaultHeaders,
535
+ ...options.headers,
536
+ },
537
+ body: JSON.stringify(NansenAPI.cleanBody(body)),
538
+ });
539
+ if (paidResponse.ok) {
540
+ console.error(`[x402] Paid via Privy wallet ${defaultWalletName} (${network})`);
541
+ try {
542
+ const { checkX402Balance } = await import('./x402.js');
543
+ const balance = await checkX402Balance(network);
544
+ if (balance !== null && balance < 0.25) {
545
+ console.error(`[x402] Warning: USDC balance low ($${balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
546
+ }
547
+ } catch { /* balance check is best-effort */ }
548
+ return await paidResponse.json();
549
+ }
550
550
  }
551
+ } catch (privyErr) {
552
+ message = `x402 Privy payment failed: ${privyErr.message}`;
551
553
  }
552
- if (!paymentRequirements && data.paymentRequirements) {
553
- paymentRequirements = data.paymentRequirements;
554
- }
555
-
556
- if (paymentRequirements) {
557
- try {
558
- const { handleX402Payment } = await import('./walletconnect-x402.js');
559
- const paymentSignature = await handleX402Payment(paymentRequirements);
554
+ } else {
555
+ // Local wallet or no wallet: existing local wallet + WalletConnect flow
556
+ // 1. Try local wallet with fallback across payment networks
557
+ try {
558
+ const { createPaymentSignatures } = await import('./x402.js');
559
+ for await (const { signature, network } of createPaymentSignatures(response, url)) {
560
560
  const paidResponse = await fetch(url, {
561
561
  method: 'POST',
562
562
  headers: {
563
563
  'Content-Type': 'application/json',
564
564
  'X-Client-Type': 'nansen-cli',
565
565
  'X-Client-Version': packageVersion,
566
- 'Payment-Signature': paymentSignature,
566
+ 'Payment-Signature': signature,
567
567
  ...this.defaultHeaders,
568
568
  ...options.headers,
569
569
  },
570
570
  body: JSON.stringify(NansenAPI.cleanBody(body)),
571
571
  });
572
572
  if (paidResponse.ok) {
573
+ console.error(`[x402] Paid via local wallet ${defaultWalletName} (${network})`);
574
+ // Check remaining balance and warn if low
575
+ try {
576
+ const { checkX402Balance } = await import('./x402.js');
577
+ const balance = await checkX402Balance(network);
578
+ if (balance !== null && balance < 0.25) {
579
+ console.error(`[x402] Warning: USDC balance low ($${balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
580
+ }
581
+ } catch { /* balance check is best-effort */ }
573
582
  return await paidResponse.json();
574
583
  }
575
- } catch (x402Err) {
576
- if (!this.apiKey) {
577
- // No API key and no payment wallet guide the user to login rather than
578
- // showing a confusing x402 payment dump they can't act on.
579
- // TODO: full fix would skip x402 entirely when no apiKey is set — see PR #<this PR number>
580
- message = 'No API key configured. Two ways to authenticate:\n' +
581
- ' 1. API key: nansen login --api-key <key> (get key at https://app.nansen.ai/api)\n' +
582
- ' 2. x402 micropayment: nansen wallet create + fund with USDC (no API key needed)';
583
- } else {
584
- message = `x402 auto-payment failed: ${x402Err.message}`;
584
+ // This payment option was rejected, try next
585
+ }
586
+ } catch { /* local wallet unavailable, try WalletConnect */ }
587
+
588
+ // 2. Fall back to WalletConnect (walletconnect-x402.js)
589
+ {
590
+ let paymentRequirements;
591
+ const paymentHeader = response.headers.get('payment-required');
592
+ if (paymentHeader) {
593
+ try {
594
+ paymentRequirements = JSON.parse(atob(paymentHeader));
595
+ } catch {
596
+ data.paymentRequiredRaw = paymentHeader;
585
597
  }
586
598
  }
587
- // Only include raw payment requirements in the error details when the user
588
- // has an API key — for unauthenticated users they add noise, not signal.
589
- if (this.apiKey) {
590
- data.paymentRequirements = paymentRequirements;
599
+ if (!paymentRequirements && data.paymentRequirements) {
600
+ paymentRequirements = data.paymentRequirements;
601
+ }
602
+
603
+ if (paymentRequirements) {
604
+ try {
605
+ const { handleX402Payment } = await import('./walletconnect-x402.js');
606
+ const paymentSignature = await handleX402Payment(paymentRequirements);
607
+ const paidResponse = await fetch(url, {
608
+ method: 'POST',
609
+ headers: {
610
+ 'Content-Type': 'application/json',
611
+ 'X-Client-Type': 'nansen-cli',
612
+ 'X-Client-Version': packageVersion,
613
+ 'Payment-Signature': paymentSignature,
614
+ ...this.defaultHeaders,
615
+ ...options.headers,
616
+ },
617
+ body: JSON.stringify(NansenAPI.cleanBody(body)),
618
+ });
619
+ if (paidResponse.ok) {
620
+ return await paidResponse.json();
621
+ }
622
+ } catch (x402Err) {
623
+ if (!this.apiKey) {
624
+ message = 'No API key configured. Two ways to authenticate:\n' +
625
+ ' 1. API key: nansen login --api-key <key> (get key at https://app.nansen.ai/api)\n' +
626
+ ' 2. x402 micropayment: nansen wallet create + fund with USDC (no API key needed)';
627
+ } else {
628
+ message = `x402 auto-payment failed: ${x402Err.message}`;
629
+ }
630
+ }
631
+ if (this.apiKey) {
632
+ data.paymentRequirements = paymentRequirements;
633
+ }
591
634
  }
592
635
  }
593
636
  }
package/src/chain-ids.js CHANGED
@@ -14,7 +14,6 @@ export const EVM_CHAIN_IDS = {
14
14
  bnb: 56,
15
15
  linea: 59144,
16
16
  scroll: 534352,
17
- zksync: 324,
18
17
  mantle: 5000,
19
18
  };
20
19
 
@@ -25,6 +24,6 @@ export const EVM_CHAIN_IDS = {
25
24
  */
26
25
  export const EVM_CHAINS = [
27
26
  'ethereum', 'arbitrum', 'base', 'bnb', 'polygon', 'optimism',
28
- 'avalanche', 'linea', 'scroll', 'zksync', 'mantle', 'ronin',
29
- 'sei', 'plasma', 'sonic', 'unichain', 'monad', 'hyperevm', 'iotaevm',
27
+ 'avalanche', 'linea', 'scroll', 'mantle', 'ronin',
28
+ 'sei', 'plasma', 'sonic', 'monad', 'hyperevm', 'iotaevm',
30
29
  ];
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,9 +684,9 @@ 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
- Research chains: ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, zksync, mantle, ronin, sei, plasma, sonic, unichain, monad, hyperevm, iotaevm
689
+ Research chains: ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, mantle, ronin, sei, plasma, sonic, monad, hyperevm, iotaevm
690
690
  Trade chains: solana, base
691
691
  Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
692
692
 
@@ -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
+ }