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/AGENTS.md +222 -0
- package/README.md +36 -2
- package/SKILL.md +25 -21
- package/TODO.md +17 -0
- package/package.json +1 -1
- package/src/api.js +101 -29
- package/src/cli.js +65 -18
- package/src/crypto.js +215 -0
- package/src/trading.js +1081 -0
- package/src/transfer.js +723 -0
- package/src/wallet.js +764 -0
- package/src/x402-evm.js +207 -0
- package/src/x402-svm.js +474 -0
- package/src/x402.js +205 -0
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
|
+
}
|