nansen-cli 1.11.1 → 1.12.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#207](https://github.com/nansen-ai/nansen-cli/pull/207) [`73ca500`](https://github.com/nansen-ai/nansen-cli/commit/73ca5009c03ad541673165ca6b50f33ff4cc1673) Thanks [@TimNooren](https://github.com/TimNooren)! - Add --unsafe-no-password flag to wallet create for agent-friendly passwordless wallets.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#212](https://github.com/nansen-ai/nansen-cli/pull/212) [`726c29d`](https://github.com/nansen-ai/nansen-cli/commit/726c29d2676c8a37772299c6237b44890493dfa5) Thanks [@0xlaveen](https://github.com/0xlaveen)! - Clarify empty input handling in parseAddressList with explicit early return
12
+
13
+ - [#218](https://github.com/nansen-ai/nansen-cli/pull/218) [`8c4dd71`](https://github.com/nansen-ai/nansen-cli/commit/8c4dd71ce215026e149a6b097540c46622f13d3a) Thanks [@TimNooren](https://github.com/TimNooren)! - fix: `nansen changelog --since <version>` now correctly filters changeset-format entries (## x.y.z) in addition to Keep a Changelog entries (## [x.y.z])
14
+
15
+ - [#209](https://github.com/nansen-ai/nansen-cli/pull/209) [`a6dc1ed`](https://github.com/nansen-ai/nansen-cli/commit/a6dc1ed9dc40ad3506e7debe09746d463a70c14d) Thanks [@0xlaveen](https://github.com/0xlaveen)! - fix: prevent --help from executing destructive commands (logout, schema, cache)
16
+
17
+ ## 1.11.2
18
+
19
+ ### Patch Changes
20
+
21
+ - [#205](https://github.com/nansen-ai/nansen-cli/pull/205) [`dba24aa`](https://github.com/nansen-ai/nansen-cli/commit/dba24aaa64b2083fdbaa85a002758bfe21d9f4a0) Thanks [@TimNooren](https://github.com/TimNooren)! - Add hot wallet and password handling warnings to wallet create output
22
+
3
23
  ## 1.11.1
4
24
 
5
25
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -19,7 +19,8 @@
19
19
  "test:watch": "vitest",
20
20
  "test:coverage": "vitest run --coverage",
21
21
  "test:live": "NANSEN_LIVE_TEST=1 vitest run",
22
- "test:trade": "vitest run --config vitest.e2e.config.js",
22
+ "test:trade": "vitest run --config vitest.e2e.config.js src/__tests__/trade.e2e.test.js",
23
+ "test:send": "vitest run --config vitest.e2e.config.js src/__tests__/send.e2e.test.js",
23
24
  "lint": "eslint .",
24
25
  "lint:fix": "eslint . --fix",
25
26
  "changeset": "changeset",
@@ -60,5 +61,11 @@
60
61
  "eslint": "^10.0.2",
61
62
  "globals": "^17.4.0",
62
63
  "vitest": "^4.0.18"
64
+ },
65
+ "dependencies": {
66
+ "@ethereumjs/rlp": "^10.1.1",
67
+ "@noble/curves": "^2.0.1",
68
+ "@noble/hashes": "^2.0.1",
69
+ "@scure/base": "^2.0.0"
63
70
  }
64
- }
71
+ }
package/src/api.js CHANGED
@@ -582,7 +582,9 @@ export class NansenAPI {
582
582
  // No API key and no payment wallet — guide the user to login rather than
583
583
  // showing a confusing x402 payment dump they can't act on.
584
584
  // TODO: full fix would skip x402 entirely when no apiKey is set — see PR #<this PR number>
585
- message = 'No API key configured. Run: nansen login --api-key <key>. Get your key at https://app.nansen.ai/api';
585
+ message = 'No API key configured. Two ways to authenticate:\n' +
586
+ ' 1. API key: nansen login --api-key <key> (get key at https://app.nansen.ai/api)\n' +
587
+ ' 2. x402 micropayment: nansen wallet create + fund with USDC (no API key needed)';
586
588
  } else {
587
589
  message = `x402 auto-payment failed: ${x402Err.message}`;
588
590
  }
package/src/cli.js CHANGED
@@ -439,6 +439,37 @@ async function enrichTransfers(result, apiInstance, chain) {
439
439
  return result;
440
440
  }
441
441
 
442
+ // ============= Address Parsing =============
443
+
444
+ /**
445
+ * Parse an --addresses option that may arrive as:
446
+ * - a pre-parsed array (arg parser split it)
447
+ * - a JSON array string: '["0x…","0x…"]'
448
+ * - a comma-separated string: "0x…,0x…"
449
+ * Non-array JSON values (objects, numbers, booleans) are rejected.
450
+ */
451
+ export function parseAddressList(raw) {
452
+ if (Array.isArray(raw)) {
453
+ return raw.map(a => String(a).trim()).filter(Boolean);
454
+ }
455
+ if (!raw) return [];
456
+
457
+ const s = String(raw);
458
+ try {
459
+ const parsed = JSON.parse(s);
460
+ if (Array.isArray(parsed)) {
461
+ return parsed.map(a => String(a).trim()).filter(Boolean);
462
+ }
463
+ throw new NansenError(
464
+ '--addresses must be a comma-separated list or JSON array, got: ' + typeof parsed,
465
+ ErrorCode.INVALID_PARAMS
466
+ );
467
+ } catch (e) {
468
+ if (e instanceof NansenError) throw e;
469
+ return s.split(',').map(a => a.trim()).filter(Boolean);
470
+ }
471
+ }
472
+
442
473
  // ============= Composite Functions =============
