nansen-cli 1.5.1 → 1.7.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 ADDED
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Nansen CLI - Wallet Management
3
+ * Local key generation and storage for EVM and Solana chains.
4
+ * Zero external dependencies — uses Node.js built-in crypto only.
5
+ */
6
+
7
+ import crypto from 'crypto';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import * as readline from 'readline';
11
+
12
+ // ============= Constants =============
13
+
14
+ function getWalletsDir() {
15
+ const configDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
16
+ return path.join(configDir, 'wallets');
17
+ }
18
+ function getWalletConfigPath() {
19
+ return path.join(getWalletsDir(), 'config.json');
20
+ }
21
+
22
+ // Encryption parameters
23
+ const SCRYPT_N = 131072;
24
+ const SCRYPT_R = 8;
25
+ const SCRYPT_P = 1;
26
+ const SCRYPT_KEYLEN = 32;
27
+ const SALT_LEN = 16;
28
+ const IV_LEN = 12;
29
+ const AUTH_TAG_LEN = 16;
30
+
31
+ import { keccak256 } from './crypto.js';
32
+
33
+ // ============= Base58 Encoding (for Solana) =============
34
+
35
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
36
+
37
+ /**
38
+ * Encode a Buffer to base58 string.
39
+ */
40
+ export function base58Encode(buf) {
41
+ let num = 0n;
42
+ for (const byte of buf) {
43
+ num = num * 256n + BigInt(byte);
44
+ }
45
+
46
+ let str = '';
47
+ while (num > 0n) {
48
+ const rem = Number(num % 58n);
49
+ num = num / 58n;
50
+ str = BASE58_ALPHABET[rem] + str;
51
+ }
52
+
53
+ // Leading zeros → leading '1's
54
+ for (const byte of buf) {
55
+ if (byte === 0) str = '1' + str;
56
+ else break;
57
+ }
58
+
59
+ return str || '1';
60
+ }
61
+
62
+ // ============= Encryption =============
63
+
64
+ /**
65
+ * Derive encryption key from password using scrypt.
66
+ */
67
+ function deriveKey(password, salt) {
68
+ return crypto.scryptSync(password, salt, SCRYPT_KEYLEN, {
69
+ N: SCRYPT_N,
70
+ r: SCRYPT_R,
71
+ p: SCRYPT_P,
72
+ maxmem: 256 * 1024 * 1024, // 256MB — needed for N=131072
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Encrypt a private key with a password.
78
+ * Returns a JSON-serializable object with all params needed for decryption.
79
+ */
80
+ export function encryptKey(privateKeyHex, password) {
81
+ const salt = crypto.randomBytes(SALT_LEN);
82
+ const iv = crypto.randomBytes(IV_LEN);
83
+ const key = deriveKey(password, salt);
84
+
85
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
86
+ const encrypted = Buffer.concat([cipher.update(privateKeyHex, 'utf8'), cipher.final()]);
87
+ const authTag = cipher.getAuthTag();
88
+
89
+ return {
90
+ cipher: 'aes-256-gcm',
91
+ kdf: 'scrypt',
92
+ kdfParams: { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P },
93
+ salt: salt.toString('hex'),
94
+ iv: iv.toString('hex'),
95
+ authTag: authTag.toString('hex'),
96
+ ciphertext: encrypted.toString('hex'),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Decrypt a private key with a password.
102
+ * @returns {string} Private key hex string
103
+ * @throws {Error} If password is wrong
104
+ */
105
+ export function decryptKey(encryptedData, password) {
106
+ const salt = Buffer.from(encryptedData.salt, 'hex');
107
+ const iv = Buffer.from(encryptedData.iv, 'hex');
108
+ const authTag = Buffer.from(encryptedData.authTag, 'hex');
109
+ const ciphertext = Buffer.from(encryptedData.ciphertext, 'hex');
110
+ const key = deriveKey(password, salt);
111
+
112
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
113
+ decipher.setAuthTag(authTag);
114
+
115
+ try {
116
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
117
+ return decrypted.toString('utf8');
118
+ } catch {
119
+ throw new Error('Incorrect password');
120
+ }
121
+ }
122
+
123
+ // ============= Key Generation =============
124
+
125
+ /**
126
+ * Generate an EVM wallet (secp256k1).
127
+ * Returns { privateKey, address } where address is checksummed.
128
+ */
129
+ export function generateEvmWallet() {
130
+ // Generate a random 32-byte private key
131
+ const privateKey = crypto.randomBytes(32);
132
+
133
+ // Derive public key (uncompressed, 65 bytes: 0x04 + x + y)
134
+ const ecdh = crypto.createECDH('secp256k1');
135
+ ecdh.setPrivateKey(privateKey);
136
+ const publicKey = ecdh.getPublicKey(null, 'uncompressed');
137
+
138
+ // Address = last 20 bytes of keccak256(publicKey without 0x04 prefix)
139
+ const hash = keccak256(publicKey.subarray(1));
140
+ const addressBytes = hash.subarray(12);
141
+ const addressHex = addressBytes.toString('hex');
142
+
143
+ // EIP-55 checksum
144
+ const addressHash = keccak256(Buffer.from(addressHex, 'utf8')).toString('hex');
145
+ let checksummed = '0x';
146
+ for (let i = 0; i < 40; i++) {
147
+ checksummed += parseInt(addressHash[i], 16) >= 8
148
+ ? addressHex[i].toUpperCase()
149
+ : addressHex[i];
150
+ }
151
+
152
+ const result = {
153
+ privateKey: privateKey.toString('hex'),
154
+ address: checksummed,
155
+ };
156
+
157
+ // Zero sensitive buffers (strings remain in heap — JS limitation)
158
+ privateKey.fill(0);
159
+
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Generate a Solana wallet (Ed25519).
165
+ * Returns { privateKey, address } where address is base58 public key.
166
+ */
167
+ export function generateSolanaWallet() {
168
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
169
+ publicKeyEncoding: { type: 'spki', format: 'der' },
170
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
171
+ });
172
+
173
+ // Extract raw 32-byte keys from DER encoding
174
+ // PKCS8 Ed25519 private key: last 32 bytes of the inner octet string
175
+ // The raw private seed is at a fixed offset in the DER structure
176
+ const rawPrivate = privateKey.subarray(privateKey.length - 32);
177
+ // SPKI Ed25519 public key: last 32 bytes
178
+ const rawPublic = publicKey.subarray(publicKey.length - 32);
179
+
180
+ // Solana keypair format: 64 bytes = private seed (32) + public key (32)
181
+ const keypair = Buffer.concat([rawPrivate, rawPublic]);
182
+
183
+ return {
184
+ privateKey: keypair.toString('hex'),
185
+ address: base58Encode(rawPublic),
186
+ };
187
+ }
188
+
189
+ // ============= Storage =============
190
+
191
+ function ensureWalletsDir() {
192
+ if (!fs.existsSync(getWalletsDir())) {
193
+ fs.mkdirSync(getWalletsDir(), { mode: 0o700, recursive: true });
194
+ }
195
+ }
196
+
197
+ function warnIfInsecurePerms(filePath) {
198
+ try {
199
+ const mode = fs.statSync(filePath).mode & 0o777;
200
+ if (mode & 0o077) { // group or other has any access
201
+ console.error(`⚠️ Warning: ${filePath} has insecure permissions (${mode.toString(8)}). Run: chmod 600 ${filePath}`);
202
+ }
203
+ } catch { /* ignore stat errors */ }
204
+ }
205
+
206
+ export function getWalletConfig() {
207
+ if (!fs.existsSync(getWalletConfigPath())) {
208
+ return { defaultWallet: null, passwordHash: null };
209
+ }
210
+ warnIfInsecurePerms(getWalletConfigPath());
211
+ return JSON.parse(fs.readFileSync(getWalletConfigPath(), 'utf8'));
212
+ }
213
+
214
+ function saveWalletConfig(config) {
215
+ ensureWalletsDir();
216
+ fs.writeFileSync(getWalletConfigPath(), JSON.stringify(config, null, 2), { mode: 0o600 });
217
+ }
218
+
219
+ const WALLET_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
220
+
221
+ function validateWalletName(name) {
222
+ if (!name || !WALLET_NAME_RE.test(name)) {
223
+ throw new Error('Wallet name must be 1-64 characters: letters, numbers, hyphens, underscores only');
224
+ }
225
+ }
226
+
227
+ function getWalletFile(name) {
228
+ validateWalletName(name);
229
+ return path.join(getWalletsDir(), `${name}.json`);
230
+ }
231
+
232
+ /**
233
+ * Verify the global password against stored hash.
234
+ */
235
+ export function verifyPassword(password, config) {
236
+ if (!config.passwordHash) return true; // No password set yet
237
+ const { salt, hash } = config.passwordHash;
238
+ const derived = crypto.scryptSync(password, Buffer.from(salt, 'hex'), 32, {
239
+ N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: 256 * 1024 * 1024,
240
+ });
241
+ return crypto.timingSafeEqual(derived, Buffer.from(hash, 'hex'));
242
+ }
243
+
244
+ /**
245
+ * Create a password hash for storage (NOT the encryption key, just for verification).
246
+ */
247
+ function hashPassword(password) {
248
+ const salt = crypto.randomBytes(16);
249
+ const hash = crypto.scryptSync(password, salt, 32, {
250
+ N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: 256 * 1024 * 1024,
251
+ });
252
+ return { salt: salt.toString('hex'), hash: hash.toString('hex') };
253
+ }
254
+
255
+ // ============= Prompt Helper =============
256
+
257
+ async function promptPassword(question, deps = {}) {
258
+ const promptFn = deps.promptFn;
259
+ if (promptFn) {
260
+ return promptFn(question, true);
261
+ }
262
+ // Fallback to readline
263
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
264
+ return new Promise((resolve) => {
265
+ if (process.stdout.isTTY) {
266
+ process.stdout.write(question);
267
+ let input = '';
268
+ process.stdin.setRawMode(true);
269
+ process.stdin.resume();
270
+ process.stdin.setEncoding('utf8');
271
+ const onData = (char) => {
272
+ if (char === '\n' || char === '\r') {
273
+ process.stdin.setRawMode(false);
274
+ process.stdin.pause();
275
+ process.stdin.removeListener('data', onData);
276
+ process.stdout.write('\n');
277
+ rl.close();
278
+ resolve(input);
279
+ } else if (char === '\u0003') {
280
+ process.exit();
281
+ } else if (char === '\u007F' || char === '\b') {
282
+ input = input.slice(0, -1);
283
+ } else {
284
+ input += char;
285
+ process.stdout.write('*');
286
+ }
287
+ };
288
+ process.stdin.on('data', onData);
289
+ } else {
290
+ rl.question(question, (answer) => { rl.close(); resolve(answer); });
291
+ }
292
+ });
293
+ }
294
+
295
+ // ============= Public API =============
296
+
297
+ /**
298
+ * List all wallets.
299
+ */
300
+ export function listWallets() {
301
+ ensureWalletsDir();
302
+ const config = getWalletConfig();
303
+ const files = fs.readdirSync(getWalletsDir()).filter(f => f.endsWith('.json') && f !== 'config.json');
304
+
305
+ const wallets = files.map(f => {
306
+ const data = JSON.parse(fs.readFileSync(path.join(getWalletsDir(), f), 'utf8'));
307
+ return {
308
+ name: data.name,
309
+ evm: data.evm?.address || null,
310
+ solana: data.solana?.address || null,
311
+ createdAt: data.createdAt,
312
+ isDefault: data.name === config.defaultWallet,
313
+ };
314
+ });
315
+
316
+ return { wallets, defaultWallet: config.defaultWallet };
317
+ }
318
+
319
+ /**
320
+ * Create a new wallet pair (EVM + Solana).
321
+ */
322
+ export function createWallet(name, password) {
323
+ ensureWalletsDir();
324
+ const config = getWalletConfig();
325
+ const walletFile = getWalletFile(name);
326
+
327
+ if (fs.existsSync(walletFile)) {
328
+ throw new Error(`Wallet "${name}" already exists`);
329
+ }
330
+
331
+ // If this is the first wallet, set the password hash
332
+ if (!config.passwordHash) {
333
+ config.passwordHash = hashPassword(password);
334
+ } else {
335
+ if (!verifyPassword(password, config)) {
336
+ throw new Error('Incorrect password');
337
+ }
338
+ }
339
+
340
+ const evm = generateEvmWallet();
341
+ const solana = generateSolanaWallet();
342
+
343
+ const wallet = {
344
+ name,
345
+ createdAt: new Date().toISOString(),
346
+ evm: {
347
+ address: evm.address,
348
+ encrypted: encryptKey(evm.privateKey, password),
349
+ },
350
+ solana: {
351
+ address: solana.address,
352
+ encrypted: encryptKey(solana.privateKey, password),
353
+ },
354
+ };
355
+
356
+ fs.writeFileSync(walletFile, JSON.stringify(wallet, null, 2), { mode: 0o600 });
357
+
358
+ // Set as default if it's the first wallet
359
+ if (!config.defaultWallet) {
360
+ config.defaultWallet = name;
361
+ }
362
+ saveWalletConfig(config);
363
+
364
+ return {
365
+ name,
366
+ evm: evm.address,
367
+ solana: solana.address,
368
+ isDefault: config.defaultWallet === name,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Show wallet details (addresses only, no keys).
374
+ */
375
+ export function showWallet(name) {
376
+ const walletFile = getWalletFile(name);
377
+ if (!fs.existsSync(walletFile)) {
378
+ throw new Error(`Wallet "${name}" not found`);
379
+ }
380
+
381
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
382
+ const config = getWalletConfig();
383
+
384
+ return {
385
+ name: data.name,
386
+ evm: data.evm?.address || null,
387
+ solana: data.solana?.address || null,
388
+ createdAt: data.createdAt,
389
+ isDefault: data.name === config.defaultWallet,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Export private keys for a wallet (requires password).
395
+ */
396
+ export function exportWallet(name, password) {
397
+ const walletFile = getWalletFile(name);
398
+ if (!fs.existsSync(walletFile)) {
399
+ throw new Error(`Wallet "${name}" not found`);
400
+ }
401
+
402
+ const config = getWalletConfig();
403
+ if (!verifyPassword(password, config)) {
404
+ throw new Error('Incorrect password');
405
+ }
406
+
407
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
408
+
409
+ return {
410
+ name: data.name,
411
+ evm: {
412
+ address: data.evm?.address,
413
+ privateKey: data.evm ? decryptKey(data.evm.encrypted, password) : null,
414
+ },
415
+ solana: {
416
+ address: data.solana?.address,
417
+ privateKey: data.solana ? decryptKey(data.solana.encrypted, password) : null,
418
+ },
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Set the default wallet.
424
+ */
425
+ export function setDefaultWallet(name) {
426
+ const walletFile = getWalletFile(name);
427
+ if (!fs.existsSync(walletFile)) {
428
+ throw new Error(`Wallet "${name}" not found`);
429
+ }
430
+
431
+ const config = getWalletConfig();
432
+ config.defaultWallet = name;
433
+ saveWalletConfig(config);
434
+
435
+ return { defaultWallet: name };
436
+ }
437
+
438
+ /**
439
+ * Delete a wallet.
440
+ */
441
+ export function deleteWallet(name, password) {
442
+ const walletFile = getWalletFile(name);
443
+ if (!fs.existsSync(walletFile)) {
444
+ throw new Error(`Wallet "${name}" not found`);
445
+ }
446
+
447
+ const config = getWalletConfig();
448
+ if (!verifyPassword(password, config)) {
449
+ throw new Error('Incorrect password');
450
+ }
451
+
452
+ fs.unlinkSync(walletFile);
453
+
454
+ if (config.defaultWallet === name) {
455
+ // Pick another wallet as default, or null
456
+ const remaining = fs.readdirSync(getWalletsDir()).filter(f => f.endsWith('.json') && f !== 'config.json');
457
+ config.defaultWallet = remaining.length > 0 ? remaining[0].replace('.json', '') : null;
458
+ saveWalletConfig(config);
459
+ }
460
+
461
+ return { deleted: name, newDefault: config.defaultWallet };
462
+ }
463
+
464
+ /**
465
+ * Get the default wallet's address for a given chain type.
466
+ */
467
+ export function getDefaultAddress(chainType = 'evm') {
468
+ const config = getWalletConfig();
469
+ if (!config.defaultWallet) {
470
+ throw new Error('No default wallet set. Run: nansen wallet create');
471
+ }
472
+
473
+ const wallet = showWallet(config.defaultWallet);
474
+ const field = chainType === 'solana' ? 'solana' : 'evm';
475
+ return wallet[field];
476
+ }
477
+
478
+ // ============= CLI Command Builder =============
479
+
480
+ /**
481
+ * Build wallet command handlers for integration into CLI.
482
+ */
483
+ export function buildWalletCommands(deps = {}) {
484
+ const { log = console.log, promptFn, exit = process.exit } = deps;
485
+
486
+ return {
487
+ 'wallet': async (args, apiInstance, flags, options) => {
488
+ const subcommand = args[0] || 'help';
489
+
490
+ const handlers = {
491
+ 'create': async () => {
492
+ const name = options.name || args[1] || 'default';
493
+ const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
494
+ if (!password || password.length < 12) {
495
+ log('❌ Password must be at least 12 characters');
496
+ exit(1);
497
+ return;
498
+ }
499
+
500
+ // Confirm password for first wallet (skip if set via env var)
501
+ const config = getWalletConfig();
502
+ if (!config.passwordHash && !process.env.NANSEN_WALLET_PASSWORD) {
503
+ const confirm = await promptPassword('Confirm password: ', deps);
504
+ if (password !== confirm) {
505
+ log('❌ Passwords do not match');
506
+ exit(1);
507
+ return;
508
+ }
509
+ }
510
+
511
+ try {
512
+ const result = createWallet(name, password);
513
+ log(`\n✓ Wallet "${result.name}" created\n`);
514
+ log(` EVM: ${result.evm}`);
515
+ log(` Solana: ${result.solana}`);
516
+ if (result.isDefault) log(` ★ Set as default wallet`);
517
+ log('');
518
+ log(' Fund this wallet to start making API calls or trading:');
519
+ log(` Base (recommended, lower fees): send USDC to ${result.evm}`);
520
+ log(` Solana: send USDC to ${result.solana}`);
521
+ log('');
522
+ return result;
523
+ } catch (err) {
524
+ log(`❌ ${err.message}`);
525
+ exit(1);
526
+ }
527
+ },
528
+
529
+ 'list': async () => {
530
+ const result = listWallets();
531
+ if (result.wallets.length === 0) {
532
+ log('No wallets found. Create one with: nansen wallet create');
533
+ return result;
534
+ }
535
+ log('');
536
+ for (const w of result.wallets) {
537
+ const star = w.isDefault ? ' ★' : '';
538
+ log(` ${w.name}${star}`);
539
+ log(` EVM: ${w.evm}`);
540
+ log(` Solana: ${w.solana}`);
541
+ log('');
542
+ }
543
+ return result;
544
+ },
545
+
546
+ 'show': async () => {
547
+ const name = options.name || args[1];
548
+ if (!name) {
549
+ log('Usage: nansen wallet show <name>');
550
+ exit(1);
551
+ return;
552
+ }
553
+ try {
554
+ const result = showWallet(name);
555
+ const star = result.isDefault ? ' ★' : '';
556
+ log(`\n ${result.name}${star}`);
557
+ log(` EVM: ${result.evm}`);
558
+ log(` Solana: ${result.solana}`);
559
+ log(` Created: ${result.createdAt}\n`);
560
+ return result;
561
+ } catch (err) {
562
+ log(`❌ ${err.message}`);
563
+ exit(1);
564
+ }
565
+ },
566
+
567
+ 'export': async () => {
568
+ const name = options.name || args[1];
569
+ if (!name) {
570
+ log('Usage: nansen wallet export <name>');
571
+ exit(1);
572
+ return;
573
+ }
574
+ const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
575
+ try {
576
+ const result = exportWallet(name, password);
577
+ log(`\n⚠️ Private keys for "${result.name}" — do not share!\n`);
578
+ log(` EVM:`);
579
+ log(` Address: ${result.evm.address}`);
580
+ log(` Private Key: ${result.evm.privateKey}`);
581
+ log(` Solana:`);
582
+ log(` Address: ${result.solana.address}`);
583
+ log(` Private Key: ${result.solana.privateKey}`);
584
+ log('');
585
+ return result;
586
+ } catch (err) {
587
+ log(`❌ ${err.message}`);
588
+ exit(1);
589
+ }
590
+ },
591
+
592
+ 'default': async () => {
593
+ const name = options.name || args[1];
594
+ if (!name) {
595
+ log('Usage: nansen wallet default <name>');
596
+ exit(1);
597
+ return;
598
+ }
599
+ try {
600
+ const result = setDefaultWallet(name);
601
+ log(`✓ Default wallet set to "${result.defaultWallet}"`);
602
+ return result;
603
+ } catch (err) {
604
+ log(`❌ ${err.message}`);
605
+ exit(1);
606
+ }
607
+ },
608
+
609
+ 'delete': async () => {
610
+ const name = options.name || args[1];
611
+ if (!name) {
612
+ log('Usage: nansen wallet delete <name>');
613
+ exit(1);
614
+ return;
615
+ }
616
+ const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
617
+ try {
618
+ const result = deleteWallet(name, password);
619
+ log(`✓ Wallet "${result.deleted}" deleted`);
620
+ if (result.newDefault) {
621
+ log(` New default: ${result.newDefault}`);
622
+ }
623
+ return result;
624
+ } catch (err) {
625
+ log(`❌ ${err.message}`);
626
+ exit(1);
627
+ }
628
+ },
629
+
630
+ 'send': async () => {
631
+ const { sendTokens } = await import('./transfer.js');
632
+
633
+ if (!options.to) {
634
+ log('❌ --to <address> is required');
635
+ exit(1);
636
+ return;
637
+ }
638
+
639
+ const isMax = flags.max || options.amount === 'max';
640
+ if (!options.amount && !isMax) {
641
+ log('❌ --amount <number> or --max is required');
642
+ exit(1);
643
+ return;
644
+ }
645
+
646
+ if (!options.chain) {
647
+ log('❌ --chain <evm|solana> is required');
648
+ exit(1);
649
+ return;
650
+ }
651
+
652
+ if (!['evm', 'solana', 'ethereum', 'base'].includes(options.chain)) {
653
+ log('❌ --chain must be one of: evm, solana, ethereum, base');
654
+ exit(1);
655
+ return;
656
+ }
657
+
658
+ const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
659
+ const dryRun = flags['dry-run'] || flags.dryRun;
660
+
661
+ try {
662
+ const sendOpts = {
663
+ to: options.to,
664
+ amount: isMax ? '0' : String(options.amount),
665
+ chain: options.chain,
666
+ token: options.token || null,
667
+ wallet: options.wallet || null,
668
+ max: isMax,
669
+ password,
670
+ dryRun,
671
+ };
672
+
673
+ if (dryRun) {
674
+ // Build the transaction but don't broadcast
675
+ const result = await sendTokens(sendOpts);
676
+ const output = {
677
+ dryRun: true,
678
+ from: result.from,
679
+ to: options.to,
680
+ amount: result.amount || (isMax ? 'max' : String(options.amount)),
681
+ token: options.token || '(native)',
682
+ chain: options.chain,
683
+ ...(result.estimatedFee ? { estimatedFee: result.estimatedFee } : {}),
684
+ };
685
+ log(JSON.stringify(output, null, 2));
686
+ return output;
687
+ }
688
+
689
+ const result = await sendTokens(sendOpts);
690
+
691
+ const output = {
692
+ success: true,
693
+ transactionHash: result.transactionHash,
694
+ confirmed: result.confirmed,
695
+ ...(result.blockNumber ? { blockNumber: result.blockNumber } : {}),
696
+ from: result.from,
697
+ to: result.to,
698
+ amount: result.amount,
699
+ token: result.token,
700
+ chain: result.chain,
701
+ explorer: result.explorer,
702
+ };
703
+ log(JSON.stringify(output, null, 2));
704
+ return output;
705
+ } catch (err) {
706
+ log(JSON.stringify({ success: false, error: err.message }));
707
+ exit(1);
708
+ }
709
+ },
710
+
711
+ 'help': async () => {
712
+ log(`
713
+ Wallet Management - Local key storage for EVM and Solana
714
+
715
+ USAGE:
716
+ nansen wallet <command> [options]
717
+
718
+ COMMANDS:
719
+ create [--name <label>] Create a new wallet pair (EVM + Solana)
720
+ list List all wallets
721
+ show <name> Show wallet addresses
722
+ export <name> Export private keys (requires password)
723
+ default <name> Set the default wallet
724
+ delete <name> Delete a wallet (requires password)
725
+ send --to <address> --amount <number> --chain <evm|solana> [--token <address>] [--wallet <name>] [--max] [--dry-run]
726
+ Send tokens or native currency (--max sends entire balance, --dry-run previews without sending)
727
+
728
+ OPTIONS:
729
+ --name <label> Wallet name (default: "default")
730
+ --to <address> Recipient address (required for send)
731
+ --amount <number> Amount to send in human-readable format (required unless --max)
732
+ --chain <evm|solana> Blockchain to use (required for send)
733
+ --token <address> Token contract/mint address (optional, sends native if omitted)
734
+ --wallet <name> Wallet to use (optional, uses default if omitted)
735
+ --max Send entire balance (deducts gas for native transfers)
736
+
737
+ ENVIRONMENT:
738
+ NANSEN_WALLET_PASSWORD Password for non-interactive use (e.g. CI/scripts)
739
+ NANSEN_EVM_RPC Custom EVM RPC endpoint
740
+ NANSEN_SOLANA_RPC Custom Solana RPC endpoint
741
+
742
+ EXAMPLES:
743
+ nansen wallet create --name trading
744
+ nansen wallet list
745
+ nansen wallet export trading
746
+ nansen wallet default trading
747
+ nansen wallet send --to 0x742d35Cc... --amount 1.5 --chain evm
748
+ nansen wallet send --to 9WzDXw... --amount 0.1 --chain solana --token So11...
749
+ `);
750
+ return {
751
+ commands: ['create', 'list', 'show', 'export', 'default', 'delete', 'send'],
752
+ description: 'Local wallet management for EVM and Solana',
753
+ };
754
+ },
755
+ };
756
+
757
+ if (!handlers[subcommand]) {
758
+ return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
759
+ }
760
+
761
+ return handlers[subcommand]();
762
+ },
763
+ };
764
+ }