nansen-cli 1.7.0 → 1.8.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/ens.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ENS (Ethereum Name Service) resolution
3
+ * Resolves .eth names to addresses using public APIs with onchain RPC fallback.
4
+ * Zero external dependencies.
5
+ */
6
+
7
+ import https from 'https';
8
+ import { keccak256 } from './crypto.js';
9
+
10
+ const ENS_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.eth$/;
11
+
12
+ const EVM_CHAINS = [
13
+ 'ethereum', 'base', 'optimism', 'arbitrum', 'polygon', 'bnb',
14
+ 'avalanche', 'fantom', 'gnosis', 'linea', 'scroll', 'zksync',
15
+ 'blast', 'mantle', 'ronin', 'sei', 'plasma', 'sonic', 'unichain', 'monad', 'hyperevm', 'iotaevm'
16
+ ];
17
+
18
+ /**
19
+ * Check if a string looks like an ENS name
20
+ */
21
+ export function isEnsName(name) {
22
+ return typeof name === 'string' && ENS_PATTERN.test(name.trim());
23
+ }
24
+
25
+ /**
26
+ * Resolve an address input — if it's an ENS name, resolve it; otherwise pass through.
27
+ *
28
+ * @param {string} addressOrName - Address (0x...) or ENS name (*.eth)
29
+ * @param {string} chain - Chain context (ENS only resolves on EVM chains)
30
+ * @returns {Promise<{address: string, ensName?: string}>}
31
+ */
32
+ export async function resolveAddress(addressOrName, chain = 'ethereum') {
33
+ if (!addressOrName || typeof addressOrName !== 'string') {
34
+ return { address: addressOrName };
35
+ }
36
+
37
+ const trimmed = addressOrName.trim();
38
+
39
+ if (!isEnsName(trimmed)) {
40
+ return { address: trimmed };
41
+ }
42
+
43
+ if (!EVM_CHAINS.includes(chain)) {
44
+ throw new Error(`ENS names can only be resolved on EVM chains, not ${chain}`);
45
+ }
46
+
47
+ const name = trimmed.toLowerCase();
48
+ const errors = [];
49
+
50
+ // Try ensideas API first (fast, no auth)
51
+ try {
52
+ const addr = await resolveViaEnsIdeas(name);
53
+ if (addr) return { address: addr, ensName: name };
54
+ } catch (e) {
55
+ errors.push(`ensideas: ${e.message}`);
56
+ }
57
+
58
+ // Fallback: onchain resolution via public RPC
59
+ try {
60
+ const addr = await resolveOnchain(name);
61
+ if (addr) return { address: addr, ensName: name };
62
+ } catch (e) {
63
+ errors.push(`onchain: ${e.message}`);
64
+ }
65
+
66
+ throw new Error(`Could not resolve ENS name: ${name}${errors.length ? ` (${errors.join('; ')})` : ''}`);
67
+ }
68
+
69
+ // ============= Resolvers =============
70
+
71
+ function httpsGet(url, timeoutMs = 5000) {
72
+ return new Promise((resolve, reject) => {
73
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
74
+ let data = '';
75
+ res.on('data', chunk => { data += chunk; });
76
+ res.on('end', () => {
77
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
78
+ try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
79
+ });
80
+ });
81
+ req.on('error', reject);
82
+ req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
83
+ });
84
+ }
85
+
86
+ function httpsPost(url, body, timeoutMs = 5000) {
87
+ return new Promise((resolve, reject) => {
88
+ const payload = JSON.stringify(body);
89
+ const parsed = new URL(url);
90
+ const req = https.request({
91
+ hostname: parsed.hostname,
92
+ path: parsed.pathname,
93
+ method: 'POST',
94
+ timeout: timeoutMs,
95
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
96
+ }, (res) => {
97
+ let buf = '';
98
+ res.on('data', chunk => { buf += chunk; });
99
+ res.on('end', () => {
100
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
101
+ try { resolve(JSON.parse(buf)); } catch (e) { reject(e); }
102
+ });
103
+ });
104
+ req.on('error', reject);
105
+ req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
106
+ req.write(payload);
107
+ req.end();
108
+ });
109
+ }
110
+
111
+ const VALID_ADDR = /^0x[0-9a-fA-F]{40}$/;
112
+
113
+ async function resolveViaEnsIdeas(name) {
114
+ const result = await httpsGet(`https://api.ensideas.com/ens/resolve/${encodeURIComponent(name)}`);
115
+ if (result?.address && VALID_ADDR.test(result.address)) return result.address;
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Compute ENS namehash using keccak256 from crypto.js
121
+ */
122
+ function namehash(name) {
123
+ let node = Buffer.alloc(32, 0); // bytes32(0)
124
+ if (!name) return node.toString('hex');
125
+
126
+ const labels = name.split('.').reverse();
127
+ for (const label of labels) {
128
+ const labelHash = keccak256(Buffer.from(label, 'utf8'));
129
+ node = keccak256(Buffer.concat([node, labelHash]));
130
+ }
131
+ return node.toString('hex');
132
+ }
133
+
134
+ const ENS_REGISTRY = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e';
135
+ const ZERO_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
136
+ const RPC_URL = 'https://eth.llamarpc.com';
137
+
138
+ async function resolveOnchain(name) {
139
+ const hash = namehash(name);
140
+
141
+ // Step 1: Get resolver from ENS registry — resolver(bytes32)
142
+ const resolverResult = await httpsPost(RPC_URL, {
143
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
144
+ params: [{ to: ENS_REGISTRY, data: '0x0178b8bf' + hash }, 'latest']
145
+ });
146
+
147
+ const resolverHex = resolverResult?.result;
148
+ if (!resolverHex || resolverHex === '0x' || resolverHex.slice(2) === ZERO_HASH) return null;
149
+
150
+ const resolver = '0x' + resolverHex.slice(26);
151
+
152
+ // Step 2: Call addr(bytes32) on the resolver — selector 0x3b3b57de
153
+ const addrResult = await httpsPost(RPC_URL, {
154
+ jsonrpc: '2.0', id: 2, method: 'eth_call',
155
+ params: [{ to: resolver, data: '0x3b3b57de' + hash }, 'latest']
156
+ });
157
+
158
+ const addrHex = addrResult?.result;
159
+ if (!addrHex || addrHex === '0x' || addrHex.slice(2) === ZERO_HASH) return null;
160
+
161
+ const address = '0x' + addrHex.slice(26);
162
+ return VALID_ADDR.test(address) ? address : null;
163
+ }
@@ -28,6 +28,41 @@ function isNewer(latest, current) {
28
28
  return lp > cp;
29
29
  }
30
30
 
31
+ const LAST_VERSION_FILE = path.join(CONFIG_DIR, 'last-version.json');
32
+
33
+ /**
34
+ * After an update, show a one-time "what's new" notice on the first run.
35
+ * Compares current version against the stored last-seen version.
36
+ * Returns a notice string or null. Writes current version to disk.
37
+ */
38
+ export function getUpgradeNotice(currentVersion) {
39
+ try {
40
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return null;
41
+
42
+ let previousVersion = null;
43
+ if (fs.existsSync(LAST_VERSION_FILE)) {
44
+ const raw = fs.readFileSync(LAST_VERSION_FILE, 'utf8');
45
+ const data = JSON.parse(raw);
46
+ previousVersion = data.version;
47
+ }
48
+
49
+ // Always update the stored version
50
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
51
+ fs.writeFileSync(LAST_VERSION_FILE, JSON.stringify({ version: currentVersion }));
52
+
53
+ // If no previous version stored, this is a fresh install — no notice
54
+ if (!previousVersion) return null;
55
+
56
+ // If versions match, no update happened
57
+ if (previousVersion === currentVersion) return null;
58
+
59
+ // Version changed — show notice
60
+ return `\n ✨ Updated to ${currentVersion} (was ${previousVersion}). Run \`nansen changelog --since ${previousVersion}\` for details.\n`;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
31
66
  /**
32
67
  * Read the cached check result and return a notification string (or null).
33
68
  */
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.e2e.test.js'],
8
+ testTimeout: 120000,
9
+ },
10
+ });