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 +28 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/api.js +107 -64
- package/src/chain-ids.js +2 -3
- package/src/cli.js +31 -23
- package/src/keychain.js +229 -0
- package/src/privy.js +359 -0
- package/src/schema.json +42 -2
- package/src/trading.js +264 -118
- package/src/transfer.js +150 -25
- package/src/wallet.js +354 -70
- package/src/x402-svm.js +43 -24
- package/src/x402.js +2 -2
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` `
|
|
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.
|
|
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
|
-
//
|
|
507
|
+
// Determine payment method from default wallet's provider
|
|
508
|
+
let defaultWalletProvider = 'local';
|
|
509
|
+
let defaultWalletName = 'unknown';
|
|
508
510
|
try {
|
|
509
|
-
const {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
{
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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':
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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', '
|
|
29
|
-
'sei', 'plasma', 'sonic', '
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
765
|
-
log(' nansen login
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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(
|
|
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'
|
package/src/keychain.js
ADDED
|
@@ -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
|
+
}
|