evm-rpc-checker 1.0.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/README.md +86 -0
- package/dist/index.js +519 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# evm-rpc-checker
|
|
2
|
+
|
|
3
|
+
A CLI tool for analyzing EVM-compatible blockchain RPC endpoints. Performs 10 automated tests and outputs a comprehensive quality score.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g evm-rpc-checker
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
evm-rpc-checker <rpc-url>
|
|
15
|
+
|
|
16
|
+
# or without installing
|
|
17
|
+
npx evm-rpc-checker https://ethereum-rpc.publicnode.com
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Example Output
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
EVM RPC Checker
|
|
24
|
+
──────────────────────────────────────────────────
|
|
25
|
+
Target: https://ethereum-rpc.publicnode.com
|
|
26
|
+
|
|
27
|
+
Chain: Ethereum Mainnet (ID: 1)
|
|
28
|
+
Block: #24,696,724
|
|
29
|
+
|
|
30
|
+
⚡ Connectivity PASS
|
|
31
|
+
Chain ID: 1
|
|
32
|
+
Chain: Ethereum Mainnet
|
|
33
|
+
Latest Block: 24,696,724
|
|
34
|
+
Latency: 332 ms
|
|
35
|
+
|
|
36
|
+
⏱ Latency PASS
|
|
37
|
+
Avg: 332 ms
|
|
38
|
+
Min: 304 ms
|
|
39
|
+
Max: 409 ms
|
|
40
|
+
Jitter: 105 ms
|
|
41
|
+
|
|
42
|
+
🌐 CORS PASS
|
|
43
|
+
Allow-Origin: *
|
|
44
|
+
Browser OK: Yes
|
|
45
|
+
|
|
46
|
+
📦 Batch Request PASS
|
|
47
|
+
Supported: Yes
|
|
48
|
+
Responses: 3/3
|
|
49
|
+
|
|
50
|
+
🔌 WebSocket PASS
|
|
51
|
+
📚 Archive Node PASS
|
|
52
|
+
🔧 Methods PASS (6/7)
|
|
53
|
+
🚀 Throughput PASS (3.0 req/s)
|
|
54
|
+
🧩 Consistency PASS
|
|
55
|
+
⛽ Gas Tracker PASS
|
|
56
|
+
|
|
57
|
+
──────────────────────────────────────────────────
|
|
58
|
+
Overall Score: 88/100 ██████████████████░░
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Test Suites
|
|
62
|
+
|
|
63
|
+
| Test | Description |
|
|
64
|
+
|------|-------------|
|
|
65
|
+
| **Connectivity** | `eth_chainId`, `eth_blockNumber`, `net_version` |
|
|
66
|
+
| **Latency** | Avg/min/max latency and jitter over 5 requests |
|
|
67
|
+
| **CORS** | CORS header validation via OPTIONS preflight |
|
|
68
|
+
| **Batch Request** | JSON-RPC batch request support |
|
|
69
|
+
| **WebSocket** | HTTP-to-WS/WSS upgrade support |
|
|
70
|
+
| **Archive Node** | Historical state query at block #1 |
|
|
71
|
+
| **Methods** | 7 RPC methods including trace/debug |
|
|
72
|
+
| **Throughput** | Sequential request throughput (req/s) |
|
|
73
|
+
| **Consistency** | Block height variance across 3 queries |
|
|
74
|
+
| **Gas Tracker** | Gas price, EIP-1559 base fee and priority fees |
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
- Node.js >= 18
|
|
79
|
+
|
|
80
|
+
## Related
|
|
81
|
+
|
|
82
|
+
Desktop app with GUI available at [github.com/lolieatapple/rpc-checker](https://github.com/lolieatapple/rpc-checker)
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var c = {
|
|
5
|
+
reset: "\x1B[0m",
|
|
6
|
+
bold: "\x1B[1m",
|
|
7
|
+
dim: "\x1B[2m",
|
|
8
|
+
green: "\x1B[32m",
|
|
9
|
+
red: "\x1B[31m",
|
|
10
|
+
yellow: "\x1B[33m",
|
|
11
|
+
blue: "\x1B[34m",
|
|
12
|
+
cyan: "\x1B[36m",
|
|
13
|
+
magenta: "\x1B[35m",
|
|
14
|
+
white: "\x1B[37m",
|
|
15
|
+
gray: "\x1B[90m",
|
|
16
|
+
bgGreen: "\x1B[42m",
|
|
17
|
+
bgRed: "\x1B[41m",
|
|
18
|
+
bgYellow: "\x1B[43m",
|
|
19
|
+
bgBlue: "\x1B[44m"
|
|
20
|
+
};
|
|
21
|
+
var KNOWN_CHAINS = {
|
|
22
|
+
1: "Ethereum Mainnet",
|
|
23
|
+
5: "Goerli",
|
|
24
|
+
11155111: "Sepolia",
|
|
25
|
+
56: "BNB Smart Chain",
|
|
26
|
+
137: "Polygon",
|
|
27
|
+
42161: "Arbitrum One",
|
|
28
|
+
10: "Optimism",
|
|
29
|
+
43114: "Avalanche C-Chain",
|
|
30
|
+
250: "Fantom Opera",
|
|
31
|
+
100: "Gnosis Chain",
|
|
32
|
+
8453: "Base",
|
|
33
|
+
324: "zkSync Era",
|
|
34
|
+
59144: "Linea",
|
|
35
|
+
534352: "Scroll",
|
|
36
|
+
1101: "Polygon zkEVM",
|
|
37
|
+
5000: "Mantle",
|
|
38
|
+
81457: "Blast",
|
|
39
|
+
34443: "Mode",
|
|
40
|
+
169: "Manta Pacific",
|
|
41
|
+
7777777: "Zora",
|
|
42
|
+
2222: "Kava EVM",
|
|
43
|
+
1284: "Moonbeam",
|
|
44
|
+
1285: "Moonriver",
|
|
45
|
+
42220: "Celo",
|
|
46
|
+
25: "Cronos",
|
|
47
|
+
1088: "Metis",
|
|
48
|
+
128: "HECO",
|
|
49
|
+
66: "OKXChain"
|
|
50
|
+
};
|
|
51
|
+
function fmtMs(ms) {
|
|
52
|
+
return ms < 1 ? "<1 ms" : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;
|
|
53
|
+
}
|
|
54
|
+
async function rpcCall(rpcUrl, method, params = []) {
|
|
55
|
+
const body = JSON.stringify({ jsonrpc: "2.0", method, params, id: Date.now() });
|
|
56
|
+
const start = performance.now();
|
|
57
|
+
try {
|
|
58
|
+
const controller = new AbortController;
|
|
59
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
60
|
+
const res = await fetch(rpcUrl, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body,
|
|
64
|
+
signal: controller.signal
|
|
65
|
+
});
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
const latency = performance.now() - start;
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
return { success: true, data, latency, status: res.status };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { success: false, error: err.message, latency: performance.now() - start };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function corsCheck(rpcUrl) {
|
|
75
|
+
try {
|
|
76
|
+
const controller = new AbortController;
|
|
77
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
78
|
+
const res = await fetch(rpcUrl, {
|
|
79
|
+
method: "OPTIONS",
|
|
80
|
+
headers: {
|
|
81
|
+
Origin: "http://localhost:3000",
|
|
82
|
+
"Access-Control-Request-Method": "POST",
|
|
83
|
+
"Access-Control-Request-Headers": "Content-Type"
|
|
84
|
+
},
|
|
85
|
+
signal: controller.signal
|
|
86
|
+
});
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
const headers = {};
|
|
89
|
+
for (const [k, v] of res.headers.entries()) {
|
|
90
|
+
if (k.startsWith("access-control"))
|
|
91
|
+
headers[k] = v;
|
|
92
|
+
}
|
|
93
|
+
return { success: true, status: res.status, headers };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { success: false, error: err.message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function batchCall(rpcUrl, calls) {
|
|
99
|
+
const body = JSON.stringify(calls.map((c2, i) => ({ jsonrpc: "2.0", method: c2.method, params: c2.params, id: i + 1 })));
|
|
100
|
+
const start = performance.now();
|
|
101
|
+
try {
|
|
102
|
+
const controller = new AbortController;
|
|
103
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
104
|
+
const res = await fetch(rpcUrl, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json" },
|
|
107
|
+
body,
|
|
108
|
+
signal: controller.signal
|
|
109
|
+
});
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
const latency = performance.now() - start;
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
return { success: true, data, latency };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { success: false, error: err.message, latency: performance.now() - start };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function wsCheck(rpcUrl) {
|
|
119
|
+
let wsUrl = rpcUrl;
|
|
120
|
+
if (wsUrl.startsWith("https://"))
|
|
121
|
+
wsUrl = wsUrl.replace("https://", "wss://");
|
|
122
|
+
else if (wsUrl.startsWith("http://"))
|
|
123
|
+
wsUrl = wsUrl.replace("http://", "ws://");
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
try {
|
|
126
|
+
const ws = new WebSocket(wsUrl);
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
ws.close();
|
|
129
|
+
resolve({ success: false, error: "Connection timeout (5s)" });
|
|
130
|
+
}, 5000);
|
|
131
|
+
ws.onopen = () => {
|
|
132
|
+
ws.send(JSON.stringify({ jsonrpc: "2.0", method: "eth_blockNumber", params: [], id: 1 }));
|
|
133
|
+
};
|
|
134
|
+
ws.onmessage = (event) => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
ws.close();
|
|
137
|
+
try {
|
|
138
|
+
const data = JSON.parse(String(event.data));
|
|
139
|
+
resolve({ success: true, data, wsUrl });
|
|
140
|
+
} catch {
|
|
141
|
+
resolve({ success: false, error: "Invalid JSON response" });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
ws.onerror = () => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve({ success: false, error: "WebSocket connection failed" });
|
|
147
|
+
};
|
|
148
|
+
} catch (err) {
|
|
149
|
+
resolve({ success: false, error: err.message });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function statusIcon(status) {
|
|
154
|
+
switch (status) {
|
|
155
|
+
case "pass":
|
|
156
|
+
return `${c.green}PASS${c.reset}`;
|
|
157
|
+
case "fail":
|
|
158
|
+
return `${c.red}FAIL${c.reset}`;
|
|
159
|
+
case "warn":
|
|
160
|
+
return `${c.yellow}WARN${c.reset}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function printHeader(rpcUrl) {
|
|
164
|
+
console.log();
|
|
165
|
+
console.log(`${c.bold}${c.cyan} EVM RPC Checker${c.reset}`);
|
|
166
|
+
console.log(`${c.gray} ${"─".repeat(50)}${c.reset}`);
|
|
167
|
+
console.log(`${c.gray} Target: ${c.white}${rpcUrl}${c.reset}`);
|
|
168
|
+
console.log();
|
|
169
|
+
}
|
|
170
|
+
function printChainInfo(chainId, chainName, blockNumber) {
|
|
171
|
+
console.log(`${c.blue} Chain:${c.reset} ${chainName} ${c.dim}(ID: ${chainId})${c.reset}`);
|
|
172
|
+
console.log(`${c.blue} Block:${c.reset} #${blockNumber.toLocaleString()}`);
|
|
173
|
+
console.log();
|
|
174
|
+
}
|
|
175
|
+
function printTestResult(result) {
|
|
176
|
+
console.log(` ${result.icon} ${c.bold}${result.name}${c.reset} ${statusIcon(result.status)}`);
|
|
177
|
+
if (result.message) {
|
|
178
|
+
console.log(` ${c.dim}${result.message}${c.reset}`);
|
|
179
|
+
}
|
|
180
|
+
for (const d of result.details) {
|
|
181
|
+
console.log(` ${c.gray}${d.label}:${c.reset} ${d.value}`);
|
|
182
|
+
}
|
|
183
|
+
console.log();
|
|
184
|
+
}
|
|
185
|
+
function printScore(score) {
|
|
186
|
+
const color = score >= 80 ? c.green : score >= 50 ? c.yellow : c.red;
|
|
187
|
+
const bar = "█".repeat(Math.round(score / 5)) + "░".repeat(20 - Math.round(score / 5));
|
|
188
|
+
console.log(`${c.gray} ${"─".repeat(50)}${c.reset}`);
|
|
189
|
+
console.log(` ${c.bold}Overall Score:${c.reset} ${color}${c.bold}${score}${c.reset}/100 ${color}${bar}${c.reset}`);
|
|
190
|
+
console.log();
|
|
191
|
+
}
|
|
192
|
+
function printProgress(current, total, name) {
|
|
193
|
+
const pct = Math.round(current / total * 100);
|
|
194
|
+
const filled = Math.round(pct / 5);
|
|
195
|
+
const bar = "█".repeat(filled) + "░".repeat(20 - filled);
|
|
196
|
+
process.stdout.write(`\r ${c.cyan}${bar}${c.reset} ${pct}% ${c.dim}${name}...${c.reset} `);
|
|
197
|
+
}
|
|
198
|
+
function clearProgress() {
|
|
199
|
+
process.stdout.write("\r" + " ".repeat(70) + "\r");
|
|
200
|
+
}
|
|
201
|
+
var TESTS = [
|
|
202
|
+
{ id: "connectivity", name: "Connectivity", icon: "⚡" },
|
|
203
|
+
{ id: "latency", name: "Latency", icon: "⏱" },
|
|
204
|
+
{ id: "cors", name: "CORS", icon: "\uD83C\uDF10" },
|
|
205
|
+
{ id: "batch", name: "Batch Request", icon: "\uD83D\uDCE6" },
|
|
206
|
+
{ id: "websocket", name: "WebSocket", icon: "\uD83D\uDD0C" },
|
|
207
|
+
{ id: "archive", name: "Archive Node", icon: "\uD83D\uDCDA" },
|
|
208
|
+
{ id: "methods", name: "Methods", icon: "\uD83D\uDD27" },
|
|
209
|
+
{ id: "throughput", name: "Throughput", icon: "\uD83D\uDE80" },
|
|
210
|
+
{ id: "consistency", name: "Consistency", icon: "\uD83E\uDDE9" },
|
|
211
|
+
{ id: "gastracker", name: "Gas Tracker", icon: "⛽" }
|
|
212
|
+
];
|
|
213
|
+
async function runAnalysis(rpcUrl) {
|
|
214
|
+
printHeader(rpcUrl);
|
|
215
|
+
const results = [];
|
|
216
|
+
let completed = 0;
|
|
217
|
+
const total = TESTS.length;
|
|
218
|
+
printProgress(completed, total, "Connectivity");
|
|
219
|
+
let chainId = 0;
|
|
220
|
+
let blockNumber = 0;
|
|
221
|
+
try {
|
|
222
|
+
const [chainRes, blockRes, netRes] = await Promise.all([
|
|
223
|
+
rpcCall(rpcUrl, "eth_chainId"),
|
|
224
|
+
rpcCall(rpcUrl, "eth_blockNumber"),
|
|
225
|
+
rpcCall(rpcUrl, "net_version")
|
|
226
|
+
]);
|
|
227
|
+
if (!chainRes.success || chainRes.data?.error)
|
|
228
|
+
throw new Error(chainRes.data?.error?.message || chainRes.error || "Cannot connect");
|
|
229
|
+
chainId = parseInt(chainRes.data.result, 16);
|
|
230
|
+
blockNumber = parseInt(blockRes.data?.result || "0", 16);
|
|
231
|
+
const netVersion = netRes.data?.result || "N/A";
|
|
232
|
+
const chainName = KNOWN_CHAINS[chainId] || `Unknown Chain (${chainId})`;
|
|
233
|
+
results.push({
|
|
234
|
+
name: "Connectivity",
|
|
235
|
+
icon: "⚡",
|
|
236
|
+
status: "pass",
|
|
237
|
+
score: 10,
|
|
238
|
+
details: [
|
|
239
|
+
{ label: "Chain ID", value: String(chainId) },
|
|
240
|
+
{ label: "Chain", value: chainName },
|
|
241
|
+
{ label: "Latest Block", value: blockNumber.toLocaleString() },
|
|
242
|
+
{ label: "Net Version", value: netVersion },
|
|
243
|
+
{ label: "Latency", value: fmtMs(chainRes.latency) }
|
|
244
|
+
]
|
|
245
|
+
});
|
|
246
|
+
completed++;
|
|
247
|
+
clearProgress();
|
|
248
|
+
printChainInfo(chainId, chainName, blockNumber);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
clearProgress();
|
|
251
|
+
results.push({ name: "Connectivity", icon: "⚡", status: "fail", score: 0, details: [], message: `Connection failed: ${e.message}` });
|
|
252
|
+
printTestResult(results[0]);
|
|
253
|
+
console.log(` ${c.red}${c.bold}Connectivity test failed. Cannot continue.${c.reset}`);
|
|
254
|
+
console.log();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
async function runTest(id, name, icon, fn) {
|
|
258
|
+
printProgress(completed, total, name);
|
|
259
|
+
try {
|
|
260
|
+
const result = await fn();
|
|
261
|
+
results.push({ name, icon, ...result });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
results.push({ name, icon, status: "fail", score: 0, details: [], message: `Exception: ${e.message}` });
|
|
264
|
+
}
|
|
265
|
+
completed++;
|
|
266
|
+
}
|
|
267
|
+
await runTest("latency", "Latency", "⏱", async () => {
|
|
268
|
+
const times = [];
|
|
269
|
+
for (let i = 0;i < 5; i++) {
|
|
270
|
+
const res = await rpcCall(rpcUrl, "eth_blockNumber");
|
|
271
|
+
if (res.success)
|
|
272
|
+
times.push(res.latency);
|
|
273
|
+
}
|
|
274
|
+
if (times.length === 0)
|
|
275
|
+
return { status: "fail", score: 0, details: [], message: "All requests failed" };
|
|
276
|
+
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
|
277
|
+
const min = Math.min(...times);
|
|
278
|
+
const max = Math.max(...times);
|
|
279
|
+
const jitter = max - min;
|
|
280
|
+
const score = avg < 100 ? 10 : avg < 300 ? 8 : avg < 600 ? 6 : avg < 1000 ? 4 : 2;
|
|
281
|
+
return {
|
|
282
|
+
status: avg < 1000 ? "pass" : "warn",
|
|
283
|
+
score,
|
|
284
|
+
details: [
|
|
285
|
+
{ label: "Avg", value: fmtMs(avg) },
|
|
286
|
+
{ label: "Min", value: fmtMs(min) },
|
|
287
|
+
{ label: "Max", value: fmtMs(max) },
|
|
288
|
+
{ label: "Jitter", value: fmtMs(jitter) },
|
|
289
|
+
{ label: "Samples", value: `${times.length}/5` }
|
|
290
|
+
]
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
await runTest("cors", "CORS", "\uD83C\uDF10", async () => {
|
|
294
|
+
const res = await corsCheck(rpcUrl);
|
|
295
|
+
if (!res.success) {
|
|
296
|
+
return { status: "warn", score: 5, details: [
|
|
297
|
+
{ label: "Status", value: "Cannot detect" },
|
|
298
|
+
{ label: "Info", value: res.error || "Preflight may not be supported" }
|
|
299
|
+
] };
|
|
300
|
+
}
|
|
301
|
+
const headers = res.headers || {};
|
|
302
|
+
const hasOrigin = !!headers["access-control-allow-origin"];
|
|
303
|
+
const origin = headers["access-control-allow-origin"] || "Not set";
|
|
304
|
+
const methods = headers["access-control-allow-methods"] || "Not set";
|
|
305
|
+
const isOpen = origin === "*";
|
|
306
|
+
return {
|
|
307
|
+
status: hasOrigin ? isOpen ? "pass" : "warn" : "fail",
|
|
308
|
+
score: isOpen ? 10 : hasOrigin ? 7 : 3,
|
|
309
|
+
details: [
|
|
310
|
+
{ label: "Allow-Origin", value: origin },
|
|
311
|
+
{ label: "Allow-Methods", value: methods },
|
|
312
|
+
{ label: "Browser OK", value: hasOrigin ? "Yes" : "No" }
|
|
313
|
+
]
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
await runTest("batch", "Batch Request", "\uD83D\uDCE6", async () => {
|
|
317
|
+
const res = await batchCall(rpcUrl, [
|
|
318
|
+
{ method: "eth_blockNumber", params: [] },
|
|
319
|
+
{ method: "eth_chainId", params: [] },
|
|
320
|
+
{ method: "eth_gasPrice", params: [] }
|
|
321
|
+
]);
|
|
322
|
+
if (!res.success)
|
|
323
|
+
return { status: "fail", score: 0, details: [], message: `Batch failed: ${res.error}` };
|
|
324
|
+
const isArray = Array.isArray(res.data);
|
|
325
|
+
const allOk = isArray && res.data.every((r) => r.result !== undefined);
|
|
326
|
+
return {
|
|
327
|
+
status: isArray ? "pass" : "fail",
|
|
328
|
+
score: isArray ? allOk ? 10 : 7 : 0,
|
|
329
|
+
details: [
|
|
330
|
+
{ label: "Supported", value: isArray ? "Yes" : "No" },
|
|
331
|
+
{ label: "Responses", value: isArray ? `${res.data.length}/3` : "N/A" },
|
|
332
|
+
{ label: "All OK", value: allOk ? "Yes" : "No" },
|
|
333
|
+
{ label: "Latency", value: fmtMs(res.latency) }
|
|
334
|
+
]
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
await runTest("websocket", "WebSocket", "\uD83D\uDD0C", async () => {
|
|
338
|
+
const res = await wsCheck(rpcUrl);
|
|
339
|
+
if (!res.success) {
|
|
340
|
+
return { status: "warn", score: 4, details: [
|
|
341
|
+
{ label: "WebSocket", value: "Not Supported" },
|
|
342
|
+
{ label: "Reason", value: res.error || "Unknown" }
|
|
343
|
+
] };
|
|
344
|
+
}
|
|
345
|
+
const bn = parseInt(res.data?.result || "0", 16);
|
|
346
|
+
return {
|
|
347
|
+
status: "pass",
|
|
348
|
+
score: 10,
|
|
349
|
+
details: [
|
|
350
|
+
{ label: "WebSocket", value: "Supported" },
|
|
351
|
+
{ label: "WS URL", value: res.wsUrl || "" },
|
|
352
|
+
{ label: "Block Height", value: bn.toLocaleString() }
|
|
353
|
+
]
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
await runTest("archive", "Archive Node", "\uD83D\uDCDA", async () => {
|
|
357
|
+
const res = await rpcCall(rpcUrl, "eth_getBalance", ["0x0000000000000000000000000000000000000000", "0x1"]);
|
|
358
|
+
if (!res.success)
|
|
359
|
+
return { status: "fail", score: 0, details: [], message: `Request failed: ${res.error}` };
|
|
360
|
+
const hasError = !!res.data?.error;
|
|
361
|
+
const isArchive = !hasError && res.data?.result !== undefined;
|
|
362
|
+
return {
|
|
363
|
+
status: isArchive ? "pass" : "warn",
|
|
364
|
+
score: isArchive ? 10 : 5,
|
|
365
|
+
details: [
|
|
366
|
+
{ label: "Archive", value: isArchive ? "Yes" : "No" },
|
|
367
|
+
{ label: "History Query", value: isArchive ? "Supported" : "Not Supported" },
|
|
368
|
+
...hasError ? [{ label: "Error", value: res.data.error.message || "unknown" }] : [],
|
|
369
|
+
{ label: "Latency", value: fmtMs(res.latency) }
|
|
370
|
+
]
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
await runTest("methods", "Methods", "\uD83D\uDD27", async () => {
|
|
374
|
+
const methods = [
|
|
375
|
+
{ method: "eth_gasPrice", params: [], label: "eth_gasPrice" },
|
|
376
|
+
{ method: "eth_estimateGas", params: [{ to: "0x0000000000000000000000000000000000000000", value: "0x0" }], label: "eth_estimateGas" },
|
|
377
|
+
{ method: "eth_getLogs", params: [{ fromBlock: "latest", toBlock: "latest" }], label: "eth_getLogs" },
|
|
378
|
+
{ method: "eth_getCode", params: ["0x0000000000000000000000000000000000000001", "latest"], label: "eth_getCode" },
|
|
379
|
+
{ method: "trace_block", params: ["latest"], label: "trace_block" },
|
|
380
|
+
{ method: "debug_traceBlockByNumber", params: ["latest", {}], label: "debug_traceBlock" },
|
|
381
|
+
{ method: "eth_feeHistory", params: ["0x5", "latest", [25, 75]], label: "eth_feeHistory" }
|
|
382
|
+
];
|
|
383
|
+
const callResults = [];
|
|
384
|
+
for (const m of methods) {
|
|
385
|
+
callResults.push(await rpcCall(rpcUrl, m.method, m.params));
|
|
386
|
+
}
|
|
387
|
+
let supported = 0;
|
|
388
|
+
const details = methods.map((m, i) => {
|
|
389
|
+
const r = callResults[i];
|
|
390
|
+
const ok = r.success && !r.data?.error;
|
|
391
|
+
if (ok)
|
|
392
|
+
supported++;
|
|
393
|
+
return { label: m.label, value: ok ? "✓" : "✗" };
|
|
394
|
+
});
|
|
395
|
+
const score = Math.round(supported / methods.length * 10);
|
|
396
|
+
return {
|
|
397
|
+
status: supported >= 4 ? "pass" : supported >= 2 ? "warn" : "fail",
|
|
398
|
+
score,
|
|
399
|
+
details: [{ label: "Support Rate", value: `${supported}/${methods.length}` }, ...details]
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
await runTest("throughput", "Throughput", "\uD83D\uDE80", async () => {
|
|
403
|
+
const count = 10;
|
|
404
|
+
const start = performance.now();
|
|
405
|
+
const callResults = [];
|
|
406
|
+
for (let i = 0;i < count; i++) {
|
|
407
|
+
callResults.push(await rpcCall(rpcUrl, "eth_blockNumber"));
|
|
408
|
+
}
|
|
409
|
+
const elapsed = performance.now() - start;
|
|
410
|
+
const successCount = callResults.filter((r) => r.success && !r.data?.error).length;
|
|
411
|
+
const rps = (successCount / (elapsed / 1000)).toFixed(1);
|
|
412
|
+
const avgLatency = callResults.filter((r) => r.success).reduce((s, r) => s + r.latency, 0) / (successCount || 1);
|
|
413
|
+
const score = parseFloat(rps) >= 20 ? 10 : parseFloat(rps) >= 10 ? 8 : parseFloat(rps) >= 3 ? 5 : 2;
|
|
414
|
+
return {
|
|
415
|
+
status: successCount === count ? "pass" : "warn",
|
|
416
|
+
score,
|
|
417
|
+
details: [
|
|
418
|
+
{ label: "Requests", value: String(count) },
|
|
419
|
+
{ label: "Success", value: `${successCount}/${count}` },
|
|
420
|
+
{ label: "Throughput", value: `${rps} req/s` },
|
|
421
|
+
{ label: "Total Time", value: fmtMs(elapsed) },
|
|
422
|
+
{ label: "Avg Latency", value: fmtMs(avgLatency) }
|
|
423
|
+
]
|
|
424
|
+
};
|
|
425
|
+
});
|
|
426
|
+
await runTest("consistency", "Consistency", "\uD83E\uDDE9", async () => {
|
|
427
|
+
const r1 = await rpcCall(rpcUrl, "eth_blockNumber");
|
|
428
|
+
const r2 = await rpcCall(rpcUrl, "eth_blockNumber");
|
|
429
|
+
const r3 = await rpcCall(rpcUrl, "eth_blockNumber");
|
|
430
|
+
const blocks = [r1, r2, r3].filter((r) => r.success && r.data?.result).map((r) => parseInt(r.data.result, 16));
|
|
431
|
+
if (blocks.length < 3)
|
|
432
|
+
return { status: "fail", score: 0, details: [], message: "Some requests failed" };
|
|
433
|
+
const maxDiff = Math.max(...blocks) - Math.min(...blocks);
|
|
434
|
+
const consistent = maxDiff <= 1;
|
|
435
|
+
return {
|
|
436
|
+
status: consistent ? "pass" : "warn",
|
|
437
|
+
score: consistent ? 10 : maxDiff <= 3 ? 6 : 2,
|
|
438
|
+
details: [
|
|
439
|
+
{ label: "Block #1", value: blocks[0].toLocaleString() },
|
|
440
|
+
{ label: "Block #2", value: blocks[1].toLocaleString() },
|
|
441
|
+
{ label: "Block #3", value: blocks[2].toLocaleString() },
|
|
442
|
+
{ label: "Max Drift", value: `${maxDiff} blocks` },
|
|
443
|
+
{ label: "Consistent", value: consistent ? "Yes" : "No" }
|
|
444
|
+
]
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
await runTest("gastracker", "Gas Tracker", "⛽", async () => {
|
|
448
|
+
const gasPriceRes = await rpcCall(rpcUrl, "eth_gasPrice");
|
|
449
|
+
const feeHistoryRes = await rpcCall(rpcUrl, "eth_feeHistory", ["0xa", "latest", [10, 50, 90]]);
|
|
450
|
+
if (!gasPriceRes.success || gasPriceRes.data?.error) {
|
|
451
|
+
return { status: "fail", score: 0, details: [], message: "Cannot get gas price" };
|
|
452
|
+
}
|
|
453
|
+
const gasPrice = parseInt(gasPriceRes.data.result, 16);
|
|
454
|
+
const gasPriceGwei = (gasPrice / 1e9).toFixed(2);
|
|
455
|
+
const details = [
|
|
456
|
+
{ label: "Gas Price", value: `${gasPriceGwei} Gwei` }
|
|
457
|
+
];
|
|
458
|
+
if (feeHistoryRes.success && !feeHistoryRes.data?.error && feeHistoryRes.data?.result) {
|
|
459
|
+
const fh = feeHistoryRes.data.result;
|
|
460
|
+
const baseFees = (fh.baseFeePerGas || []).map((b) => parseInt(b, 16) / 1e9);
|
|
461
|
+
if (baseFees.length > 0) {
|
|
462
|
+
const avgBase = (baseFees.reduce((a, b) => a + b, 0) / baseFees.length).toFixed(2);
|
|
463
|
+
details.push({ label: "Avg Base Fee", value: `${avgBase} Gwei` });
|
|
464
|
+
}
|
|
465
|
+
const rewards = fh.reward;
|
|
466
|
+
if (rewards?.length > 0) {
|
|
467
|
+
const latest = rewards[rewards.length - 1];
|
|
468
|
+
if (latest) {
|
|
469
|
+
details.push({ label: "Priority P10", value: `${(parseInt(latest[0], 16) / 1e9).toFixed(2)} Gwei` });
|
|
470
|
+
details.push({ label: "Priority P50", value: `${(parseInt(latest[1], 16) / 1e9).toFixed(2)} Gwei` });
|
|
471
|
+
details.push({ label: "Priority P90", value: `${(parseInt(latest[2], 16) / 1e9).toFixed(2)} Gwei` });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
details.push({ label: "EIP-1559", value: "Supported" });
|
|
475
|
+
} else {
|
|
476
|
+
details.push({ label: "EIP-1559", value: "Not Supported" });
|
|
477
|
+
}
|
|
478
|
+
return { status: "pass", score: 8, details };
|
|
479
|
+
});
|
|
480
|
+
clearProgress();
|
|
481
|
+
for (const r of results) {
|
|
482
|
+
printTestResult(r);
|
|
483
|
+
}
|
|
484
|
+
const totalScore = results.reduce((s, r) => s + r.score, 0);
|
|
485
|
+
const maxScore = results.length * 10;
|
|
486
|
+
const pct = Math.round(totalScore / maxScore * 100);
|
|
487
|
+
printScore(pct);
|
|
488
|
+
}
|
|
489
|
+
function printUsage() {
|
|
490
|
+
console.log();
|
|
491
|
+
console.log(`${c.bold}${c.cyan} EVM RPC Checker${c.reset} - Analyze EVM-compatible RPC endpoints`);
|
|
492
|
+
console.log();
|
|
493
|
+
console.log(` ${c.bold}Usage:${c.reset}`);
|
|
494
|
+
console.log(` ${c.green}evm-rpc-checker${c.reset} <rpc-url>`);
|
|
495
|
+
console.log(` ${c.green}bun cli/src/index.ts${c.reset} <rpc-url>`);
|
|
496
|
+
console.log();
|
|
497
|
+
console.log(` ${c.bold}Example:${c.reset}`);
|
|
498
|
+
console.log(` ${c.dim}evm-rpc-checker https://eth.llamarpc.com${c.reset}`);
|
|
499
|
+
console.log(` ${c.dim}evm-rpc-checker https://rpc.ankr.com/eth${c.reset}`);
|
|
500
|
+
console.log();
|
|
501
|
+
}
|
|
502
|
+
var args = process.argv.slice(2);
|
|
503
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
504
|
+
printUsage();
|
|
505
|
+
process.exit(0);
|
|
506
|
+
}
|
|
507
|
+
var rpcUrl = args[0];
|
|
508
|
+
if (!rpcUrl.startsWith("http://") && !rpcUrl.startsWith("https://")) {
|
|
509
|
+
console.error(`
|
|
510
|
+
${c.red}Error: Invalid URL. Must start with http:// or https://${c.reset}
|
|
511
|
+
`);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
runAnalysis(rpcUrl).catch((err) => {
|
|
515
|
+
console.error(`
|
|
516
|
+
${c.red}Fatal error: ${err.message}${c.reset}
|
|
517
|
+
`);
|
|
518
|
+
process.exit(1);
|
|
519
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "evm-rpc-checker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool for analyzing EVM-compatible RPC endpoints",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"evm-rpc-checker": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "bun build src/index.ts --outfile dist/index.js --target node",
|
|
11
|
+
"prepublishOnly": "bun run build",
|
|
12
|
+
"dev": "bun src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.0.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"evm",
|
|
22
|
+
"rpc",
|
|
23
|
+
"ethereum",
|
|
24
|
+
"blockchain",
|
|
25
|
+
"checker",
|
|
26
|
+
"analyzer",
|
|
27
|
+
"web3",
|
|
28
|
+
"cli"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/lolieatapple/rpc-checker.git",
|
|
33
|
+
"directory": "cli"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/lolieatapple/rpc-checker",
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|