aether-hub 1.2.6 → 1.2.8

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.
@@ -1,509 +1,509 @@
1
- /**
2
- * aether-cli snapshot - Aether Node Sync & Snapshot Status
3
- *
4
- * Shows how far your node has synced vs the network, snapshot availability,
5
- * and whether your node is catching up or is fully current.
6
- *
7
- * Usage:
8
- * aether-cli snapshot # Interactive sync status view
9
- * aether-cli snapshot --json # JSON output for scripting
10
- * aether-cli snapshot --rpc <url> # Query a specific RPC endpoint
11
- * aether-cli snapshot --watch # Refresh every 5 seconds
12
- *
13
- * @see docs/MINING_VALIDATOR_TOOLS.md for spec
14
- */
15
-
16
- const http = require('http');
17
- const https = require('https');
18
-
19
- // ANSI colours
20
- const C = {
21
- reset: '\x1b[0m',
22
- bright: '\x1b[1m',
23
- dim: '\x1b[2m',
24
- red: '\x1b[31m',
25
- green: '\x1b[32m',
26
- yellow: '\x1b[33m',
27
- blue: '\x1b[34m',
28
- cyan: '\x1b[36m',
29
- magenta: '\x1b[35m',
30
- };
31
-
32
- const DEFAULT_RPC = process.env.AETHER_RPC || 'http://127.0.0.1:8899';
33
- const REFRESH_INTERVAL_MS = 5000;
34
-
35
- // ---------------------------------------------------------------------------
36
- // HTTP helpers
37
- // ---------------------------------------------------------------------------
38
-
39
- function httpRequest(rpcUrl, path, options = {}) {
40
- return new Promise((resolve, reject) => {
41
- const url = new URL(path, rpcUrl);
42
- const isHttps = url.protocol === 'https:';
43
- const lib = isHttps ? https : http;
44
-
45
- const reqOptions = {
46
- hostname: url.hostname,
47
- port: url.port || (isHttps ? 443 : 80),
48
- path: url.pathname + url.search,
49
- method: 'GET',
50
- timeout: 8000,
51
- headers: { 'Content-Type': 'application/json' },
52
- };
53
-
54
- const req = lib.request(reqOptions, (res) => {
55
- let data = '';
56
- res.on('data', (chunk) => (data += chunk));
57
- res.on('end', () => {
58
- try { resolve(JSON.parse(data)); }
59
- catch { resolve({ raw: data }); }
60
- });
61
- });
62
-
63
- req.on('error', reject);
64
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
65
- req.end();
66
- });
67
- }
68
-
69
- function httpPost(rpcUrl, path, body) {
70
- return new Promise((resolve, reject) => {
71
- const url = new URL(path, rpcUrl);
72
- const isHttps = url.protocol === 'https:';
73
- const lib = isHttps ? https : http;
74
- const bodyStr = JSON.stringify(body);
75
-
76
- const req = lib.request({
77
- hostname: url.hostname,
78
- port: url.port || (isHttps ? 443 : 80),
79
- path: url.pathname + url.search,
80
- method: 'POST',
81
- timeout: 8000,
82
- headers: {
83
- 'Content-Type': 'application/json',
84
- 'Content-Length': Buffer.byteLength(bodyStr),
85
- },
86
- }, (res) => {
87
- let data = '';
88
- res.on('data', (chunk) => (data += chunk));
89
- res.on('end', () => {
90
- try { resolve(JSON.parse(data)); }
91
- catch { resolve(data); }
92
- });
93
- });
94
-
95
- req.on('error', reject);
96
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
97
- req.write(bodyStr);
98
- req.end();
99
- });
100
- }
101
-
102
- // ---------------------------------------------------------------------------
103
- // Argument parsing
104
- // ---------------------------------------------------------------------------
105
-
106
- function parseArgs() {
107
- const args = process.argv.slice(2);
108
- const options = {
109
- rpc: DEFAULT_RPC,
110
- asJson: false,
111
- watch: false,
112
- };
113
-
114
- for (let i = 0; i < args.length; i++) {
115
- if (args[i] === '--rpc' || args[i] === '-r') {
116
- options.rpc = args[++i];
117
- } else if (args[i] === '--json' || args[i] === '-j') {
118
- options.asJson = true;
119
- } else if (args[i] === '--watch' || args[i] === '-w') {
120
- options.watch = true;
121
- } else if (args[i] === '--help' || args[i] === '-h') {
122
- showHelp();
123
- process.exit(0);
124
- }
125
- }
126
-
127
- return options;
128
- }
129
-
130
- function showHelp() {
131
- console.log(`
132
- ${C.bright}${C.cyan}aether-cli snapshot${C.reset} - Aether Node Sync & Snapshot Status
133
-
134
- ${C.bright}Usage:${C.reset}
135
- aether-cli snapshot [options]
136
-
137
- ${C.bright}Options:${C.reset}
138
- -r, --rpc <url> RPC endpoint (default: ${DEFAULT_RPC} or $AETHER_RPC)
139
- -j, --json Output raw JSON (good for scripting)
140
- -w, --watch Refresh every 5 seconds (live view)
141
- -h, --help Show this help message
142
-
143
- ${C.bright}Examples:${C.reset}
144
- aether-cli snapshot # Interactive sync status
145
- aether-cli snapshot --json # JSON output
146
- aether-cli snapshot --watch # Live refreshing view
147
- aether-cli snapshot --rpc https://api.testnet.aether.network
148
- `.trim());
149
- }
150
-
151
- // ---------------------------------------------------------------------------
152
- // Data fetchers
153
- // ---------------------------------------------------------------------------
154
-
155
- /** GET /v1/slot — current network slot */
156
- async function getSlot(rpc) {
157
- try {
158
- const res = await httpRequest(rpc, '/v1/slot');
159
- return res.slot ?? res.root_slot ?? null;
160
- } catch {
161
- return null;
162
- }
163
- }
164
-
165
- /** GET /v1/block_height — node's synced block height */
166
- async function getBlockHeight(rpc) {
167
- try {
168
- const res = await httpRequest(rpc, '/v1/block_height');
169
- return res.block_height ?? null;
170
- } catch {
171
- return null;
172
- }
173
- }
174
-
175
- /** GET /v1/epoch — current epoch info */
176
- async function getEpoch(rpc) {
177
- try {
178
- const res = await httpRequest(rpc, '/v1/epoch');
179
- return res;
180
- } catch {
181
- return null;
182
- }
183
- }
184
-
185
- /** GET /v1/snapshot — snapshot slot info */
186
- async function getSnapshot(rpc) {
187
- try {
188
- const res = await httpRequest(rpc, '/v1/snapshot');
189
- return res;
190
- } catch {
191
- return null;
192
- }
193
- }
194
-
195
- /** POST /v1/version — node version info */
196
- async function getVersion(rpc) {
197
- try {
198
- const res = await httpPost(rpc, '/v1/version', {});
199
- return res;
200
- } catch {
201
- return null;
202
- }
203
- }
204
-
205
- /** GET /v1/health — node health (if supported) */
206
- async function getHealth(rpc) {
207
- try {
208
- const res = await httpRequest(rpc, '/v1/health');
209
- return res;
210
- } catch {
211
- return null;
212
- }
213
- }
214
-
215
- // ---------------------------------------------------------------------------
216
- // Formatting helpers
217
- // ---------------------------------------------------------------------------
218
-
219
- function formatNumber(n) {
220
- if (n === null || n === undefined) return 'N/A';
221
- return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
222
- }
223
-
224
- function syncStatus(nodeSlot, networkSlot) {
225
- if (nodeSlot === null || networkSlot === null) {
226
- return { label: `${C.yellow}UNKNOWN${C.reset}`, icon: '?', color: C.yellow };
227
- }
228
- const diff = networkSlot - nodeSlot;
229
- const pct = networkSlot > 0 ? ((nodeSlot / networkSlot) * 100).toFixed(1) : '0.0';
230
-
231
- if (diff <= 0) {
232
- return { label: `${C.green}SYNCED${C.reset}`, icon: '✓', color: C.green, diff };
233
- }
234
- if (diff <= 5) {
235
- return { label: `${C.green}CATCHING UP${C.reset}`, icon: '◐', color: C.green, diff };
236
- }
237
- if (diff <= 50) {
238
- return { label: `${C.yellow}BEHIND${C.reset}`, icon: '◑', color: C.yellow, diff };
239
- }
240
- return { label: `${C.red}FAR BEHIND${C.reset}`, icon: '✗', color: C.red, diff };
241
- }
242
-
243
- function progressBar(nodeSlot, networkSlot, width = 30) {
244
- if (nodeSlot === null || networkSlot === null || networkSlot === 0) {
245
- return `${C.dim}[${'─'.repeat(width)}]${C.reset} N/A`;
246
- }
247
- const ratio = Math.min(nodeSlot / networkSlot, 1);
248
- const filled = Math.round(ratio * width);
249
- const empty = width - filled;
250
- return (
251
- `${C.green}[${'█'.repeat(filled)}${C.dim}${'─'.repeat(empty)}${C.reset}]` +
252
- ` ${(ratio * 100).toFixed(1)}%`
253
- );
254
- }
255
-
256
- function catchupEstimate(nodeSlot, networkSlot) {
257
- if (nodeSlot === null || networkSlot === null || networkSlot <= nodeSlot) return null;
258
- const diff = networkSlot - nodeSlot;
259
- // Rough estimate: ~2 slots/second typical throughput
260
- const seconds = Math.floor(diff / 2);
261
- if (seconds < 60) return `~${seconds}s`;
262
- if (seconds < 3600) return `~${Math.floor(seconds / 60)}m`;
263
- return `~${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
264
- }
265
-
266
- // ---------------------------------------------------------------------------
267
- // Renderers
268
- // ---------------------------------------------------------------------------
269
-
270
- function renderSync(data, rpc) {
271
- const { nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData, asJson } = data;
272
-
273
- if (asJson) {
274
- console.log(JSON.stringify({
275
- rpc,
276
- fetchedAt: new Date().toISOString(),
277
- node: {
278
- slot: nodeSlot,
279
- blockHeight,
280
- },
281
- network: {
282
- slot: networkSlot,
283
- },
284
- sync: {
285
- status: syncStatus(nodeSlot, networkSlot).label.replace(/\x1b\[\d+m/g, ''),
286
- slotsBehind: networkSlot !== null && nodeSlot !== null ? Math.max(0, networkSlot - nodeSlot) : null,
287
- percentSynced: nodeSlot !== null && networkSlot !== null && networkSlot > 0
288
- ? parseFloat((nodeSlot / networkSlot * 100).toFixed(2))
289
- : null,
290
- },
291
- epoch: epochData ? {
292
- epoch: epochData.epoch,
293
- slotIndex: epochData.slot_index,
294
- slotsInEpoch: epochData.slots_in_epoch,
295
- absoluteSlot: epochData.absolute_slot,
296
- } : null,
297
- snapshot: snapshotData && !snapshotData.error ? {
298
- fullSnapshotSlot: snapshotData.full_snapshot_slot ?? snapshotData.snapshot_slot ?? null,
299
- incrementalSnapshotSlot: snapshotData.incremental_snapshot_slot ?? null,
300
- } : null,
301
- version: versionData?.version ?? versionData?.solana_core ?? versionData?.['software-version'] ?? null,
302
- healthy: healthData && !healthData.error ? (healthData.ok ?? true) : null,
303
- }, null, 2));
304
- return;
305
- }
306
-
307
- const status = syncStatus(nodeSlot, networkSlot);
308
- const now = new Date().toLocaleTimeString();
309
- const catchup = catchupEstimate(nodeSlot, networkSlot);
310
-
311
- console.log();
312
- console.log(`${C.bright}${C.cyan}╔═══════════════════════════════════════════════════════════════════╗${C.reset}`);
313
- console.log(`${C.bright}${C.cyan}║${C.reset} ${C.bright}AETHER NODE SNAPSHOT / SYNC STATUS${C.reset}${C.cyan} ║${C.reset}`);
314
- console.log(`${C.bright}${C.cyan}╚═══════════════════════════════════════════════════════════════════╝${C.reset}`);
315
- console.log(` ${C.dim}RPC:${C.reset} ${rpc}`);
316
- console.log(` ${C.dim}Updated:${C.reset} ${now}`);
317
- console.log();
318
-
319
- // Health indicator
320
- if (healthData && !healthData.error) {
321
- const ok = healthData.ok ?? true;
322
- console.log(` ${C.bright}┌──────────────────────────────────────────────────────────────────────┐${C.reset}`);
323
- console.log(` ${C.bright}│${C.reset} Node Health: ${ok ? `${C.green}● HEALTHY${C.reset}` : `${C.red}● UNHEALTHY${C.reset}`}`.padEnd(65) + `${C.bright}│${C.reset}`);
324
- console.log(` ${C.bright}└──────────────────────────────────────────────────────────────────────┘${C.reset}`);
325
- console.log();
326
- } else if (healthData && healthData.error) {
327
- console.log(` ${C.red}⚠ Health check failed:${C.reset} ${healthData.error}\n`);
328
- }
329
-
330
- // Sync status — large prominent display
331
- console.log(` ${C.bright}┌──────────────────────────────────────────────────────────────────────┐${C.reset}`);
332
- console.log(` ${C.bright}│${C.reset} Sync Status: ${status.color}${C.bright}${status.label}${C.reset}`.padEnd(65) + `${C.bright}│${C.reset}`);
333
- if (status.diff !== undefined && status.diff > 0) {
334
- console.log(` ${C.bright}│${C.reset} Slots behind: ${C.yellow}${formatNumber(status.diff)}${C.reset}`.padEnd(65) + `${C.bright}│${C.reset}`);
335
- if (catchup) {
336
- console.log(` ${C.bright}│${C.reset} Est. time to sync: ${C.cyan}${catchup}${C.reset}`.padEnd(65) + `${C.bright}│${C.reset}`);
337
- }
338
- }
339
- console.log(` ${C.bright}└──────────────────────────────────────────────────────────────────────┘${C.reset}`);
340
- console.log();
341
-
342
- // Progress bar
343
- console.log(` ${C.bright}── Slot Progress ───────────────────────────────────────────${C.reset}`);
344
- console.log(` ${progressBar(nodeSlot, networkSlot)}`);
345
- console.log();
346
-
347
- // Slot details
348
- console.log(` ${C.bright}┌────────────────────┬────────────────────┐${C.reset}`);
349
- console.log(` ${C.bright}│${C.reset} ${C.cyan}Your Node${C.reset} ${C.bright}│${C.reset} ${C.cyan}Network${C.reset} ${C.bright}│${C.reset}`);
350
- console.log(` ${C.bright}├────────────────────┼────────────────────┤${C.reset}`);
351
- const nodeStr = nodeSlot !== null ? formatNumber(nodeSlot) : 'N/A';
352
- const netStr = networkSlot !== null ? formatNumber(networkSlot) : 'N/A';
353
- console.log(` ${C.bright}│${C.reset} Slot: ${C.green}${nodeStr.padEnd(22)}${C.reset} ${C.bright}│${C.reset} Slot: ${C.cyan}${netStr.padEnd(22)}${C.reset} ${C.bright}│${C.reset}`);
354
- const bhStr = blockHeight !== null ? formatNumber(blockHeight) : 'N/A';
355
- console.log(` ${C.bright}│${C.reset} Block: ${C.blue}${bhStr.padEnd(21)}${C.reset} ${C.bright}│${C.reset} ${C.dim}Block: same as slot${C.reset} ${C.bright}│${C.reset}`);
356
- console.log(` ${C.bright}└────────────────────┴────────────────────┘${C.reset}`);
357
- console.log();
358
-
359
- // Epoch info
360
- if (epochData && epochData.epoch !== undefined) {
361
- console.log(` ${C.bright}── Epoch Info ──────────────────────────────────────────────${C.reset}`);
362
- const ep = epochData.epoch;
363
- const slotIdx = epochData.slot_index !== undefined ? epochData.slot_index : '?';
364
- const slotsInEp = epochData.slots_in_epoch !== undefined ? epochData.slots_in_epoch : '?';
365
- const progress = slotsInEp !== '?' && slotsInEp > 0
366
- ? ((slotIdx / slotsInEp) * 100).toFixed(1) + '%'
367
- : '?';
368
- console.log(` ${C.dim}Epoch:${C.reset} ${C.bright}${ep}${C.reset} ${C.dim}Slot in epoch:${C.reset} ${C.bright}${slotIdx} / ${slotsInEp}${C.reset} ${C.dim}(${progress})${C.reset}`);
369
- if (epochData.absolute_slot !== undefined) {
370
- console.log(` ${C.dim}Absolute slot:${C.reset} ${C.bright}${formatNumber(epochData.absolute_slot)}${C.reset}`);
371
- }
372
- console.log();
373
- }
374
-
375
- // Snapshot info
376
- if (snapshotData && !snapshotData.error) {
377
- console.log(` ${C.bright}── Snapshot Info ───────────────────────────────────────────${C.reset}`);
378
- const fullSnap = snapshotData.full_snapshot_slot ?? snapshotData.snapshot_slot ?? null;
379
- const incSnap = snapshotData.incremental_snapshot_slot ?? null;
380
-
381
- if (fullSnap !== null) {
382
- const age = networkSlot !== null ? formatNumber(networkSlot - fullSnap) : null;
383
- console.log(` ${C.dim}Full snapshot slot:${C.reset} ${C.green}${formatNumber(fullSnap)}${C.reset}`);
384
- if (age !== null) {
385
- console.log(` ${C.dim} (~${age} slots ago)${C.reset}`);
386
- }
387
- if (nodeSlot !== null && networkSlot !== null) {
388
- const snapAge = networkSlot - fullSnap;
389
- const freshness = snapAge < 100 ? `${C.green}fresh${C.reset}` : snapAge < 1000 ? `${C.yellow}aging${C.reset}` : `${C.red}old${C.reset}`;
390
- console.log(` ${C.dim} Snapshot freshness:${C.reset} ${freshness}`);
391
- }
392
- } else {
393
- console.log(` ${C.dim}No full snapshot available.${C.reset}`);
394
- }
395
-
396
- if (incSnap !== null) {
397
- console.log(` ${C.dim}Incremental snapshot slot:${C.reset} ${C.cyan}${formatNumber(incSnap)}${C.reset}`);
398
- }
399
- console.log();
400
- }
401
-
402
- // Version info
403
- if (versionData && !versionData.error) {
404
- const ver = versionData.version || versionData.solana_core || versionData['software-version'];
405
- if (ver) {
406
- console.log(` ${C.bright}── Node Version ───────────────────────────────────────────${C.reset}`);
407
- console.log(` ${C.dim}Version:${C.reset} ${C.green}${ver}${C.reset}`);
408
- console.log();
409
- }
410
- }
411
-
412
- // Tips
413
- if (status.diff === 0 || status.diff === undefined) {
414
- console.log(` ${C.green}✓ Node is fully synced with the network.${C.reset}`);
415
- } else if (status.diff > 0) {
416
- console.log(` ${C.yellow}⏳ Node is catching up — this is normal on first start or after a restart.${C.reset}`);
417
- console.log(` ${C.dim} For faster sync, try downloading a recent snapshot from a peer.${C.reset}`);
418
- console.log(` ${C.dim} Check: aether-cli network --peers${C.reset}`);
419
- }
420
- console.log();
421
- console.log(` ${C.dim}Tip: --watch for live view | --json for scripting${C.reset}`);
422
- console.log();
423
- }
424
-
425
- // ---------------------------------------------------------------------------
426
- // Watch mode
427
- // ---------------------------------------------------------------------------
428
-
429
- async function watchMode(rpc) {
430
- const readline = require('readline');
431
- let running = true;
432
-
433
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
434
- const drainStdin = () => {
435
- rl.close();
436
- running = false;
437
- };
438
- process.stdin.on('data', drainStdin);
439
- process.stdin.resume();
440
-
441
- console.log(` ${C.cyan}Live sync monitoring started.${C.reset} ${C.dim}Press Ctrl+C to stop.${C.reset}\n`);
442
-
443
- while (running) {
444
- // Move cursor up and clear lines for clean refresh
445
- process.stdout.write('\x1b[2J\x1b[H');
446
-
447
- try {
448
- const [nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData] =
449
- await Promise.all([
450
- getSlot(rpc),
451
- getSlot(rpc), // network slot uses same endpoint
452
- getBlockHeight(rpc),
453
- getEpoch(rpc),
454
- getSnapshot(rpc),
455
- getVersion(rpc),
456
- getHealth(rpc),
457
- ]);
458
-
459
- renderSync({ nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData, asJson: false }, rpc);
460
- } catch (err) {
461
- console.log(` ${C.red}✗ Error fetching data:${C.reset} ${err.message}`);
462
- }
463
-
464
- if (!running) break;
465
- await new Promise((res) => setTimeout(res, REFRESH_INTERVAL_MS));
466
- }
467
-
468
- process.stdin.pause();
469
- process.stdin.off('data', drainStdin);
470
- console.log(`\n ${C.dim}Stopped.${C.reset}\n`);
471
- }
472
-
473
- // ---------------------------------------------------------------------------
474
- // Main
475
- // ---------------------------------------------------------------------------
476
-
477
- async function snapshotCommand() {
478
- const opts = parseArgs();
479
- const rpc = opts.rpc;
480
-
481
- if (opts.watch) {
482
- await watchMode(rpc);
483
- return;
484
- }
485
-
486
- const [nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData] =
487
- await Promise.all([
488
- getSlot(rpc),
489
- getSlot(rpc), // same endpoint for network slot
490
- getBlockHeight(rpc),
491
- getEpoch(rpc),
492
- getSnapshot(rpc),
493
- getVersion(rpc),
494
- getHealth(rpc),
495
- ]);
496
-
497
- renderSync({ nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData, asJson: opts.asJson }, rpc);
498
- }
499
-
500
- module.exports = { snapshotCommand };
501
-
502
- if (require.main === module) {
503
- snapshotCommand().catch((err) => {
504
- console.error(`\n${C.red}✗ Snapshot command failed:${C.reset} ${err.message}`);
505
- console.error(` ${C.dim}Check that your validator is running and RPC is accessible.${C.reset}`);
506
- console.error(` ${C.dim}Set custom RPC: AETHER_RPC=http://your-rpc-url${C.reset}\n`);
507
- process.exit(1);
508
- });
509
- }
1
+ /**
2
+ * aether-cli snapshot - Aether Node Sync & Snapshot Status
3
+ *
4
+ * Shows how far your node has synced vs the network, snapshot availability,
5
+ * and whether your node is catching up or is fully current.
6
+ *
7
+ * Usage:
8
+ * aether-cli snapshot # Interactive sync status view
9
+ * aether-cli snapshot --json # JSON output for scripting
10
+ * aether-cli snapshot --rpc <url> # Query a specific RPC endpoint
11
+ * aether-cli snapshot --watch # Refresh every 5 seconds
12
+ *
13
+ * @see docs/MINING_VALIDATOR_TOOLS.md for spec
14
+ */
15
+
16
+ const http = require('http');
17
+ const https = require('https');
18
+
19
+ // ANSI colours
20
+ const C = {
21
+ reset: '\x1b[0m',
22
+ bright: '\x1b[1m',
23
+ dim: '\x1b[2m',
24
+ red: '\x1b[31m',
25
+ green: '\x1b[32m',
26
+ yellow: '\x1b[33m',
27
+ blue: '\x1b[34m',
28
+ cyan: '\x1b[36m',
29
+ magenta: '\x1b[35m',
30
+ };
31
+
32
+ const DEFAULT_RPC = process.env.AETHER_RPC || 'http://127.0.0.1:8899';
33
+ const REFRESH_INTERVAL_MS = 5000;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // HTTP helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function httpRequest(rpcUrl, path, options = {}) {
40
+ return new Promise((resolve, reject) => {
41
+ const url = new URL(path, rpcUrl);
42
+ const isHttps = url.protocol === 'https:';
43
+ const lib = isHttps ? https : http;
44
+
45
+ const reqOptions = {
46
+ hostname: url.hostname,
47
+ port: url.port || (isHttps ? 443 : 80),
48
+ path: url.pathname + url.search,
49
+ method: 'GET',
50
+ timeout: 8000,
51
+ headers: { 'Content-Type': 'application/json' },
52
+ };
53
+
54
+ const req = lib.request(reqOptions, (res) => {
55
+ let data = '';
56
+ res.on('data', (chunk) => (data += chunk));
57
+ res.on('end', () => {
58
+ try { resolve(JSON.parse(data)); }
59
+ catch { resolve({ raw: data }); }
60
+ });
61
+ });
62
+
63
+ req.on('error', reject);
64
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ function httpPost(rpcUrl, path, body) {
70
+ return new Promise((resolve, reject) => {
71
+ const url = new URL(path, rpcUrl);
72
+ const isHttps = url.protocol === 'https:';
73
+ const lib = isHttps ? https : http;
74
+ const bodyStr = JSON.stringify(body);
75
+
76
+ const req = lib.request({
77
+ hostname: url.hostname,
78
+ port: url.port || (isHttps ? 443 : 80),
79
+ path: url.pathname + url.search,
80
+ method: 'POST',
81
+ timeout: 8000,
82
+ headers: {
83
+ 'Content-Type': 'application/json',
84
+ 'Content-Length': Buffer.byteLength(bodyStr),
85
+ },
86
+ }, (res) => {
87
+ let data = '';
88
+ res.on('data', (chunk) => (data += chunk));
89
+ res.on('end', () => {
90
+ try { resolve(JSON.parse(data)); }
91
+ catch { resolve(data); }
92
+ });
93
+ });
94
+
95
+ req.on('error', reject);
96
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
97
+ req.write(bodyStr);
98
+ req.end();
99
+ });
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Argument parsing
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function parseArgs() {
107
+ const args = process.argv.slice(2);
108
+ const options = {
109
+ rpc: DEFAULT_RPC,
110
+ asJson: false,
111
+ watch: false,
112
+ };
113
+
114
+ for (let i = 0; i < args.length; i++) {
115
+ if (args[i] === '--rpc' || args[i] === '-r') {
116
+ options.rpc = args[++i];
117
+ } else if (args[i] === '--json' || args[i] === '-j') {
118
+ options.asJson = true;
119
+ } else if (args[i] === '--watch' || args[i] === '-w') {
120
+ options.watch = true;
121
+ } else if (args[i] === '--help' || args[i] === '-h') {
122
+ showHelp();
123
+ process.exit(0);
124
+ }
125
+ }
126
+
127
+ return options;
128
+ }
129
+
130
+ function showHelp() {
131
+ console.log(`
132
+ ${C.bright}${C.cyan}aether-cli snapshot${C.reset} - Aether Node Sync & Snapshot Status
133
+
134
+ ${C.bright}Usage:${C.reset}
135
+ aether-cli snapshot [options]
136
+
137
+ ${C.bright}Options:${C.reset}
138
+ -r, --rpc <url> RPC endpoint (default: ${DEFAULT_RPC} or $AETHER_RPC)
139
+ -j, --json Output raw JSON (good for scripting)
140
+ -w, --watch Refresh every 5 seconds (live view)
141
+ -h, --help Show this help message
142
+
143
+ ${C.bright}Examples:${C.reset}
144
+ aether-cli snapshot # Interactive sync status
145
+ aether-cli snapshot --json # JSON output
146
+ aether-cli snapshot --watch # Live refreshing view
147
+ aether-cli snapshot --rpc https://api.testnet.aether.network
148
+ `.trim());
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Data fetchers
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** GET /v1/slot — current network slot */
156
+ async function getSlot(rpc) {
157
+ try {
158
+ const res = await httpRequest(rpc, '/v1/slot');
159
+ return res.slot ?? res.root_slot ?? null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /** GET /v1/block_height — node's synced block height */
166
+ async function getBlockHeight(rpc) {
167
+ try {
168
+ const res = await httpRequest(rpc, '/v1/block_height');
169
+ return res.block_height ?? null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /** GET /v1/epoch — current epoch info */
176
+ async function getEpoch(rpc) {
177
+ try {
178
+ const res = await httpRequest(rpc, '/v1/epoch');
179
+ return res;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /** GET /v1/snapshot — snapshot slot info */
186
+ async function getSnapshot(rpc) {
187
+ try {
188
+ const res = await httpRequest(rpc, '/v1/snapshot');
189
+ return res;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /** POST /v1/version — node version info */
196
+ async function getVersion(rpc) {
197
+ try {
198
+ const res = await httpPost(rpc, '/v1/version', {});
199
+ return res;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /** GET /v1/health — node health (if supported) */
206
+ async function getHealth(rpc) {
207
+ try {
208
+ const res = await httpRequest(rpc, '/v1/health');
209
+ return res;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Formatting helpers
217
+ // ---------------------------------------------------------------------------
218
+
219
+ function formatNumber(n) {
220
+ if (n === null || n === undefined) return 'N/A';
221
+ return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
222
+ }
223
+
224
+ function syncStatus(nodeSlot, networkSlot) {
225
+ if (nodeSlot === null || networkSlot === null) {
226
+ return { label: `${C.yellow}UNKNOWN${C.reset}`, icon: '?', color: C.yellow };
227
+ }
228
+ const diff = networkSlot - nodeSlot;
229
+ const pct = networkSlot > 0 ? ((nodeSlot / networkSlot) * 100).toFixed(1) : '0.0';
230
+
231
+ if (diff <= 0) {
232
+ return { label: `${C.green}SYNCED${C.reset}`, icon: '✓', color: C.green, diff };
233
+ }
234
+ if (diff <= 5) {
235
+ return { label: `${C.green}CATCHING UP${C.reset}`, icon: '◐', color: C.green, diff };
236
+ }
237
+ if (diff <= 50) {
238
+ return { label: `${C.yellow}BEHIND${C.reset}`, icon: '◑', color: C.yellow, diff };
239
+ }
240
+ return { label: `${C.red}FAR BEHIND${C.reset}`, icon: '✗', color: C.red, diff };
241
+ }
242
+
243
+ function progressBar(nodeSlot, networkSlot, width = 30) {
244
+ if (nodeSlot === null || networkSlot === null || networkSlot === 0) {
245
+ return `${C.dim}[${'─'.repeat(width)}]${C.reset} N/A`;
246
+ }
247
+ const ratio = Math.min(nodeSlot / networkSlot, 1);
248
+ const filled = Math.round(ratio * width);
249
+ const empty = width - filled;
250
+ return (
251
+ `${C.green}[${'█'.repeat(filled)}${C.dim}${'─'.repeat(empty)}${C.reset}]` +
252
+ ` ${(ratio * 100).toFixed(1)}%`
253
+ );
254
+ }
255
+
256
+ function catchupEstimate(nodeSlot, networkSlot) {
257
+ if (nodeSlot === null || networkSlot === null || networkSlot <= nodeSlot) return null;
258
+ const diff = networkSlot - nodeSlot;
259
+ // Rough estimate: ~2 slots/second typical throughput
260
+ const seconds = Math.floor(diff / 2);
261
+ if (seconds < 60) return `~${seconds}s`;
262
+ if (seconds < 3600) return `~${Math.floor(seconds / 60)}m`;
263
+ return `~${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Renderers
268
+ // ---------------------------------------------------------------------------
269
+
270
+ function renderSync(data, rpc) {
271
+ const { nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData, asJson } = data;
272
+
273
+ if (asJson) {
274
+ console.log(JSON.stringify({
275
+ rpc,
276
+ fetchedAt: new Date().toISOString(),
277
+ node: {
278
+ slot: nodeSlot,
279
+ blockHeight,
280
+ },
281
+ network: {
282
+ slot: networkSlot,
283
+ },
284
+ sync: {
285
+ status: syncStatus(nodeSlot, networkSlot).label.replace(/\x1b\[\d+m/g, ''),
286
+ slotsBehind: networkSlot !== null && nodeSlot !== null ? Math.max(0, networkSlot - nodeSlot) : null,
287
+ percentSynced: nodeSlot !== null && networkSlot !== null && networkSlot > 0
288
+ ? parseFloat((nodeSlot / networkSlot * 100).toFixed(2))
289
+ : null,
290
+ },
291
+ epoch: epochData ? {
292
+ epoch: epochData.epoch,
293
+ slotIndex: epochData.slot_index,
294
+ slotsInEpoch: epochData.slots_in_epoch,
295
+ absoluteSlot: epochData.absolute_slot,
296
+ } : null,
297
+ snapshot: snapshotData && !snapshotData.error ? {
298
+ fullSnapshotSlot: snapshotData.full_snapshot_slot ?? snapshotData.snapshot_slot ?? null,
299
+ incrementalSnapshotSlot: snapshotData.incremental_snapshot_slot ?? null,
300
+ } : null,
301
+ version: versionData?.version ?? versionData?.solana_core ?? versionData?.['software-version'] ?? null,
302
+ healthy: healthData && !healthData.error ? (healthData.ok ?? true) : null,
303
+ }, null, 2));
304
+ return;
305
+ }
306
+
307
+ const status = syncStatus(nodeSlot, networkSlot);
308
+ const now = new Date().toLocaleTimeString();
309
+ const catchup = catchupEstimate(nodeSlot, networkSlot);
310
+
311
+ console.log();
312
+ console.log(`${C.bright}${C.cyan}╔═══════════════════════════════════════════════════════════════════╗${C.reset}`);
313
+ console.log(`${C.bright}${C.cyan}║${C.reset} ${C.bright}AETHER NODE SNAPSHOT / SYNC STATUS${C.reset}${C.cyan} ║${C.reset}`);
314
+ console.log(`${C.bright}${C.cyan}╚═══════════════════════════════════════════════════════════════════╝${C.reset}`);
315
+ console.log(` ${C.dim}RPC:${C.reset} ${rpc}`);
316
+ console.log(` ${C.dim}Updated:${C.reset} ${now}`);
317
+ console.log();
318
+
319
+ // Health indicator
320
+ if (healthData && !healthData.error) {
321
+ const ok = healthData.ok ?? true;
322
+ console.log(` ${C.bright}┌──────────────────────────────────────────────────────────────────────┐${C.reset}`);
323
+ console.log(` ${C.bright}│${C.reset} Node Health: ${ok ? `${C.green}● HEALTHY${C.reset}` : `${C.red}● UNHEALTHY${C.reset}`}`.padEnd(65) + `${C.bright}│${C.reset}`);
324
+ console.log(` ${C.bright}└──────────────────────────────────────────────────────────────────────┘${C.reset}`);
325
+ console.log();
326
+ } else if (healthData && healthData.error) {
327
+ console.log(` ${C.red}⚠ Health check failed:${C.reset} ${healthData.error}\n`);
328
+ }
329
+
330
+ // Sync status — large prominent display
331
+ console.log(` ${C.bright}┌──────────────────────────────────────────────────────────────────────┐${C.reset}`);
332
+ console.log(` ${C.bright}│${C.reset} Sync Status: ${status.color}${C.bright}${status.label}${C.reset}`.padEnd(65) + `${C.bright}│${C.reset}`);
333
+ if (status.diff !== undefined && status.diff > 0) {
334
+ console.log(` ${C.bright}│${C.reset} Slots behind: ${C.yellow}${formatNumber(status.diff)}${C.reset}`.padEnd(65) + `${C.bright}│${C.reset}`);
335
+ if (catchup) {
336
+ console.log(` ${C.bright}│${C.reset} Est. time to sync: ${C.cyan}${catchup}${C.reset}`.padEnd(65) + `${C.bright}│${C.reset}`);
337
+ }
338
+ }
339
+ console.log(` ${C.bright}└──────────────────────────────────────────────────────────────────────┘${C.reset}`);
340
+ console.log();
341
+
342
+ // Progress bar
343
+ console.log(` ${C.bright}── Slot Progress ───────────────────────────────────────────${C.reset}`);
344
+ console.log(` ${progressBar(nodeSlot, networkSlot)}`);
345
+ console.log();
346
+
347
+ // Slot details
348
+ console.log(` ${C.bright}┌────────────────────┬────────────────────┐${C.reset}`);
349
+ console.log(` ${C.bright}│${C.reset} ${C.cyan}Your Node${C.reset} ${C.bright}│${C.reset} ${C.cyan}Network${C.reset} ${C.bright}│${C.reset}`);
350
+ console.log(` ${C.bright}├────────────────────┼────────────────────┤${C.reset}`);
351
+ const nodeStr = nodeSlot !== null ? formatNumber(nodeSlot) : 'N/A';
352
+ const netStr = networkSlot !== null ? formatNumber(networkSlot) : 'N/A';
353
+ console.log(` ${C.bright}│${C.reset} Slot: ${C.green}${nodeStr.padEnd(22)}${C.reset} ${C.bright}│${C.reset} Slot: ${C.cyan}${netStr.padEnd(22)}${C.reset} ${C.bright}│${C.reset}`);
354
+ const bhStr = blockHeight !== null ? formatNumber(blockHeight) : 'N/A';
355
+ console.log(` ${C.bright}│${C.reset} Block: ${C.blue}${bhStr.padEnd(21)}${C.reset} ${C.bright}│${C.reset} ${C.dim}Block: same as slot${C.reset} ${C.bright}│${C.reset}`);
356
+ console.log(` ${C.bright}└────────────────────┴────────────────────┘${C.reset}`);
357
+ console.log();
358
+
359
+ // Epoch info
360
+ if (epochData && epochData.epoch !== undefined) {
361
+ console.log(` ${C.bright}── Epoch Info ──────────────────────────────────────────────${C.reset}`);
362
+ const ep = epochData.epoch;
363
+ const slotIdx = epochData.slot_index !== undefined ? epochData.slot_index : '?';
364
+ const slotsInEp = epochData.slots_in_epoch !== undefined ? epochData.slots_in_epoch : '?';
365
+ const progress = slotsInEp !== '?' && slotsInEp > 0
366
+ ? ((slotIdx / slotsInEp) * 100).toFixed(1) + '%'
367
+ : '?';
368
+ console.log(` ${C.dim}Epoch:${C.reset} ${C.bright}${ep}${C.reset} ${C.dim}Slot in epoch:${C.reset} ${C.bright}${slotIdx} / ${slotsInEp}${C.reset} ${C.dim}(${progress})${C.reset}`);
369
+ if (epochData.absolute_slot !== undefined) {
370
+ console.log(` ${C.dim}Absolute slot:${C.reset} ${C.bright}${formatNumber(epochData.absolute_slot)}${C.reset}`);
371
+ }
372
+ console.log();
373
+ }
374
+
375
+ // Snapshot info
376
+ if (snapshotData && !snapshotData.error) {
377
+ console.log(` ${C.bright}── Snapshot Info ───────────────────────────────────────────${C.reset}`);
378
+ const fullSnap = snapshotData.full_snapshot_slot ?? snapshotData.snapshot_slot ?? null;
379
+ const incSnap = snapshotData.incremental_snapshot_slot ?? null;
380
+
381
+ if (fullSnap !== null) {
382
+ const age = networkSlot !== null ? formatNumber(networkSlot - fullSnap) : null;
383
+ console.log(` ${C.dim}Full snapshot slot:${C.reset} ${C.green}${formatNumber(fullSnap)}${C.reset}`);
384
+ if (age !== null) {
385
+ console.log(` ${C.dim} (~${age} slots ago)${C.reset}`);
386
+ }
387
+ if (nodeSlot !== null && networkSlot !== null) {
388
+ const snapAge = networkSlot - fullSnap;
389
+ const freshness = snapAge < 100 ? `${C.green}fresh${C.reset}` : snapAge < 1000 ? `${C.yellow}aging${C.reset}` : `${C.red}old${C.reset}`;
390
+ console.log(` ${C.dim} Snapshot freshness:${C.reset} ${freshness}`);
391
+ }
392
+ } else {
393
+ console.log(` ${C.dim}No full snapshot available.${C.reset}`);
394
+ }
395
+
396
+ if (incSnap !== null) {
397
+ console.log(` ${C.dim}Incremental snapshot slot:${C.reset} ${C.cyan}${formatNumber(incSnap)}${C.reset}`);
398
+ }
399
+ console.log();
400
+ }
401
+
402
+ // Version info
403
+ if (versionData && !versionData.error) {
404
+ const ver = versionData.version || versionData.solana_core || versionData['software-version'];
405
+ if (ver) {
406
+ console.log(` ${C.bright}── Node Version ───────────────────────────────────────────${C.reset}`);
407
+ console.log(` ${C.dim}Version:${C.reset} ${C.green}${ver}${C.reset}`);
408
+ console.log();
409
+ }
410
+ }
411
+
412
+ // Tips
413
+ if (status.diff === 0 || status.diff === undefined) {
414
+ console.log(` ${C.green}✓ Node is fully synced with the network.${C.reset}`);
415
+ } else if (status.diff > 0) {
416
+ console.log(` ${C.yellow}⏳ Node is catching up — this is normal on first start or after a restart.${C.reset}`);
417
+ console.log(` ${C.dim} For faster sync, try downloading a recent snapshot from a peer.${C.reset}`);
418
+ console.log(` ${C.dim} Check: aether-cli network --peers${C.reset}`);
419
+ }
420
+ console.log();
421
+ console.log(` ${C.dim}Tip: --watch for live view | --json for scripting${C.reset}`);
422
+ console.log();
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Watch mode
427
+ // ---------------------------------------------------------------------------
428
+
429
+ async function watchMode(rpc) {
430
+ const readline = require('readline');
431
+ let running = true;
432
+
433
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
434
+ const drainStdin = () => {
435
+ rl.close();
436
+ running = false;
437
+ };
438
+ process.stdin.on('data', drainStdin);
439
+ process.stdin.resume();
440
+
441
+ console.log(` ${C.cyan}Live sync monitoring started.${C.reset} ${C.dim}Press Ctrl+C to stop.${C.reset}\n`);
442
+
443
+ while (running) {
444
+ // Move cursor up and clear lines for clean refresh
445
+ process.stdout.write('\x1b[2J\x1b[H');
446
+
447
+ try {
448
+ const [nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData] =
449
+ await Promise.all([
450
+ getSlot(rpc),
451
+ getSlot(rpc), // network slot uses same endpoint
452
+ getBlockHeight(rpc),
453
+ getEpoch(rpc),
454
+ getSnapshot(rpc),
455
+ getVersion(rpc),
456
+ getHealth(rpc),
457
+ ]);
458
+
459
+ renderSync({ nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData, asJson: false }, rpc);
460
+ } catch (err) {
461
+ console.log(` ${C.red}✗ Error fetching data:${C.reset} ${err.message}`);
462
+ }
463
+
464
+ if (!running) break;
465
+ await new Promise((res) => setTimeout(res, REFRESH_INTERVAL_MS));
466
+ }
467
+
468
+ process.stdin.pause();
469
+ process.stdin.off('data', drainStdin);
470
+ console.log(`\n ${C.dim}Stopped.${C.reset}\n`);
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Main
475
+ // ---------------------------------------------------------------------------
476
+
477
+ async function snapshotCommand() {
478
+ const opts = parseArgs();
479
+ const rpc = opts.rpc;
480
+
481
+ if (opts.watch) {
482
+ await watchMode(rpc);
483
+ return;
484
+ }
485
+
486
+ const [nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData] =
487
+ await Promise.all([
488
+ getSlot(rpc),
489
+ getSlot(rpc), // same endpoint for network slot
490
+ getBlockHeight(rpc),
491
+ getEpoch(rpc),
492
+ getSnapshot(rpc),
493
+ getVersion(rpc),
494
+ getHealth(rpc),
495
+ ]);
496
+
497
+ renderSync({ nodeSlot, networkSlot, blockHeight, epochData, snapshotData, versionData, healthData, asJson: opts.asJson }, rpc);
498
+ }
499
+
500
+ module.exports = { snapshotCommand };
501
+
502
+ if (require.main === module) {
503
+ snapshotCommand().catch((err) => {
504
+ console.error(`\n${C.red}✗ Snapshot command failed:${C.reset} ${err.message}`);
505
+ console.error(` ${C.dim}Check that your validator is running and RPC is accessible.${C.reset}`);
506
+ console.error(` ${C.dim}Set custom RPC: AETHER_RPC=http://your-rpc-url${C.reset}\n`);
507
+ process.exit(1);
508
+ });
509
+ }