443
474
 
444
475
  export async function batchProfile(api, params = {}) {
@@ -644,7 +675,7 @@ EXAMPLES:
644
675
  nansen research profiler balance --address 0x... --chain ethereum
645
676
  nansen trade quote --chain base --from ETH --to USDC --amount 1
646
677
 
647
- Research chains: ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, mantle, ronin, sei, plasma, sonic, monad, hyperevm, iotaevm
678
+ Research chains: ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, zksync, mantle, ronin, sei, plasma, sonic, unichain, monad, hyperevm, iotaevm
648
679
  Trade chains: solana, base
649
680
  Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
650
681
 
@@ -780,6 +811,10 @@ export function buildCommands(deps = {}) {
780
811
  },
781
812
 
782
813
  'changelog': async (_args, _apiInstance, _flags, _options) => {
814
+ if (_flags.help || _flags.h) {
815
+ log('changelog — Show release history\n\nUsage:\n nansen changelog [--since <version>]\n\nOptions:\n --since <version> Show only entries for versions >= this version\n\nExamples:\n nansen changelog\n nansen changelog --since 1.10.0');
816
+ return;
817
+ }
783
818
  const changelogPath = new URL('../CHANGELOG.md', import.meta.url).pathname;
784
819
  let content;
785
820
  try {
@@ -795,10 +830,10 @@ export function buildCommands(deps = {}) {
795
830
  const filtered = [];
796
831
  let include = false;
797
832
  for (const line of lines) {
798
- // Match ## [x.y.z] headers
799
- const match = line.match(/^## \[(\d+\.\d+\.\d+)\]/);
833
+ // Match ## [x.y.z] (Keep a Changelog format) or ## x.y.z (changeset format)
834
+ const match = line.match(/^## \[(\d+\.\d+\.\d+)\]|^## (\d+\.\d+\.\d+)\b/);
800
835
  if (match) {
801
- const ver = match[1];
836
+ const ver = match[1] || match[2];
802
837
  // Compare: include versions >= since, stop at versions < since
803
838
  if (compareSemver(ver, since) >= 0) {
804
839
  include = true;
@@ -945,7 +980,7 @@ export function buildCommands(deps = {}) {
945
980
  'batch': () => {
946
981
  let addresses = [];
947
982
  if (options.addresses) {
948
- addresses = options.addresses.split(',').map(a => a.trim()).filter(Boolean);
983
+ addresses = parseAddressList(options.addresses);
949
984
  } else if (options.file) {
950
985
  const content = fs.readFileSync(options.file, 'utf8');
951
986
  try {
@@ -976,13 +1011,13 @@ export function buildCommands(deps = {}) {
976
1011
  return traceCounterparties(apiInstance, { address, chain, depth, width, days, delayMs });
977
1012
  },
978
1013
  'compare': () => {
979
- const addrs = (options.addresses || '').split(',').map(a => a.trim()).filter(Boolean);
1014
+ const addrs = parseAddressList(options.addresses);
980
1015
  return compareWallets(apiInstance, { addresses: addrs, chain, days });
981
1016
  },
982
1017
  'help': () => ({
983
1018
  commands: ['balance', 'labels', 'transactions', 'pnl', 'search', 'historical-balances', 'related-wallets', 'counterparties', 'pnl-summary', 'perp-positions', 'perp-trades', 'batch', 'trace', 'compare'],
984
1019
  description: 'Wallet profiling endpoints',
985
- example: 'nansen profiler balance --address 0x123... --chain ethereum'
1020
+ example: 'nansen research profiler compare --addresses "0xABC...,0xDEF..." --chain ethereum'
986
1021
  })
987
1022
  };
988
1023
 
@@ -1294,7 +1329,8 @@ export function generateSubcommandHelp(command, subcommand) {
1294
1329
 
1295
1330
  const exampleValues = { address: '0x...', token: '0x...', query: '"term"', symbol: 'BTC', date: '2024-01-01' };
1296
1331
  const chain = subSchema.options?.chain?.default || 'solana';
1297
- let example = `nansen ${command} ${subcommand}`;
1332
+ const prefix = DEPRECATED_TO_RESEARCH.has(command) ? `research ${command}` : command;
1333
+ let example = `nansen ${prefix} ${subcommand}`;
1298
1334
  if (subSchema.options) {
1299
1335
  for (const [name, opt] of Object.entries(subSchema.options)) {
1300
1336
  if (opt.required) example += ` --${name} ${exampleValues[name] || '<val>'}`;
@@ -1408,6 +1444,20 @@ export async function runCLI(rawArgs, deps = {}) {
1408
1444
  return { type: 'command-help', command };
1409
1445
  }
1410
1446
  }
1447
+ // Simple commands (logout, schema, cache) — show help instead of executing
1448
+ // Prevents destructive commands like logout from running when user just wants help
1449
+ if (commands[command]) {
1450
+ const simpleHelp = {
1451
+ 'logout': 'nansen logout — Remove saved API key from ~/.nansen/config.json',
1452
+ 'schema': 'nansen schema [command] [--pretty] — Show JSON schema for all commands (or a specific command)',
1453
+ 'cache': 'nansen cache clear — Clear the API response cache',
1454
+ };
1455
+ if (simpleHelp[command]) {
1456
+ output(simpleHelp[command]);
1457
+ notify();
1458
+ return { type: 'command-help', command };
1459
+ }
1460
+ }
1411
1461
  // Commands with handlers (e.g. quote, execute) show their own usage
1412
1462
  if (command === 'help' || !commands[command]) {
1413
1463
  output(BANNER + HELP);
package/src/crypto.js CHANGED
@@ -1,178 +1,46 @@
1
1
  /**
2
2
  * Shared cryptographic primitives for EVM transaction signing.
3
3
  * Exports keccak256, secp256k1 ECDSA signing, RLP encoding.
4
- * Zero external dependencies uses Node.js built-in crypto only.
4
+ * Uses audited libraries: @noble/hashes, @noble/curves, @ethereumjs/rlp.
5
5
  */
6
6
 
7
- import crypto from "crypto";
7
+ import { keccak_256 } from "@noble/hashes/sha3.js";
8
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
9
+ import { RLP } from "@ethereumjs/rlp";
8
10
 
9
11
  // ============= Keccak-256 =============
10
12
 
11
- // Keccak-256 (NOT SHA3-256; Ethereum uses original Keccak with 0x01 padding).
12
- // Uses a flat 25-element state array (lanes indexed as state[x + 5*y])
13
- // with BigInt64 arithmetic.
14
-
15
- const RC = [
16
- 0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
17
- 0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
18
- 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
19
- 0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
20
- 0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
21
- 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
22
- ];
23
-
24
- const ROT = [
25
- 0, 1, 62, 28, 27,
26
- 36, 44, 6, 55, 20,
27
- 3, 10, 43, 25, 39,
28
- 41, 45, 15, 21, 8,
29
- 18, 2, 61, 56, 14,
30
- ];
31
-
32
- const M = 0xffffffffffffffffn;
33
-
34
- function rot64(v, r) {
35
- return r === 0 ? v : ((v << BigInt(r)) | (v >> BigInt(64 - r))) & M;
36
- }
37
-
38
- function keccakF(s) {
39
- for (let round = 0; round < 24; round++) {
40
- const c0 = s[0] ^ s[5] ^ s[10] ^ s[15] ^ s[20];
41
- const c1 = s[1] ^ s[6] ^ s[11] ^ s[16] ^ s[21];
42
- const c2 = s[2] ^ s[7] ^ s[12] ^ s[17] ^ s[22];
43
- const c3 = s[3] ^ s[8] ^ s[13] ^ s[18] ^ s[23];
44
- const c4 = s[4] ^ s[9] ^ s[14] ^ s[19] ^ s[24];
45
- const d0 = (c4 ^ rot64(c1, 1)) & M;
46
- const d1 = (c0 ^ rot64(c2, 1)) & M;
47
- const d2 = (c1 ^ rot64(c3, 1)) & M;
48
- const d3 = (c2 ^ rot64(c4, 1)) & M;
49
- const d4 = (c3 ^ rot64(c0, 1)) & M;
50
- for (let y = 0; y < 25; y += 5) {
51
- s[y] = (s[y] ^ d0) & M;
52
- s[y + 1] = (s[y + 1] ^ d1) & M;
53
- s[y + 2] = (s[y + 2] ^ d2) & M;
54
- s[y + 3] = (s[y + 3] ^ d3) & M;
55
- s[y + 4] = (s[y + 4] ^ d4) & M;
56
- }
57
- const t = new Array(25);
58
- for (let x = 0; x < 5; x++) {
59
- for (let y = 0; y < 5; y++) {
60
- const src = x + 5 * y;
61
- const dst = y + 5 * ((2 * x + 3 * y) % 5);
62
- t[dst] = rot64(s[src], ROT[src]);
63
- }
64
- }
65
- for (let y = 0; y < 25; y += 5) {
66
- const t0 = t[y], t1 = t[y+1], t2 = t[y+2], t3 = t[y+3], t4 = t[y+4];
67
- s[y] = (t0 ^ ((~t1 & M) & t2)) & M;
68
- s[y+1] = (t1 ^ ((~t2 & M) & t3)) & M;
69
- s[y+2] = (t2 ^ ((~t3 & M) & t4)) & M;
70
- s[y+3] = (t3 ^ ((~t4 & M) & t0)) & M;
71
- s[y+4] = (t4 ^ ((~t0 & M) & t1)) & M;
72
- }
73
- s[0] = (s[0] ^ RC[round]) & M;
74
- }
75
- }
76
-
77
13
  export function keccak256(input) {
78
- const rate = 136;
79
- const s = new Array(25).fill(0n);
80
- const blocks = Math.max(1, Math.ceil((input.length + 1) / rate));
81
- const padded = Buffer.alloc(blocks * rate);
82
- input.copy(padded);
83
- padded[input.length] ^= 0x01;
84
- padded[padded.length - 1] ^= 0x80;
85
- for (let off = 0; off < padded.length; off += rate) {
86
- for (let i = 0; i < 17; i++) {
87
- s[i] ^= padded.readBigUInt64LE(off + i * 8);
88
- }
89
- keccakF(s);
90
- }
91
- const out = Buffer.alloc(32);
92
- for (let i = 0; i < 4; i++) {
93
- out.writeBigUInt64LE(s[i] & M, i * 8);
94
- }
95
- return out;
14
+ const hash = keccak_256(input);
15
+ return Buffer.from(hash);
96
16
  }
97
17
 
98
18
  // ============= secp256k1 ECDSA =============
99
19
 
100
- const P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2Fn;
101
- const N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
102
- const Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798n;
103
- const Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8n;
104
-
105
- function modInv(a, m) {
106
- let [old_r, r] = [((a % m) + m) % m, m];
107
- let [old_s, s] = [1n, 0n];
108
- while (r !== 0n) {
109
- const q = old_r / r;
110
- [old_r, r] = [r, old_r - q * r];
111
- [old_s, s] = [s, old_s - q * s];
112
- }
113
- return ((old_s % m) + m) % m;
114
- }
115
-
116
- function ptAdd(x1, y1, x2, y2) {
117
- if (x1 === null) return [x2, y2];
118
- if (x2 === null) return [x1, y1];
119
- if (x1 === x2 && y1 === y2) {
120
- const lam = (3n * x1 * x1 * modInv(2n * y1, P)) % P;
121
- const x3 = ((lam * lam - 2n * x1) % P + P) % P;
122
- return [x3, ((lam * (x1 - x3) - y1) % P + P) % P];
123
- }
124
- if (x1 === x2) return [null, null];
125
- const lam = (((y2 - y1) % P + P) * modInv(((x2 - x1) % P + P) % P, P)) % P;
126
- const x3 = ((lam * lam - x1 - x2) % P + P) % P;
127
- return [x3, ((lam * (x1 - x3) - y1) % P + P) % P];
128
- }
129
-
130
- function ptMul(k, x, y) {
131
- let [rx, ry] = [null, null];
132
- let [qx, qy] = [x, y];
133
- while (k > 0n) {
134
- if (k & 1n) [rx, ry] = ptAdd(rx, ry, qx, qy);
135
- [qx, qy] = ptAdd(qx, qy, qx, qy);
136
- k >>= 1n;
137
- }
138
- return [rx, ry];
139
- }
140
-
141
- function rfc6979k(privBuf, hash) {
142
- let v = Buffer.alloc(32, 0x01);
143
- let k = Buffer.alloc(32, 0x00);
144
- k = crypto.createHmac('sha256', k).update(Buffer.concat([v, Buffer.from([0x00]), privBuf, hash])).digest();
145
- v = crypto.createHmac('sha256', k).update(v).digest();
146
- k = crypto.createHmac('sha256', k).update(Buffer.concat([v, Buffer.from([0x01]), privBuf, hash])).digest();
147
- v = crypto.createHmac('sha256', k).update(v).digest();
148
- while (true) {
149
- v = crypto.createHmac('sha256', k).update(v).digest();
150
- const candidate = BigInt('0x' + v.toString('hex'));
151
- if (candidate >= 1n && candidate < N) return candidate;
152
- k = crypto.createHmac('sha256', k).update(Buffer.concat([v, Buffer.from([0x00])])).digest();
153
- v = crypto.createHmac('sha256', k).update(v).digest();
154
- }
155
- }
156
-
157
20
  /**
158
21
  * Sign a 32-byte hash with secp256k1 ECDSA.
159
22
  * Uses RFC 6979 deterministic k and low-S normalization (EIP-2).
160
23
  * Returns { r, s, v } where v is the recovery ID (0 or 1).
161
24
  */
162
25
  export function signSecp256k1(hash, privateKey) {
163
- const z = BigInt('0x' + hash.toString('hex'));
164
- const d = BigInt('0x' + privateKey.toString('hex'));
165
- const k = rfc6979k(privateKey, hash);
166
- const [rx, ry] = ptMul(k, Gx, Gy);
167
- const r = rx % N;
168
- if (r === 0n) throw new Error('Invalid signature: r=0');
169
- let s = (modInv(k, N) * ((z + r * d) % N)) % N;
170
- if (s === 0n) throw new Error('Invalid signature: s=0');
171
- let v = (ry % 2n === 0n) ? 0 : 1;
172
- if (s > N >> 1n) { s = N - s; v ^= 1; }
26
+ const sigBytes = secp256k1.sign(hash, privateKey, { prehash: false, lowS: true });
27
+ const sig = secp256k1.Signature.fromBytes(sigBytes);
28
+ const pubKey = secp256k1.getPublicKey(privateKey, false);
29
+ const pubKeyHex = Buffer.from(pubKey).toString("hex");
30
+
31
+ // Determine recovery bit by trying both values
32
+ let v = 0;
33
+ for (const bit of [0, 1]) {
34
+ const recovered = sig.addRecoveryBit(bit).recoverPublicKey(hash);
35
+ if (Buffer.from(recovered.toBytes(false)).toString("hex") === pubKeyHex) {
36
+ v = bit;
37
+ break;
38
+ }
39
+ }
40
+
173
41
  return {
174
- r: Buffer.from(r.toString(16).padStart(64, '0'), 'hex'),
175
- s: Buffer.from(s.toString(16).padStart(64, '0'), 'hex'),
42
+ r: Buffer.from(sig.r.toString(16).padStart(64, "0"), "hex"),
43
+ s: Buffer.from(sig.s.toString(16).padStart(64, "0"), "hex"),
176
44
  v,
177
45
  };
178
46
  }
@@ -182,34 +50,39 @@ export function signSecp256k1(hash, privateKey) {
182
50
  export function bigIntToMinBuf(n) {
183
51
  if (n === 0n) return Buffer.alloc(0);
184
52
  const hex = n.toString(16);
185
- return Buffer.from(hex.length % 2 ? '0' + hex : hex, 'hex');
53
+ return Buffer.from(hex.length % 2 ? "0" + hex : hex, "hex");
186
54
  }
187
55
 
188
56
  export function rlpEncode(input) {
57
+ return Buffer.from(RLP.encode(toRlpInput(input)));
58
+ }
59
+
60
+ /**
61
+ * Convert our legacy input format to what @ethereumjs/rlp expects.
62
+ * Handles: arrays (recursive), Buffers, hex strings, empty values.
63
+ */
64
+ function toRlpInput(input) {
189
65
  if (Array.isArray(input)) {
190
- const encoded = input.map(rlpEncode);
191
- const payload = Buffer.concat(encoded);
192
- if (payload.length < 56) {
193
- return Buffer.concat([Buffer.from([0xc0 + payload.length]), payload]);
194
- }
195
- const lenBytes = bigIntToMinBuf(BigInt(payload.length));
196
- return Buffer.concat([Buffer.from([0xf7 + lenBytes.length]), lenBytes, payload]);
66
+ return input.map(toRlpInput);
197
67
  }
198
68
 
199
- let data;
200
69
  if (Buffer.isBuffer(input)) {
201
- data = input;
202
- } else {
203
- let hex = (typeof input === 'string' ? input : '').replace(/^0x/, '');
204
- hex = hex.replace(/^0+/, '');
205
- if (hex === '' || hex.length === 0) return Buffer.from([0x80]);
206
- if (hex.length % 2) hex = '0' + hex;
207
- data = Buffer.from(hex, 'hex');
70
+ return Uint8Array.from(input);
71
+ }
72
+
73
+ if (input instanceof Uint8Array) {
74
+ return input;
75
+ }
76
+
77
+ if (typeof input === "string") {
78
+ let hex = input.replace(/^0x/, "");
79
+ // Strip leading zeros to match legacy behavior for hex-string values
80
+ // (chain IDs, nonces, gas prices, etc. passed as hex strings)
81
+ hex = hex.replace(/^0+/, "");
82
+ if (hex === "" || hex.length === 0) return Uint8Array.from([]);
83
+ if (hex.length % 2) hex = "0" + hex;
84
+ return Uint8Array.from(Buffer.from(hex, "hex"));
208
85
  }
209
86
 
210
- if (data.length === 0) return Buffer.from([0x80]);
211
- if (data.length === 1 && data[0] < 0x80) return data;
212
- if (data.length < 56) return Buffer.concat([Buffer.from([0x80 + data.length]), data]);
213
- const lenBytes = bigIntToMinBuf(BigInt(data.length));
214
- return Buffer.concat([Buffer.from([0xb7 + lenBytes.length]), lenBytes, data]);
87
+ return Uint8Array.from([]);
215
88
  }
package/src/schema.json CHANGED
@@ -1622,7 +1622,7 @@
1622
1622
  },
1623
1623
  "wallet": {
1624
1624
  "type": "string",
1625
- "description": "Wallet name (or \"walletconnect\"/\"wc\" for WalletConnect, EVM only). A configured wallet is required run `nansen wallet create` if you haven't set one up yet."
1625
+ "description": "Wallet name (or \"walletconnect\"/\"wc\" for WalletConnect, EVM only). A configured wallet is required \u2014 run `nansen wallet create` if you haven't set one up yet."
1626
1626
  }
1627
1627
  },
1628
1628
  "prerequisites": [
@@ -1692,11 +1692,13 @@
1692
1692
  "avalanche",
1693
1693
  "linea",
1694
1694
  "scroll",
1695
+ "zksync",
1695
1696
  "mantle",
1696
1697
  "ronin",
1697
1698
  "sei",
1698
1699
  "plasma",
1699
1700
  "sonic",
1701
+ "unichain",
1700
1702
  "monad",
1701
1703
  "hyperevm",
1702
1704
  "iotaevm"
package/src/trading.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import crypto from 'crypto';
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
- import { exportWallet, getDefaultAddress, showWallet, listWallets } from './wallet.js';
11
+ import { exportWallet, getDefaultAddress, showWallet, listWallets, getWalletConfig } from './wallet.js';
12
12
  import { base58Decode } from './transfer.js';
13
13
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
14
14
  import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
@@ -957,7 +957,10 @@ EXAMPLES:
957
957
  let exported = null;
958
958
  if (!isWalletConnect) {
959
959
  // Get wallet credentials once (before the loop)
960
- const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
960
+ const walletConfig = getWalletConfig();
961
+ const password = walletConfig.passwordHash
962
+ ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
963
+ : null;
961
964
 
962
965
  let effectiveWalletName = walletName;
963
966
  if (!effectiveWalletName) {
package/src/transfer.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Nansen CLI - Token Transfer
3
3
  * Send native and ERC-20/SPL tokens on EVM and Solana chains.
4
- * Zero external dependencies — uses Node.js built-in crypto only.
5
4
  */
6
5
 
7
6
  import crypto from 'crypto';
7
+ import { base58 } from '@scure/base';
8
8
  import { base58Encode, exportWallet, getWalletConfig, verifyPassword } from './wallet.js';
9
9
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
10
10
  import { getWalletConnectAddress, sendTransactionViaWalletConnect } from './walletconnect-trading.js';
@@ -34,21 +34,8 @@ const CHAIN_IDS = { ...EVM_CHAIN_IDS, evm: 1 };
34
34
 
35
35
  // ============= Base58 =============
36
36
 
37
- const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
38
-
39
37
  function base58Decode(str) {
40
- let num = 0n;
41
- for (const ch of str) {
42
- const idx = BASE58_ALPHABET.indexOf(ch);
43
- if (idx === -1) throw new Error(`Invalid base58 character: ${ch}`);
44
- num = num * 58n + BigInt(idx);
45
- }
46
- const hex = num.toString(16);
47
- const paddedHex = hex.length % 2 ? '0' + hex : hex;
48
- const bytes = num === 0n ? [] : [...Buffer.from(paddedHex, 'hex')];
49
- let leadingZeros = 0;
50
- for (const ch of str) { if (ch === '1') leadingZeros++; else break; }
51
- return Buffer.from([...Array(leadingZeros).fill(0), ...bytes]);
38
+ return Buffer.from(base58.decode(str));
52
39
  }
53
40
 
54
41
  function base58DecodePubkey(str) {
@@ -603,7 +590,7 @@ export async function sendTokens({ to, amount, chain, token = null, wallet = nul
603
590
  }
604
591
 
605
592
  const config = getWalletConfig();
606
- if (!verifyPassword(password, config)) throw new Error('Incorrect password');
593
+ if (config.passwordHash && !verifyPassword(password, config)) throw new Error('Incorrect password');
607
594
 
608
595
  const walletName = wallet || config.defaultWallet;
609
596
  if (!walletName) throw new Error('No wallet specified and no default wallet set');
package/src/wallet.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Nansen CLI - Wallet Management
3
3
  * Local key generation and storage for EVM and Solana chains.
4
- * Zero external dependencies — uses Node.js built-in crypto only.
5
4
  */
6
5
 
7
6
  import crypto from 'crypto';
8
7
  import fs from 'fs';
9
8
  import path from 'path';
10
9
  import * as readline from 'readline';
10
+ import { base58 } from '@scure/base';
11
11
 
12
12
  // ============= Constants =============
13
13
 
@@ -32,31 +32,11 @@ import { keccak256 } from './crypto.js';
32
32
 
33
33
  // ============= Base58 Encoding (for Solana) =============
34
34
 
35
- const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
36
-
37
35
  /**
38
36
  * Encode a Buffer to base58 string.
39
37
  */
40
38
  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';
39
+ return base58.encode(buf instanceof Uint8Array ? buf : Uint8Array.from(buf));
60
40
  }
61
41
 
62
42
  // ============= Encryption =============
@@ -78,6 +58,10 @@ function deriveKey(password, salt) {
78
58
  * Returns a JSON-serializable object with all params needed for decryption.
79
59
  */
80
60
  export function encryptKey(privateKeyHex, password) {
61
+ if (password === null) {
62
+ return { data: privateKeyHex, encrypted: false };
63
+ }
64
+
81
65
  const salt = crypto.randomBytes(SALT_LEN);
82
66
  const iv = crypto.randomBytes(IV_LEN);
83
67
  const key = deriveKey(password, salt);
@@ -99,10 +83,31 @@ export function encryptKey(privateKeyHex, password) {
99
83
 
100
84
  /**
101
85
  * Decrypt a private key with a password.
102
- * @returns {string} Private key hex string
103
- * @throws {Error} If password is wrong
86
+ * For unencrypted wallets (encrypted: false), password is ignored and
87
+ * plaintext data is returned directly.
104
88
  */
105
89
  export function decryptKey(encryptedData, password) {
90
+ const ENCRYPTED_FIELDS = ['salt', 'iv', 'authTag', 'ciphertext'];
91
+ const hasEncryptionFields = ENCRYPTED_FIELDS.some(f => f in encryptedData);
92
+
93
+ // Unencrypted blob
94
+ if (encryptedData.encrypted === false) {
95
+ if (hasEncryptionFields) {
96
+ throw new Error('Wallet data corrupted or tampered');
97
+ }
98
+ return encryptedData.data;
99
+ }
100
+
101
+ // Encrypted blob with missing fields
102
+ if (!ENCRYPTED_FIELDS.every(f => f in encryptedData)) {
103
+ throw new Error('Wallet data corrupted or tampered');
104
+ }
105
+
106
+ // Encrypted blob but no password provided
107
+ if (password === null || password === undefined) {
108
+ throw new Error('Wallet is encrypted. Set NANSEN_WALLET_PASSWORD.');
109
+ }
110
+
106
111
  const salt = Buffer.from(encryptedData.salt, 'hex');
107
112
  const iv = Buffer.from(encryptedData.iv, 'hex');
108
113
  const authTag = Buffer.from(encryptedData.authTag, 'hex');
@@ -234,6 +239,7 @@ function getWalletFile(name) {
234
239
  */
235
240
  export function verifyPassword(password, config) {
236
241
  if (!config.passwordHash) return true; // No password set yet
242
+ if (password === null || password === undefined) return false;
237
243
  const { salt, hash } = config.passwordHash;
238
244
  const derived = crypto.scryptSync(password, Buffer.from(salt, 'hex'), 32, {
239
245
  N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: 256 * 1024 * 1024,
@@ -328,11 +334,22 @@ export function createWallet(name, password) {
328
334
  throw new Error(`Wallet "${name}" already exists`);
329
335
  }
330
336
 
331
- // If this is the first wallet, set the password hash
332
- if (!config.passwordHash) {
333
- config.passwordHash = hashPassword(password);
337
+ if (password === null) {
338
+ // Passwordless mode: reject if existing wallets are encrypted
339
+ if (config.passwordHash) {
340
+ throw new Error('Existing wallets are password-protected. Set NANSEN_WALLET_PASSWORD.');
341
+ }
334
342
  } else {
335
- if (!verifyPassword(password, config)) {
343
+ // Encrypted mode
344
+ if (!config.passwordHash) {
345
+ // First encrypted wallet: reject if passwordless wallets exist
346
+ const existingWallets = fs.readdirSync(getWalletsDir())
347
+ .filter(f => f.endsWith('.json') && f !== 'config.json');
348
+ if (existingWallets.length > 0) {
349
+ throw new Error('Existing wallets are passwordless. Cannot mix encrypted and unencrypted wallets.');
350
+ }
351
+ config.passwordHash = hashPassword(password);
352
+ } else if (!verifyPassword(password, config)) {
336
353
  throw new Error('Incorrect password');
337
354
  }
338
355
  }
@@ -400,7 +417,7 @@ export function exportWallet(name, password) {
400
417
  }
401
418
 
402
419
  const config = getWalletConfig();
403
- if (!verifyPassword(password, config)) {
420
+ if (config.passwordHash && !verifyPassword(password, config)) {
404
421
  throw new Error('Incorrect password');
405
422
  }
406
423
 
@@ -445,7 +462,7 @@ export function deleteWallet(name, password) {
445
462
  }
446
463
 
447
464
  const config = getWalletConfig();
448
- if (!verifyPassword(password, config)) {
465
+ if (config.passwordHash && !verifyPassword(password, config)) {
449
466
  throw new Error('Incorrect password');
450
467
  }
451
468
 
@@ -490,22 +507,35 @@ export function buildWalletCommands(deps = {}) {
490
507
  const handlers = {
491
508
  'create': async () => {
492
509
  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');
510
+
511
+ let password;
512
+ if (flags['unsafe-no-password']) {
513
+ process.stderr.write('WARNING: --unsafe-no-password is set. Private keys will be stored UNENCRYPTED on disk.\nAnyone with access to this machine can steal your funds.\n');
514
+ password = null;
515
+ } else if (!process.env.NANSEN_WALLET_PASSWORD && !process.stdin.isTTY && !deps.promptFn) {
516
+ log('❌ No password provided. Either:');
517
+ log(' set NANSEN_WALLET_PASSWORD, or');
518
+ log(' use --unsafe-no-password (WARNING: Private keys will be stored UNENCRYPTED on disk. Anyone with access to this machine can steal your funds.)');
496
519
  exit(1);
497
520
  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');
521
+ } else {
522
+ password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
523
+ if (!password || password.length < 12) {
524
+ log('❌ Password must be at least 12 characters');
506
525
  exit(1);
507
526
  return;
508
527
  }
528
+
529
+ // Confirm password for first wallet (skip if set via env var)
530
+ const config = getWalletConfig();
531
+ if (!config.passwordHash && !process.env.NANSEN_WALLET_PASSWORD) {
532
+ const confirm = await promptPassword('Confirm password: ', deps);
533
+ if (password !== confirm) {
534
+ log('❌ Passwords do not match');
535
+ exit(1);
536
+ return;
537
+ }
538
+ }
509
539
  }
510
540
 
511
541
  try {
@@ -519,6 +549,13 @@ export function buildWalletCommands(deps = {}) {
519
549
  log(` Base (recommended, lower fees): send USDC to ${result.evm}`);
520
550
  log(` Solana: send USDC to ${result.solana}`);
521
551
  log('');
552
+ if (password === null) {
553
+ log(' ⚠️ This is an UNENCRYPTED hot wallet — private keys are stored in plaintext on disk.');
554
+ } else {
555
+ log(' ⚠️ This is a hot wallet and is fundamentally insecure — do not deposit more than you can afford to lose.');
556
+ log(' Store and handle your password securely, e.g. using a secrets manager or system keychain.');
557
+ }
558
+ log('');
522
559
  return;
523
560
  } catch (err) {
524
561
  log(`❌ ${err.message}`);
@@ -570,7 +607,10 @@ export function buildWalletCommands(deps = {}) {
570
607
  exit(1);
571
608
  return;
572
609
  }
573
- const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
610
+ const config = getWalletConfig();
611
+ const password = config.passwordHash
612
+ ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
613
+ : null;
574
614
  try {
575
615
  const result = exportWallet(name, password);
576
616
  log(`\n⚠️ Private keys for "${result.name}" — do not share!\n`);
@@ -612,7 +652,10 @@ export function buildWalletCommands(deps = {}) {
612
652
  exit(1);
613
653
  return;
614
654
  }
615
- const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
655
+ const config = getWalletConfig();
656
+ const password = config.passwordHash
657
+ ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
658
+ : null;
616
659
  try {
617
660
  const result = deleteWallet(name, password);
618
661
  log(`✓ Wallet "${result.deleted}" deleted`);
@@ -655,7 +698,15 @@ export function buildWalletCommands(deps = {}) {
655
698
  }
656
699
 
657
700
  const isWalletConnect = options.wallet === 'walletconnect' || options.wallet === 'wc';
658
- const password = isWalletConnect ? null : (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps));
701
+ let password;
702
+ if (isWalletConnect) {
703
+ password = null;
704
+ } else {
705
+ const sendConfig = getWalletConfig();
706
+ password = sendConfig.passwordHash
707
+ ? (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps))
708
+ : null;
709
+ }
659
710
  const dryRun = flags['dry-run'] || flags.dryRun;
660
711
 
661
712
  try {
@@ -712,7 +763,8 @@ USAGE:
712
763
  nansen wallet <command> [options]
713
764
 
714
765
  COMMANDS:
715
- create [--name <label>] Create a new wallet pair (EVM + Solana)
766
+ create [--name <label>] [--unsafe-no-password]
767
+ Create a new wallet pair (EVM + Solana)
716
768
  list List all wallets
717
769
  show <name> Show wallet addresses
718
770
  export <name> Export private keys (requires password)
@@ -729,6 +781,7 @@ OPTIONS:
729
781
  --token <address> Token contract/mint address (optional, sends native if omitted)
730
782
  --wallet <name> Wallet to use (optional, uses default if omitted; use "walletconnect" or "wc" for WalletConnect, EVM only)
731
783
  --max Send entire balance (deducts gas for native transfers)
784
+ --unsafe-no-password Skip encryption — private keys stored UNENCRYPTED on disk (create only)
732
785
 
733
786
  ENVIRONMENT:
734
787
  NANSEN_WALLET_PASSWORD Password for non-interactive use (e.g. CI/scripts)
package/src/x402-svm.js CHANGED
@@ -1,30 +1,15 @@
1
1
  /**
2
2
  * Nansen CLI - x402 Solana Auto-Payment
3
3
  * Implements SPL TransferChecked transaction building for x402 payments.
4
- * Zero external dependencies — uses Node.js built-in crypto + wallet.js base58.
5
4
  */
6
5
 
7
6
  import crypto from 'crypto';
7
+ import { base58 } from '@scure/base';
8
8
 
9
- // ============= Base58 Encode (inline from wallet.js PR #26) =============
10
- const BASE58_ALPHABET_STR = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
9
+ // ============= Base58 Encode =============
11
10
 
12
11
  export function base58Encode(buf) {
13
- let num = 0n;
14
- for (const byte of buf) {
15
- num = num * 256n + BigInt(byte);
16
- }
17
- let str = '';
18
- while (num > 0n) {
19
- const rem = Number(num % 58n);
20
- num = num / 58n;
21
- str = BASE58_ALPHABET_STR[rem] + str;
22
- }
23
- for (const byte of buf) {
24
- if (byte === 0) str = '1' + str;
25
- else break;
26
- }
27
- return str || '1';
12
+ return base58.encode(buf instanceof Uint8Array ? buf : Uint8Array.from(buf));
28
13
  }
29
14
 
30
15
  // ============= Constants =============
@@ -41,33 +26,8 @@ const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
41
26
 
42
27
  // ============= Base58 Decode =============
43
28
 
44
- const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
45
- const BASE58_MAP = new Uint8Array(128);
46
- for (let i = 0; i < BASE58_ALPHABET.length; i++) {
47
- BASE58_MAP[BASE58_ALPHABET.charCodeAt(i)] = i;
48
- }
49
-
50
29
  export function base58Decode(str) {
51
- let num = 0n;
52
- for (const ch of str) {
53
- num = num * 58n + BigInt(BASE58_MAP[ch.charCodeAt(0)]);
54
- }
55
-
56
- // Count leading '1's → leading zero bytes
57
- let leadingZeros = 0;
58
- for (const ch of str) {
59
- if (ch === '1') leadingZeros++;
60
- else break;
61
- }
62
-
63
- if (num === 0n) return Buffer.alloc(leadingZeros || 1);
64
-
65
- // Convert to bytes
66
- const hex = num.toString(16);
67
- const paddedHex = hex.length % 2 ? '0' + hex : hex;
68
- const bytes = Buffer.from(paddedHex, 'hex');
69
-
70
- return Buffer.concat([Buffer.alloc(leadingZeros), bytes]);
30
+ return Buffer.from(base58.decode(str));
71
31
  }
72
32
 
73
33
  /**
package/src/x402.js CHANGED
@@ -96,18 +96,23 @@ export async function* createPaymentSignatures(response, url, options = {}) {
96
96
  const ranked = rankRequirements(requirements);
97
97
  if (ranked.length === 0) return;
98
98
 
99
- const password = options.password || process.env.NANSEN_WALLET_PASSWORD;
100
- if (!password) return;
101
-
102
- let exportWallet, listWallets;
99
+ let exportWallet, listWallets, getWalletConfig;
103
100
  try {
104
101
  const walletMod = await import('./wallet.js');
105
102
  exportWallet = walletMod.exportWallet;
106
103
  listWallets = walletMod.listWallets;
104
+ getWalletConfig = walletMod.getWalletConfig;
107
105
  } catch {
108
106
  return;
109
107
  }
110
108
 
109
+ const walletConfig = getWalletConfig();
110
+ const password = walletConfig.passwordHash
111
+ ? (options.password || process.env.NANSEN_WALLET_PASSWORD || null)
112
+ : null;
113
+ // Encrypted wallets need a password -- silently skip if unavailable
114
+ if (walletConfig.passwordHash && password === null) return;
115
+
111
116
  const wallets = listWallets();
112
117
  if (wallets.wallets.length === 0) return;
113
118