aether-hub 1.1.8 → 1.2.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.
@@ -1,326 +1,665 @@
1
- /**
2
- * aether-cli validators - Aether Validator Registry
3
- *
4
- * List and explore validators available for staking.
5
- * aether validators list — Show all active validators
6
- * aether validators list --json — JSON output for scripting
7
- * aether validators list --tier <full|lite|observer> — Filter by tier
8
- *
9
- * @see docs/MINING_VALIDATOR_TOOLS.md for spec
10
- */
11
-
12
- const http = require('http');
13
- const https = require('https');
14
-
15
- // ANSI colours
16
- const C = {
17
- reset: '\x1b[0m',
18
- bright: '\x1b[1m',
19
- dim: '\x1b[2m',
20
- red: '\x1b[31m',
21
- green: '\x1b[32m',
22
- yellow: '\x1b[33m',
23
- blue: '\x1b[34m',
24
- cyan: '\x1b[36m',
25
- magenta: '\x1b[35m',
26
- };
27
-
28
- const DEFAULT_RPC = process.env.AETHER_RPC || 'http://127.0.0.1:8899';
29
-
30
- // ---------------------------------------------------------------------------
31
- // HTTP helpers (mirrors network.js patterns)
32
- // ---------------------------------------------------------------------------
33
-
34
- function httpRequest(rpcUrl, path) {
35
- return new Promise((resolve, reject) => {
36
- const url = new URL(path, rpcUrl);
37
- const isHttps = url.protocol === 'https:';
38
- const lib = isHttps ? https : http;
39
-
40
- const req = lib.request({
41
- hostname: url.hostname,
42
- port: url.port || (isHttps ? 443 : 80),
43
- path: url.pathname + url.search,
44
- method: 'GET',
45
- timeout: 8000,
46
- headers: { 'Content-Type': 'application/json' },
47
- }, (res) => {
48
- let data = '';
49
- res.on('data', (chunk) => (data += chunk));
50
- res.on('end', () => {
51
- try { resolve(JSON.parse(data)); }
52
- catch { resolve({ raw: data }); }
53
- });
54
- });
55
-
56
- req.on('error', reject);
57
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
58
- req.end();
59
- });
60
- }
61
-
62
- // ---------------------------------------------------------------------------
63
- // Argument parsing
64
- // ---------------------------------------------------------------------------
65
-
66
- function parseArgs() {
67
- const args = process.argv.slice(3); // skip 'validators' and subcommand
68
- const opts = {
69
- rpc: DEFAULT_RPC,
70
- tier: null,
71
- asJson: false,
72
- sort: 'stake', // 'stake' | 'name' | 'uptime'
73
- limit: 50,
74
- };
75
-
76
- for (let i = 0; i < args.length; i++) {
77
- if (args[i] === '--rpc' || args[i] === '-r') {
78
- opts.rpc = args[++i];
79
- } else if (args[i] === '--tier' || args[i] === '-t') {
80
- opts.tier = args[++i]?.toLowerCase();
81
- } else if (args[i] === '--json' || args[i] === '-j') {
82
- opts.asJson = true;
83
- } else if (args[i] === '--sort' || args[i] === '-s') {
84
- opts.sort = args[++i]?.toLowerCase();
85
- } else if (args[i] === '--limit' || args[i] === '-l') {
86
- opts.limit = parseInt(args[++i], 10) || 50;
87
- } else if (args[i] === '--help' || args[i] === '-h') {
88
- showHelp();
89
- process.exit(0);
90
- }
91
- }
92
-
93
- return opts;
94
- }
95
-
96
- function showHelp() {
97
- console.log(`
98
- ${C.bright}${C.cyan}aether-cli validators${C.reset} - Aether Validator Registry
99
-
100
- ${C.bright}Usage:${C.reset}
101
- aether validators list [options]
102
-
103
- ${C.bright}Options:${C.reset}
104
- -r, --rpc <url> RPC endpoint (default: ${DEFAULT_RPC} or $AETHER_RPC)
105
- -t, --tier <tier> Filter by tier: full, lite, observer
106
- -s, --sort <field> Sort by: stake (default), name, uptime
107
- -l, --limit <n> Max validators to show (default: 50)
108
- -j, --json Output raw JSON
109
- -h, --help Show this help
110
-
111
- ${C.bright}Examples:${C.reset}
112
- aether validators list
113
- aether validators list --tier full
114
- aether validators list --sort stake --limit 20
115
- aether validators list --json
116
- `.trim());
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // Data fetching
121
- // ---------------------------------------------------------------------------
122
-
123
- /** Fetch validators list from RPC */
124
- async function fetchValidators(rpc) {
125
- try {
126
- const res = await httpRequest(rpc, '/v1/validators');
127
- if (Array.isArray(res)) return res;
128
- if (res.validators && Array.isArray(res.validators)) return res.validators;
129
- return [];
130
- } catch (err) {
131
- return [];
132
- }
133
- }
134
-
135
- /** Fetch epoch info for context */
136
- async function fetchEpoch(rpc) {
137
- try {
138
- const res = await httpRequest(rpc, '/v1/epoch');
139
- return res;
140
- } catch {
141
- return null;
142
- }
143
- }
144
-
145
- // ---------------------------------------------------------------------------
146
- // Formatting helpers
147
- // ---------------------------------------------------------------------------
148
-
149
- function formatAether(lamports) {
150
- if (!lamports && lamports !== 0) return '?';
151
- const aeth = lamports / 1e9;
152
- if (aeth === 0) return '0';
153
- return aeth.toFixed(2).replace(/\.?0+$/, '');
154
- }
155
-
156
- function formatPct(n) {
157
- if (n === undefined || n === null) return '?';
158
- return n.toFixed(1) + '%';
159
- }
160
-
161
- function tierBadge(tier) {
162
- const map = {
163
- full: `${C.cyan}FULL${C.reset}`,
164
- lite: `${C.yellow}LITE${C.reset}`,
165
- observer: `${C.green}OBS${C.reset}`,
166
- };
167
- return map[tier?.toLowerCase()] || `${C.dim}?${C.reset}`;
168
- }
169
-
170
- function scoreColor(score) {
171
- if (score === undefined || score === null) return C.dim;
172
- if (score >= 90) return C.green;
173
- if (score >= 70) return C.yellow;
174
- return C.red;
175
- }
176
-
177
- function shortenAddr(addr, len = 16) {
178
- if (!addr) return '?';
179
- if (addr.length <= len) return addr;
180
- return addr.slice(0, 8) + '…' + addr.slice(-6);
181
- }
182
-
183
- // ---------------------------------------------------------------------------
184
- // Renderers
185
- // ---------------------------------------------------------------------------
186
-
187
- function renderList(validators, epochData, opts, rpc) {
188
- const filtered = opts.tier
189
- ? validators.filter(v => (v.tier || v.node_type || '').toLowerCase() === opts.tier)
190
- : validators;
191
-
192
- // Sort
193
- if (opts.sort === 'name') {
194
- filtered.sort((a, b) => (a.name || a.address || '').localeCompare(b.name || b.address || ''));
195
- } else if (opts.sort === 'uptime') {
196
- filtered.sort((a, b) => (b.uptime || b.score || 0) - (a.uptime || a.score || 0));
197
- } else {
198
- // Default: by stake (descending)
199
- filtered.sort((a, b) => (b.stake || b.stake_amount || 0) - (a.stake || a.stake_amount || 0));
200
- }
201
-
202
- const shown = filtered.slice(0, opts.limit);
203
- const totalStake = validators.reduce((sum, v) => sum + (v.stake || v.stake_amount || 0), 0);
204
- const networkScore = validators.reduce((sum, v) => sum + (v.score || v.uptime || 0) * (v.stake || 1), 0) / (totalStake || 1);
205
-
206
- console.log();
207
- console.log(`${C.bright}${C.cyan}╔═══════════════════════════════════════════════════════════════════════╗${C.reset}`);
208
- console.log(`${C.bright}${C.cyan}║${C.reset} ${C.bright}AETHER VALIDATOR REGISTRY${C.reset}${C.cyan} ║${C.reset}`);
209
- console.log(`${C.bright}${C.cyan}╚═══════════════════════════════════════════════════════════════════════╝${C.reset}`);
210
- console.log(` ${C.dim}RPC:${C.reset} ${rpc}`);
211
- console.log(` ${C.dim}Total validators:${C.reset} ${C.bright}${validators.length}${C.reset}`);
212
- if (opts.tier) console.log(` ${C.dim}Filtered by tier:${C.reset} ${C.bright}${opts.tier}${C.reset}`);
213
- console.log();
214
-
215
- // Summary stats
216
- console.log(` ${C.bright}┌──────────────────────────────────────────────────────────────────────────┐${C.reset}`);
217
- console.log(` ${C.bright}│${C.reset} ${C.cyan}Total Stake${C.reset} ${C.bright}│${C.reset} ${C.green}${formatAether(totalStake).padEnd(20)} AETH${C.reset}`.padEnd(80) + `${C.bright}│${C.reset}`);
218
- console.log(` ${C.bright}│${C.reset} ${C.cyan}Network Uptime${C.reset} ${C.bright}│${C.reset} ${scoreColor(networkScore)}${formatPct(networkScore).padEnd(20)}${C.reset}`.padEnd(80) + `${C.bright}│${C.reset}`);
219
- if (epochData && epochData.epoch !== undefined) {
220
- console.log(` ${C.bright}│${C.reset} ${C.cyan}Current Epoch${C.reset} ${C.bright}│${C.reset} ${C.green}${epochData.epoch}${C.reset}`.padEnd(80) + `${C.bright}│${C.reset}`);
221
- }
222
- console.log(` ${C.bright}└──────────────────────────────────────────────────────────────────────────┘${C.reset}`);
223
- console.log();
224
-
225
- if (shown.length === 0) {
226
- console.log(` ${C.yellow}⚠ No validators found${C.reset}${opts.tier ? ` for tier "${opts.tier}"` : ''}.`);
227
- console.log(` ${C.dim}Try without --tier filter or check RPC connectivity.${C.reset}`);
228
- console.log();
229
- return;
230
- }
231
-
232
- // Table header
233
- console.log(` ${C.bright}┌────┬────────────────────────┬────────┬─────────┬────────┬─────────────┐${C.reset}`);
234
- console.log(` ${C.bright}│${C.reset} ${C.cyan}#${C.reset} ${C.cyan}Validator${C.reset} ${C.cyan}Tier${C.reset} ${C.cyan}Stake${C.reset} ${C.cyan}Uptime${C.reset} ${C.cyan}Commission${C.reset} ${C.bright}│${C.reset}`);
235
- console.log(` ${C.bright}├────┼────────────────────────┼────────┼─────────┼────────┼─────────────┤${C.reset}`);
236
-
237
- for (let i = 0; i < shown.length; i++) {
238
- const v = shown[i];
239
- const num = (i + 1).toString().padStart(3);
240
- const addr = shortenAddr(v.address || v.pubkey || v.id);
241
- const tierStr = tierBadge(v.tier || v.node_type);
242
- const stake = formatAether(v.stake || v.stake_amount || 0);
243
- const uptime = formatPct(v.uptime || v.score);
244
- const commission = formatPct(v.commission || v.fee);
245
- const row = ` ${C.bright}│${C.reset} ${C.dim}${num}${C.reset} ${addr.padEnd(24)} ${tierStr.padEnd(8)} ${stake.padEnd(9)} ${scoreColor(v.uptime || v.score)}${uptime.padEnd(9)}${C.reset} ${C.dim}${commission}${C.reset} ${C.bright}│${C.reset}`;
246
- console.log(row);
247
- }
248
-
249
- console.log(` ${C.bright}└────┴────────────────────────┴────────┴─────────┴────────┴─────────────┘${C.reset}`);
250
- console.log();
251
-
252
- if (filtered.length > opts.limit) {
253
- console.log(` ${C.dim}Showing ${opts.limit} of ${filtered.length} validators. Use --limit to see more.${C.reset}`);
254
- }
255
-
256
- console.log();
257
- console.log(` ${C.dim}Stake to a validator:${C.reset}`);
258
- console.log(` ${C.cyan}aether stake --validator <address> --amount <aeth>${C.reset}`);
259
- console.log(` ${C.cyan}aether stake --validator <address> --amount <aeth> --dry-run${C.reset} ${C.dim}(preview)${C.reset}`);
260
- console.log();
261
- }
262
-
263
- function renderJson(validators, epochData, opts, rpc) {
264
- const out = {
265
- rpc,
266
- fetchedAt: new Date().toISOString(),
267
- total: validators.length,
268
- epoch: epochData?.epoch ?? null,
269
- validators: validators.map(v => ({
270
- address: v.address || v.pubkey || v.id,
271
- name: v.name || null,
272
- tier: v.tier || v.node_type || null,
273
- stake: v.stake || v.stake_amount || 0,
274
- stakeAETH: formatAether(v.stake || v.stake_amount || 0),
275
- uptime: v.uptime ?? v.score ?? null,
276
- uptimePct: formatPct(v.uptime ?? v.score),
277
- commission: v.commission ?? v.fee ?? null,
278
- commissionPct: formatPct(v.commission ?? v.fee),
279
- lastSeen: v.last_seen || v.lastActive || null,
280
- version: v.version || v.clientVersion || null,
281
- })),
282
- };
283
- console.log(JSON.stringify(out, null, 2));
284
- }
285
-
286
- // ---------------------------------------------------------------------------
287
- // Main
288
- // ---------------------------------------------------------------------------
289
-
290
- async function validatorsListCommand() {
291
- const opts = parseArgs();
292
- const rpc = opts.rpc;
293
-
294
- if (!opts.asJson) {
295
- console.log(`\n${C.cyan}Fetching validators...${C.reset} ${C.dim}(${rpc})${C.reset}`);
296
- }
297
-
298
- const [validators, epochData] = await Promise.all([
299
- fetchValidators(rpc),
300
- fetchEpoch(rpc),
301
- ]);
302
-
303
- if (validators.length === 0 && !opts.asJson) {
304
- console.log(`\n ${C.yellow}⚠ No validator data returned from RPC.${C.reset}`);
305
- console.log(` ${C.dim} Is your validator running and fully synced?${C.reset}`);
306
- console.log(` ${C.dim} Check: aether-cli network${C.reset}`);
307
- console.log(` ${C.dim} Set custom RPC: AETHER_RPC=http://your-rpc-url${C.reset}\n`);
308
- return;
309
- }
310
-
311
- if (opts.asJson) {
312
- renderJson(validators, epochData, opts, rpc);
313
- } else {
314
- renderList(validators, epochData, opts, rpc);
315
- }
316
- }
317
-
318
- module.exports = { validatorsListCommand };
319
-
320
- if (require.main === module) {
321
- validatorsListCommand().catch((err) => {
322
- console.error(`\n${C.red} Validators command failed:${C.reset} ${err.message}`);
323
- console.error(` ${C.dim}Check that your validator is running and RPC is accessible.${C.reset}\n`);
324
- process.exit(1);
325
- });
326
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli validators
4
+ *
5
+ * List, filter, and inspect validators on the Aether network.
6
+ * Shows identity, tier, stake, APY, uptime, and performance metrics.
7
+ *
8
+ * Usage:
9
+ * aether validators list List all active validators
10
+ * aether validators list --tier full Filter by tier (full|lite|observer)
11
+ * aether validators list --json JSON output for scripting
12
+ * aether validators list --rpc <url> Query a specific RPC endpoint
13
+ * aether validators list --sort stake Sort by stake (default: score)
14
+ *
15
+ * Requires AETHER_RPC env var (default: http://127.0.0.1:8899)
16
+ */
17
+
18
+ const http = require('http');
19
+ const https = require('https');
20
+
21
+ // ANSI colours
22
+ const C = {
23
+ reset: '\x1b[0m',
24
+ bright: '\x1b[1m',
25
+ dim: '\x1b[2m',
26
+ red: '\x1b[31m',
27
+ green: '\x1b[32m',
28
+ yellow: '\x1b[33m',
29
+ blue: '\x1b[34m',
30
+ cyan: '\x1b[36m',
31
+ magenta: '\x1b[35m',
32
+ };
33
+
34
+ const DEFAULT_RPC = process.env.AETHER_RPC || 'http://127.0.0.1:8899';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // HTTP helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function httpRequest(rpcUrl, path) {
41
+ return new Promise((resolve, reject) => {
42
+ const url = new URL(path, rpcUrl);
43
+ const lib = url.protocol === 'https:' ? https : http;
44
+ const req = lib.request({
45
+ hostname: url.hostname,
46
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
47
+ path: url.pathname + url.search,
48
+ method: 'GET',
49
+ timeout: 8000,
50
+ headers: { 'Content-Type': 'application/json' },
51
+ }, (res) => {
52
+ let data = '';
53
+ res.on('data', (chunk) => data += chunk);
54
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); } });
55
+ });
56
+ req.on('error', reject);
57
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
58
+ req.end();
59
+ });
60
+ }
61
+
62
+ function httpPost(rpcUrl, path, body) {
63
+ return new Promise((resolve, reject) => {
64
+ const url = new URL(path, rpcUrl);
65
+ const lib = url.protocol === 'https:' ? https : http;
66
+ const bodyStr = JSON.stringify(body);
67
+ const req = lib.request({
68
+ hostname: url.hostname,
69
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
70
+ path: url.pathname + url.search,
71
+ method: 'POST',
72
+ timeout: 8000,
73
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) },
74
+ }, (res) => {
75
+ let data = '';
76
+ res.on('data', (chunk) => data += chunk);
77
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
78
+ });
79
+ req.on('error', reject);
80
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
81
+ req.write(bodyStr);
82
+ req.end();
83
+ });
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Argument parsing
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function parseArgs() {
91
+ const args = process.argv.slice(3); // [node, index.js, validators, list, ...]
92
+ const opts = {
93
+ rpc: DEFAULT_RPC,
94
+ subcmd: 'list',
95
+ tier: null,
96
+ asJson: false,
97
+ sortBy: 'score',
98
+ limit: 100,
99
+ rank: false,
100
+ };
101
+
102
+ for (let i = 0; i < args.length; i++) {
103
+ const arg = args[i];
104
+ if (arg === '--tier' || arg === '-t') {
105
+ const tier = (args[++i] || '').toLowerCase();
106
+ if (['full', 'lite', 'observer'].includes(tier)) {
107
+ opts.tier = tier;
108
+ } else {
109
+ console.log(` ${C.yellow}⚠ Invalid tier "${tier}". Valid: full, lite, observer${C.reset}`);
110
+ }
111
+ } else if (arg === '--json' || arg === '-j') {
112
+ opts.asJson = true;
113
+ } else if (arg === '--rpc' || arg === '-r') {
114
+ opts.rpc = args[++i];
115
+ } else if (arg === '--sort' || arg === '-s') {
116
+ const sortVal = (args[++i] || '').toLowerCase();
117
+ if (['stake', 'score', 'apy', 'uptime', 'name'].includes(sortVal)) {
118
+ opts.sortBy = sortVal;
119
+ } else {
120
+ console.log(` ${C.yellow}⚠ Invalid sort "${sortVal}". Valid: stake, score, apy, uptime, name${C.reset}`);
121
+ }
122
+ } else if (arg === '--limit' || arg === '-l') {
123
+ const limit = parseInt(args[++i], 10);
124
+ if (!isNaN(limit) && limit > 0 && limit <= 500) {
125
+ opts.limit = limit;
126
+ }
127
+ } else if (arg === '--help' || arg === '-h') {
128
+ showHelp();
129
+ process.exit(0);
130
+ } else if (arg === '--rank') {
131
+ opts.rank = true;
132
+ opts.subcmd = 'rank';
133
+ }
134
+ }
135
+
136
+ return opts;
137
+ }
138
+
139
+ function showHelp() {
140
+ console.log(`
141
+ ${C.bright}${C.cyan}aether-cli validators${C.reset} - List and inspect Aether validators
142
+
143
+ ${C.bright}Usage:${C.reset}
144
+ aether validators list [options]
145
+ aether validators rank [options] Ranked leaderboard (sorted by stake)
146
+
147
+ ${C.bright}Options (list):${C.reset}
148
+ -t, --tier <type> Filter by tier: full, lite, observer
149
+ -s, --sort <field> Sort by: stake, score, apy, uptime, name (default: score)
150
+ -l, --limit <n> Max validators to show (default: 100, max: 500)
151
+ -r, --rpc <url> RPC endpoint (default: ${DEFAULT_RPC} or $AETHER_RPC)
152
+ -j, --json Output raw JSON (for scripting)
153
+ -h, --help Show this help message
154
+
155
+ ${C.bright}Options (rank):${C.reset}
156
+ -t, --tier <type> Filter by tier: full, lite, observer
157
+ -l, --limit <n> Max validators to show (default: 50, max: 200)
158
+ -r, --rpc <url> RPC endpoint (default: ${DEFAULT_RPC} or $AETHER_RPC)
159
+ -j, --json Output raw JSON (for scripting)
160
+ -h, --help Show this help message
161
+
162
+ ${C.bright}Examples:${C.reset}
163
+ aether validators list # All validators, sorted by score
164
+ aether validators list --tier full # Full validators only
165
+ aether validators list --sort stake # Sort by total stake
166
+ aether validators list --sort apy # Sort by estimated APY
167
+ aether validators list --json # JSON for scripts
168
+ aether validators rank # Top validators by stake (leaderboard)
169
+ aether validators rank --tier full # Full validators only
170
+ aether validators rank --limit 20 # Top 20 validators
171
+ aether validators list --rpc http://custom-rpc:8899
172
+ `.trim());
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Data fetchers
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** Fetch all validators from the network */
180
+ async function fetchValidators(rpc) {
181
+ try {
182
+ // Try /v1/validators first (standard Aether RPC endpoint)
183
+ const res = await httpRequest(rpc, '/v1/validators');
184
+ if (res && !res.error) {
185
+ if (Array.isArray(res)) return res;
186
+ if (res.validators && Array.isArray(res.validators)) return res.validators;
187
+ if (res.accounts && Array.isArray(res.accounts)) return res.accounts;
188
+ }
189
+ // Fallback: POST to a validators query endpoint
190
+ const res2 = await httpPost(rpc, '/v1/validators', {});
191
+ if (res2 && !res2.error) {
192
+ if (Array.isArray(res2)) return res2;
193
+ if (res2.validators && Array.isArray(res2.validators)) return res2.validators;
194
+ }
195
+ return [];
196
+ } catch {
197
+ return [];
198
+ }
199
+ }
200
+
201
+ /** Fetch epoch info for APY calculations */
202
+ async function fetchEpochInfo(rpc) {
203
+ try {
204
+ const res = await httpRequest(rpc, '/v1/epoch-info');
205
+ return res;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /** Fetch network-wide stake totals for APY estimation */
212
+ async function fetchSupply(rpc) {
213
+ try {
214
+ const res = await httpRequest(rpc, '/v1/supply');
215
+ return res;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Normalise validator record from various RPC response shapes
223
+ // ---------------------------------------------------------------------------
224
+
225
+ function normaliseValidator(v) {
226
+ // Handle different response shapes from different RPC implementations
227
+ const pubkey = v.pubkey || v.address || v.identity || v.id || null;
228
+ const name = v.name || v.moniker || v.label || v.identity_name || null;
229
+ const tier = (v.tier || v.node_type || v.type || 'full').toLowerCase();
230
+ const stake = BigInt(v.stake || v.delegatedStake || v.stake_lamports || v.lamports || 0);
231
+ const score = v.score !== undefined ? v.score : (v.uptime !== undefined ? Math.round(v.uptime * 100) : null);
232
+ const apy = v.apy !== undefined ? v.apy : (v.apy_bps !== undefined ? v.apy_bps / 100 : null);
233
+ const commission = v.commission !== undefined ? v.commission : (v.commission_bps !== undefined ? v.commission_bps / 100 : null);
234
+ const version = v.version || v.agent || v.app_version || null;
235
+ const ip = v.ip || v.remote || null;
236
+ const lastVote = v.last_vote || v.lastVote || null;
237
+ const epoch = v.epoch || null;
238
+ const voteAccount = v.vote_account || v.voteAccount || null;
239
+
240
+ return {
241
+ pubkey,
242
+ name,
243
+ tier,
244
+ stake: stake.toString(),
245
+ stakeFormatted: formatAether(stake),
246
+ stakeAeth: Number(stake) / 1e9,
247
+ score,
248
+ apy,
249
+ commission,
250
+ version,
251
+ ip,
252
+ lastVote,
253
+ epoch,
254
+ voteAccount,
255
+ // Raw for JSON export
256
+ _raw: v,
257
+ };
258
+ }
259
+
260
+ function formatAether(lamports) {
261
+ const aeth = Number(lamports) / 1e9;
262
+ if (aeth === 0) return '0 AETH';
263
+ return aeth.toFixed(2).replace(/\.?0+$/, '') + ' AETH';
264
+ }
265
+
266
+ function formatScore(score) {
267
+ if (score === null || score === undefined) return `${C.dim}—${C.reset}`;
268
+ if (score >= 80) return `${C.green}${score}${C.reset}`;
269
+ if (score >= 50) return `${C.yellow}${score}${C.reset}`;
270
+ return `${C.red}${score}${C.reset}`;
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Render outputs
275
+ // ---------------------------------------------------------------------------
276
+
277
+ function tierColor(tier) {
278
+ if (tier === 'full') return `${C.cyan}FULL${C.reset}`;
279
+ if (tier === 'lite') return `${C.yellow}LITE${C.reset}`;
280
+ if (tier === 'observer') return `${C.green}OBS${C.reset}`;
281
+ return `${C.dim}${tier.toUpperCase()}${C.reset}`;
282
+ }
283
+
284
+ function tierBadge(tier) {
285
+ if (tier === 'full') return `${C.cyan}◆ FULL${C.reset}`;
286
+ if (tier === 'lite') return `${C.yellow}◇ LITE${C.reset}`;
287
+ if (tier === 'observer') return `${C.green}○ OBS${C.reset}`;
288
+ return `${C.dim}[${tier}]${C.reset}`;
289
+ }
290
+
291
+ function renderTable(validators, opts) {
292
+ const sortBy = opts.sortBy;
293
+ const tier = opts.tier;
294
+
295
+ // Sort validators
296
+ const sorted = [...validators].sort((a, b) => {
297
+ if (sortBy === 'stake') return b.stakeAeth - a.stakeAeth;
298
+ if (sortBy === 'score') return (b.score || 0) - (a.score || 0);
299
+ if (sortBy === 'apy') return (b.apy || 0) - (a.apy || 0);
300
+ if (sortBy === 'name') return (a.name || '').localeCompare(b.name || '');
301
+ return 0;
302
+ });
303
+
304
+ // Filter by tier
305
+ const filtered = tier
306
+ ? sorted.filter(v => v.tier === tier)
307
+ : sorted;
308
+
309
+ const shown = filtered.slice(0, opts.limit);
310
+ const total = filtered.length;
311
+
312
+ // Header
313
+ console.log();
314
+ console.log(`${C.bright}${C.cyan}╔═══════════════════════════════════════════════════════════════════════════════╗${C.reset}`);
315
+ console.log(`${C.bright}${C.cyan}║${C.reset} ${C.bright}AETHER VALIDATORS${C.reset} ${C.dim}(total: ${total})${C.reset} ${C.bright}║${C.reset}`);
316
+ console.log(`${C.bright}${C.cyan}╚═══════════════════════════════════════════════════════════════════════════════╝${C.reset}`);
317
+ if (tier) console.log(` ${C.dim}Tier filter: ${tier.toUpperCase()} Sort: ${sortBy} RPC: ${opts.rpc}${C.reset}`);
318
+ else console.log(` ${C.dim}Sort: ${sortBy} RPC: ${opts.rpc}${C.reset}`);
319
+ console.log();
320
+
321
+ if (shown.length === 0) {
322
+ console.log(` ${C.yellow} No validators found${C.reset}${tier ? ` for tier "${tier}"` : ''}.`);
323
+ console.log(` ${C.dim} Check your RPC endpoint: ${opts.rpc}${C.reset}\n`);
324
+ return;
325
+ }
326
+
327
+ // Table header
328
+ console.log(` ${C.bright}┌──────────────────────────────────────────────────────────────────────────────────────┐${C.reset}`);
329
+ console.log(
330
+ ` ${C.bright}│${C.reset}` +
331
+ ` ${C.cyan}#${C.reset}`.padEnd(4) +
332
+ `${C.cyan}Validator${C.reset}`.padEnd(36) +
333
+ `${C.cyan}Tier${C.reset}`.padEnd(8) +
334
+ `${C.cyan}Stake${C.reset}`.padEnd(14) +
335
+ `${C.cyan}Score${C.reset}`.padEnd(8) +
336
+ `${C.cyan}APY${C.reset}`.padEnd(8) +
337
+ `${C.cyan}Version${C.reset}`.padEnd(10) +
338
+ `${C.bright}│${C.reset}`
339
+ );
340
+ console.log(` ${C.bright}├${'─'.repeat(90)}${C.bright}│${C.reset}`);
341
+
342
+ for (let i = 0; i < shown.length; i++) {
343
+ const v = shown[i];
344
+ const num = (i + 1).toString().padStart(3);
345
+ const nameOrKey = v.name
346
+ ? v.name.substring(0, 20).padEnd(20)
347
+ : (v.pubkey ? v.pubkey.substring(0, 20).padEnd(20) : 'unknown'.padEnd(20));
348
+ const tierStr = tierBadge(v.tier);
349
+ const stakeStr = v.stakeFormatted.padEnd(12);
350
+ const scoreStr = v.score !== null && v.score !== undefined
351
+ ? `${v.score}%`.padEnd(6)
352
+ : '—'.padEnd(6);
353
+ const apyStr = v.apy !== null && v.apy !== undefined
354
+ ? `${v.apy.toFixed(1)}%`.padEnd(6)
355
+ : '—'.padEnd(6);
356
+ const versionStr = v.version ? v.version.substring(0, 10).padEnd(10) : '—'.padEnd(10);
357
+
358
+ const scoreColor = v.score === null || v.score === undefined ? C.dim
359
+ : v.score >= 80 ? C.green
360
+ : v.score >= 50 ? C.yellow
361
+ : C.red;
362
+
363
+ console.log(
364
+ ` ${C.bright}│${C.reset}` +
365
+ ` ${C.dim}${num}${C.reset} `.substring(0, 5) +
366
+ `${C.cyan}${nameOrKey}${C.reset} ` +
367
+ `${tierStr} `.substring(0, 9) +
368
+ `${C.green}${stakeStr}${C.reset} ` +
369
+ `${scoreColor}${scoreStr}${C.reset} ` +
370
+ `${C.green}${apyStr}${C.reset} ` +
371
+ `${C.dim}${versionStr}${C.reset} ` +
372
+ `${C.bright}│${C.reset}`
373
+ );
374
+ }
375
+
376
+ console.log(` ${C.bright}└${'─'.repeat(90)}${C.bright}│${C.reset}`);
377
+ console.log();
378
+
379
+ // Summary row
380
+ const totalStake = shown.reduce((sum, v) => sum + v.stakeAeth, 0);
381
+ const avgScore = shown.reduce((sum, v) => sum + (v.score || 0), 0) / shown.filter(v => v.score !== null).length;
382
+ const fullCount = shown.filter(v => v.tier === 'full').length;
383
+ const liteCount = shown.filter(v => v.tier === 'lite').length;
384
+ const obsCount = shown.filter(v => v.tier === 'observer').length;
385
+
386
+ console.log(` ${C.dim}Showing ${shown.length} of ${total} validators${total !== shown.length ? ` (limit ${opts.limit})` : ''}${C.reset}`);
387
+ console.log(` ${C.dim}Total stake shown: ${C.reset}${C.green}${totalStake.toFixed(2)} AETH${C.reset} ${C.dim}Avg score: ${C.reset}${avgScore ? `${avgScore.toFixed(1)}%` : '—'}${C.reset}`);
388
+ if (!tier) {
389
+ console.log(` ${C.cyan}◆${C.reset} ${C.cyan}Full${C.reset}: ${fullCount} ${C.yellow}◇${C.reset} ${C.yellow}Lite${C.reset}: ${liteCount} ${C.green}○${C.reset} ${C.green}Observer${C.reset}: ${obsCount}`);
390
+ }
391
+ console.log();
392
+ console.log(` ${C.dim}Tip: --tier full|lite|observer | --sort stake|score|apy|name | --json for data${C.reset}`);
393
+ console.log();
394
+ }
395
+
396
+ function renderJson(validators, opts) {
397
+ const tier = opts.tier;
398
+ const filtered = tier
399
+ ? validators.filter(v => v.tier === tier)
400
+ : validators;
401
+
402
+ const out = {
403
+ rpc: opts.rpc,
404
+ total: filtered.length,
405
+ sort: opts.sortBy,
406
+ tier_filter: tier,
407
+ fetched_at: new Date().toISOString(),
408
+ validators: filtered.map(v => ({
409
+ pubkey: v.pubkey,
410
+ name: v.name,
411
+ tier: v.tier,
412
+ stake: v.stake,
413
+ stake_aeth: v.stakeAeth,
414
+ stake_formatted: v.stakeFormatted,
415
+ score: v.score,
416
+ apy: v.apy,
417
+ commission: v.commission,
418
+ version: v.version,
419
+ ip: v.ip,
420
+ vote_account: v.voteAccount,
421
+ last_vote: v.lastVote,
422
+ epoch: v.epoch,
423
+ })),
424
+ };
425
+
426
+ console.log(JSON.stringify(out, null, 2));
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Main
431
+ // ---------------------------------------------------------------------------
432
+
433
+ async function validatorsList(opts) {
434
+ const rpc = opts.rpc;
435
+
436
+ if (!opts.asJson) {
437
+ console.log(`${C.dim}Fetching validators from ${rpc}...${C.reset}`);
438
+ }
439
+
440
+ const [rawValidators, epochInfo, supply] = await Promise.all([
441
+ fetchValidators(rpc),
442
+ fetchEpochInfo(rpc),
443
+ fetchSupply(rpc),
444
+ ]);
445
+
446
+ if (rawValidators.length === 0) {
447
+ if (opts.asJson) {
448
+ console.log(JSON.stringify({ rpc, validators: [], total: 0, error: 'No validator data returned from RPC' }, null, 2));
449
+ } else {
450
+ console.log(`\n ${C.yellow}⚠ No validator data returned from RPC.${C.reset}`);
451
+ console.log(` ${C.dim} RPC: ${rpc}${C.reset}`);
452
+ console.log(` ${C.dim} Check that your validator is running and the RPC endpoint is accessible.${C.reset}`);
453
+ console.log(` ${C.dim} Set custom RPC: AETHER_RPC=http://your-rpc-url${C.reset}\n`);
454
+ }
455
+ return;
456
+ }
457
+
458
+ // Normalise all validators
459
+ let validators = rawValidators.map(normaliseValidator);
460
+
461
+ // Estimate APY if not provided by RPC (rough approximation)
462
+ if (supply && !supply.error) {
463
+ const totalStake = Number(supply.total_staked || supply.total || 0);
464
+ const rewardsPerEpoch = Number(epochInfo?.rewards_per_epoch || '2000000000');
465
+ if (totalStake > 0 && rewardsPerEpoch > 0) {
466
+ const apyEstimate = (rewardsPerEpoch / totalStake) * 73; // ~73 epochs/year
467
+ validators = validators.map(v => {
468
+ if (v.apy === null || v.apy === undefined) {
469
+ return { ...v, apy: apyEstimate };
470
+ }
471
+ return v;
472
+ });
473
+ }
474
+ }
475
+
476
+ if (opts.asJson) {
477
+ renderJson(validators, opts);
478
+ } else {
479
+ renderTable(validators, opts);
480
+ }
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Validators Rank — leaderboard sorted by stake
485
+ // ---------------------------------------------------------------------------
486
+
487
+ function rankBadge(rank) {
488
+ if (rank === 1) return `${C.yellow}🥇 1${C.reset}`;
489
+ if (rank === 2) return `${C.dim}🥈 2${C.reset}`;
490
+ if (rank === 3) return `${C.yellow}🥉 3${C.reset}`;
491
+ return `${C.dim}${String(rank).padStart(3)}${C.reset}`;
492
+ }
493
+
494
+ function renderRankTable(validators, opts) {
495
+ const tier = opts.tier;
496
+
497
+ // Sort by stake descending, filter by tier
498
+ const sorted = [...validators]
499
+ .sort((a, b) => b.stakeAeth - a.stakeAeth);
500
+
501
+ const filtered = tier
502
+ ? sorted.filter(v => v.tier === tier)
503
+ : sorted;
504
+
505
+ const shown = filtered.slice(0, opts.limit);
506
+ const total = filtered.length;
507
+
508
+ // Total staked across all shown validators
509
+ const totalStaked = shown.reduce((sum, v) => sum + v.stakeAeth, 0);
510
+ const maxStake = shown.length > 0 ? shown[0].stakeAeth : 1;
511
+
512
+ console.log();
513
+ console.log(`${C.bright}${C.cyan}╔═══════════════════════════════════════════════════════════════════════════════════╗${C.reset}`);
514
+ console.log(`${C.bright}${C.cyan}║${C.reset} ${C.bright}AETHER VALIDATOR LEADERBOARD${C.reset} ${C.dim}(${total} validators)${C.reset} ${C.bright}║${C.reset}`);
515
+ console.log(`${C.bright}${C.cyan}╚═══════════════════════════════════════════════════════════════════════════════════╝${C.reset}`);
516
+ if (tier) console.log(` ${C.dim}Tier: ${tier.toUpperCase()} RPC: ${opts.rpc}${C.reset}`);
517
+ else console.log(` ${C.dim}Sorted by stake RPC: ${opts.rpc}${C.reset}`);
518
+ console.log();
519
+ console.log(` ${C.bright}┌────────────────────────────────────────────────────────────────────────────────────────────┐${C.reset}`);
520
+ console.log(
521
+ ` ${C.bright}│${C.reset}` +
522
+ ` ${C.cyan}Rank${C.reset}`.padEnd(6) +
523
+ `${C.cyan}Validator${C.reset}`.padEnd(34) +
524
+ `${C.cyan}Tier${C.reset}`.padEnd(8) +
525
+ `${C.cyan}Stake (AETH)${C.reset}`.padEnd(16) +
526
+ `${C.cyan}Score${C.reset}`.padEnd(8) +
527
+ `${C.cyan}APY${C.reset}`.padEnd(8) +
528
+ `${C.bright}│${C.reset}`
529
+ );
530
+ console.log(` ${C.bright}├${'─'.repeat(94)}${C.bright}│${C.reset}`);
531
+
532
+ for (let i = 0; i < shown.length; i++) {
533
+ const v = shown[i];
534
+ const rank = i + 1;
535
+ const rankStr = rankBadge(rank);
536
+ const nameOrKey = v.name
537
+ ? v.name.substring(0, 20).padEnd(20)
538
+ : (v.pubkey ? v.pubkey.substring(0, 20).padEnd(20) : 'unknown'.padEnd(20));
539
+ const tierStr = tierBadge(v.tier);
540
+ const stakeFormatted = v.stakeFormatted.padEnd(14);
541
+ const scoreStr = v.score !== null && v.score !== undefined
542
+ ? `${v.score}%`.padEnd(6)
543
+ : '—'.padEnd(6);
544
+ const apyStr = v.apy !== null && v.apy !== undefined
545
+ ? `${v.apy.toFixed(1)}%`.padEnd(6)
546
+ : '—'.padEnd(6);
547
+
548
+ const scoreColor = v.score === null || v.score === undefined ? C.dim
549
+ : v.score >= 80 ? C.green
550
+ : v.score >= 50 ? C.yellow
551
+ : C.red;
552
+
553
+ // Mini bar for relative stake
554
+ const barLen = 12;
555
+ const fillLen = maxStake > 0 ? Math.round((v.stakeAeth / maxStake) * barLen) : 0;
556
+ const stakeBar = `${C.green}${'█'.repeat(fillLen)}${C.dim}${'░'.repeat(barLen - fillLen)}${C.reset}`;
557
+
558
+ console.log(
559
+ ` ${C.bright}│${C.reset}` +
560
+ ` ${rankStr} `.substring(0, 7) +
561
+ `${C.cyan}${nameOrKey}${C.reset} ` +
562
+ `${tierStr} `.substring(0, 9) +
563
+ `${stakeBar}${C.reset} ` +
564
+ `${C.green}${stakeFormatted}${C.reset} ` +
565
+ `${scoreColor}${scoreStr}${C.reset} ` +
566
+ `${C.green}${apyStr}${C.reset} ` +
567
+ `${C.bright}│${C.reset}`
568
+ );
569
+ }
570
+
571
+ console.log(` ${C.bright}└${'─'.repeat(94)}${C.bright}│${C.reset}`);
572
+ console.log();
573
+
574
+ // Summary
575
+ const avgApy = shown.reduce((sum, v) => sum + (v.apy || 0), 0) / shown.filter(v => v.apy !== null).length;
576
+ console.log(` ${C.dim}Total staked (shown): ${C.reset}${C.green}${totalStaked.toFixed(2)} AETH${C.reset} ${C.dim}Avg APY: ${C.reset}${avgApy.toFixed(1)}% ${C.dim}Top stake: ${C.reset}${maxStake.toFixed(2)} AETH${C.reset}`);
577
+ console.log(` ${C.dim}Run with ${C.cyan}--json${C.reset}${C.dim} for raw data, ${C.cyan}--limit 20${C.reset}${C.dim} for top 20${C.reset}`);
578
+ console.log();
579
+ }
580
+
581
+ async function validatorsRank(opts) {
582
+ const rpc = opts.rpc;
583
+ const limit = Math.min(opts.limit || 50, 200);
584
+
585
+ if (!opts.asJson) {
586
+ console.log(`${C.dim}Fetching validator leaderboard from ${rpc}...${C.reset}`);
587
+ }
588
+
589
+ const [rawValidators, epochInfo, supply] = await Promise.all([
590
+ fetchValidators(rpc),
591
+ fetchEpochInfo(rpc),
592
+ fetchSupply(rpc),
593
+ ]);
594
+
595
+ if (rawValidators.length === 0) {
596
+ if (opts.asJson) {
597
+ console.log(JSON.stringify({ rpc, validators: [], total: 0, error: 'No validator data returned from RPC' }, null, 2));
598
+ } else {
599
+ console.log(`\n ${C.yellow}⚠ No validator data returned from RPC.${C.reset}`);
600
+ console.log(` ${C.dim} RPC: ${rpc}${C.reset}`);
601
+ console.log(` ${C.dim} Check that your validator is running and the RPC endpoint is accessible.${C.reset}\n`);
602
+ }
603
+ return;
604
+ }
605
+
606
+ let validators = rawValidators.map(normaliseValidator);
607
+
608
+ // Estimate APY if not provided
609
+ if (supply && !supply.error) {
610
+ const totalStake = Number(supply.total_staked || supply.total || 0);
611
+ const rewardsPerEpoch = Number(epochInfo?.rewards_per_epoch || '2000000000');
612
+ if (totalStake > 0 && rewardsPerEpoch > 0) {
613
+ const apyEstimate = (rewardsPerEpoch / totalStake) * 73;
614
+ validators = validators.map(v => {
615
+ if (v.apy === null || v.apy === undefined) {
616
+ return { ...v, apy: apyEstimate };
617
+ }
618
+ return v;
619
+ });
620
+ }
621
+ }
622
+
623
+ opts.limit = limit;
624
+
625
+ if (opts.asJson) {
626
+ const ranked = [...validators]
627
+ .sort((a, b) => b.stakeAeth - a.stakeAeth)
628
+ .filter(v => !opts.tier || v.tier === opts.tier)
629
+ .slice(0, limit)
630
+ .map((v, i) => ({ rank: i + 1, ...v }));
631
+ console.log(JSON.stringify({ rpc, validators: ranked, total: ranked.length, fetched_at: new Date().toISOString() }, null, 2));
632
+ } else {
633
+ renderRankTable(validators, opts);
634
+ }
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // Entry point
639
+ // ---------------------------------------------------------------------------
640
+
641
+ async function main() {
642
+ const opts = parseArgs();
643
+
644
+ if (opts.subcmd === 'list') {
645
+ await validatorsList(opts);
646
+ } else if (opts.subcmd === 'rank') {
647
+ await validatorsRank(opts);
648
+ } else {
649
+ console.log(`\n ${C.red}Unknown subcommand:${C.reset} ${opts.subcmd}`);
650
+ console.log(` ${C.dim}Usage: aether validators list [--tier full] [--sort stake] [--json]${C.reset}\n`);
651
+ process.exit(1);
652
+ }
653
+ }
654
+
655
+ main().catch(err => {
656
+ console.error(`\n${C.red}✗ Validators command failed:${C.reset} ${err.message}`);
657
+ console.error(` ${C.dim}Set custom RPC: AETHER_RPC=http://your-rpc-url${C.reset}\n`);
658
+ process.exit(1);
659
+ });
660
+
661
+ module.exports = { validatorsListCommand: main };
662
+
663
+ if (require.main === module) {
664
+ main();
665
+ }