@jellylegsai/aether-cli 1.9.1 → 2.0.1

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.
@@ -0,0 +1,959 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli validator - Full Validator Lifecycle Management
4
+ *
5
+ * Comprehensive validator management with full SDK/RPC integration.
6
+ * Commands: status, info, start, stop, register, logs
7
+ *
8
+ * All blockchain calls use @jellylegsai/aether-sdk with real HTTP RPC.
9
+ * No stubs. No mocks.
10
+ *
11
+ * Usage:
12
+ * aether validator status [--json] [--rpc <url>]
13
+ * aether validator info <address> [--json] [--rpc <url>]
14
+ * aether validator start [--tier full|lite|observer] [--foreground]
15
+ * aether validator stop [--force]
16
+ * aether validator register [--tier <type>] [--amount <aeth>]
17
+ * aether validator logs [--follow] [--lines <n>]
18
+ *
19
+ * SDK Methods Used:
20
+ * - client.getSlot() → GET /v1/slot
21
+ * - client.getBlockHeight() → GET /v1/blockheight
22
+ * - client.getEpochInfo() → GET /v1/epoch
23
+ * - client.getHealth() → GET /v1/health
24
+ * - client.getVersion() → GET /v1/version
25
+ * - client.getValidators() → GET /v1/validators
26
+ * - client.getValidatorAPY() → GET /v1/validator/<addr>/apy
27
+ * - client.getClusterPeers() → GET /v1/peers
28
+ * - client.sendTransaction() → POST /v1/transaction
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const os = require('os');
34
+ const { spawn, execSync } = require('child_process');
35
+ const readline = require('readline');
36
+ const nacl = require('tweetnacl');
37
+ const bs58 = require('bs58').default;
38
+ const bip39 = require('bip39');
39
+
40
+ // Import SDK
41
+ const sdkPath = path.join(__dirname, '..', 'sdk', 'index.js');
42
+ const aether = require(sdkPath);
43
+
44
+ // Import full UI framework
45
+ const { C, indicators, BRANDING, startSpinner, stopSpinner,
46
+ success, error, warning, info, code, highlight, key, value,
47
+ drawBox, drawTable, progressBar, progressBarColored,
48
+ formatHealth, formatLatency, formatSyncStatus } = require('../lib/ui');
49
+
50
+ const CLI_VERSION = '2.1.0';
51
+ const DEFAULT_RPC = 'http://127.0.0.1:8899';
52
+ const DERIVATION_PATH = "m/44'/7777777'/0'/0'";
53
+
54
+ // Tier configurations
55
+ const TIERS = {
56
+ full: { minStake: 10000, stakeLamports: 10000 * 1e9, consensusWeight: 1.0, producesBlocks: true, minCores: 8, minRamGB: 32 },
57
+ lite: { minStake: 1000, stakeLamports: 1000 * 1e9, consensusWeight: 0.1, producesBlocks: false, minCores: 4, minRamGB: 8 },
58
+ observer: { minStake: 0, stakeLamports: 0, consensusWeight: 0, producesBlocks: false, minCores: 2, minRamGB: 4 },
59
+ };
60
+
61
+ // ============================================================================
62
+ // SDK Client Setup
63
+ // ============================================================================
64
+
65
+ function getDefaultRpc() {
66
+ return process.env.AETHER_RPC || aether.DEFAULT_RPC_URL || DEFAULT_RPC;
67
+ }
68
+
69
+ function createClient(rpcUrl) {
70
+ return new aether.AetherClient({ rpcUrl });
71
+ }
72
+
73
+ // ============================================================================
74
+ // Paths & Config
75
+ // ============================================================================
76
+
77
+ function getAetherDir() {
78
+ return path.join(os.homedir(), '.aether');
79
+ }
80
+
81
+ function getConfigPath() {
82
+ return path.join(getAetherDir(), 'config.json');
83
+ }
84
+
85
+ function getWalletsDir() {
86
+ return path.join(getAetherDir(), 'wallets');
87
+ }
88
+
89
+ function getLogDir() {
90
+ return path.join(getAetherDir(), 'logs');
91
+ }
92
+
93
+ function loadConfig() {
94
+ if (!fs.existsSync(getConfigPath())) {
95
+ return { defaultWallet: null, validators: [], version: 1 };
96
+ }
97
+ try {
98
+ return JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
99
+ } catch {
100
+ return { defaultWallet: null, validators: [], version: 1 };
101
+ }
102
+ }
103
+
104
+ function saveConfig(cfg) {
105
+ if (!fs.existsSync(getAetherDir())) {
106
+ fs.mkdirSync(getAetherDir(), { recursive: true });
107
+ }
108
+ fs.writeFileSync(getConfigPath(), JSON.stringify(cfg, null, 2));
109
+ }
110
+
111
+ function loadWallet(address) {
112
+ const fp = path.join(getWalletsDir(), `${address}.json`);
113
+ if (!fs.existsSync(fp)) return null;
114
+ try {
115
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function walletFilePath(address) {
122
+ return path.join(getWalletsDir(), `${address}.json`);
123
+ }
124
+
125
+ // ============================================================================
126
+ // Crypto Helpers
127
+ // ============================================================================
128
+
129
+ function deriveKeypair(mnemonic) {
130
+ if (!bip39.validateMnemonic(mnemonic)) {
131
+ throw new Error('Invalid mnemonic phrase');
132
+ }
133
+ const seedBuffer = bip39.mnemonicToSeedSync(mnemonic, '');
134
+ const seed32 = seedBuffer.slice(0, 32);
135
+ const keyPair = nacl.sign.keyPair.fromSeed(seed32);
136
+ return {
137
+ publicKey: Buffer.from(keyPair.publicKey),
138
+ secretKey: Buffer.from(keyPair.secretKey),
139
+ };
140
+ }
141
+
142
+ function formatAddress(publicKey) {
143
+ return 'ATH' + bs58.encode(publicKey);
144
+ }
145
+
146
+ function signTransaction(tx, secretKey) {
147
+ const txBytes = Buffer.from(JSON.stringify(tx));
148
+ const sig = nacl.sign.detached(txBytes, secretKey);
149
+ return bs58.encode(sig);
150
+ }
151
+
152
+ // ============================================================================
153
+ // Format Helpers
154
+ // ============================================================================
155
+
156
+ function formatAether(lamports) {
157
+ if (!lamports || lamports === '0') return '0 AETH';
158
+ const aeth = Number(lamports) / 1e9;
159
+ return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
160
+ }
161
+
162
+ function shortAddress(addr, len = 8) {
163
+ if (!addr || addr.length < 16) return addr || 'unknown';
164
+ return addr.slice(0, len) + '...' + addr.slice(-len);
165
+ }
166
+
167
+ function formatPercent(val) {
168
+ if (val === undefined || val === null) return 'N/A';
169
+ const pct = typeof val === 'number' ? val : parseFloat(val);
170
+ if (isNaN(pct)) return 'N/A';
171
+ return pct.toFixed(2) + '%';
172
+ }
173
+
174
+ // ============================================================================
175
+ // Readline Helpers
176
+ // ============================================================================
177
+
178
+ function createRl() {
179
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
180
+ }
181
+
182
+ function question(rl, q) {
183
+ return new Promise((res) => rl.question(q, res));
184
+ }
185
+
186
+ async function askMnemonic(rl, promptText) {
187
+ console.log(`\n${C.cyan}${promptText}${C.reset}`);
188
+ console.log(`${C.dim}Enter your 12 or 24-word passphrase:${C.reset}`);
189
+ const raw = await question(rl, ` > ${C.reset}`);
190
+ return raw.trim().toLowerCase();
191
+ }
192
+
193
+ // ============================================================================
194
+ // Subcommand: STATUS
195
+ // ============================================================================
196
+
197
+ async function validatorStatus(opts) {
198
+ const client = createClient(opts.rpc);
199
+
200
+ if (!opts.json) {
201
+ console.log(BRANDING.commandBanner('validator status', 'Check validator node status'));
202
+ startSpinner('Querying network via SDK');
203
+ }
204
+
205
+ try {
206
+ // Parallel SDK calls for all status data
207
+ const [slot, blockHeight, epochInfo, health, version, peers] = await Promise.all([
208
+ client.getSlot().catch(() => null),
209
+ client.getBlockHeight().catch(() => null),
210
+ client.getEpochInfo().catch(() => null),
211
+ client.getHealth().catch(() => 'unknown'),
212
+ client.getVersion().catch(() => null),
213
+ client.getClusterPeers().catch(() => []),
214
+ ]);
215
+
216
+ if (!opts.json) stopSpinner(true, 'Network data retrieved');
217
+
218
+ const data = {
219
+ rpc: opts.rpc,
220
+ timestamp: new Date().toISOString(),
221
+ sdk_version: CLI_VERSION,
222
+ slot,
223
+ blockHeight,
224
+ epoch: epochInfo,
225
+ health,
226
+ version: version?.aetherCore || version?.featureSet || version,
227
+ peerCount: Array.isArray(peers) ? peers.length : 0,
228
+ };
229
+
230
+ if (opts.json) {
231
+ console.log(JSON.stringify(data, null, 2));
232
+ return;
233
+ }
234
+
235
+ // Pretty output
236
+ const isHealthy = health === 'ok' || health === 'healthy';
237
+ const healthStatus = isHealthy ?
238
+ `${C.green}${indicators.success} Healthy${C.reset}` :
239
+ `${C.yellow}${indicators.warning} ${health}${C.reset}`;
240
+
241
+ const versionStr = data.version || `${C.dim}unknown${C.reset}`;
242
+
243
+ console.log(drawBox(
244
+ `
245
+ ${C.bright}AETHER NETWORK STATUS${C.reset} ${C.dim}${data.timestamp}${C.reset}
246
+
247
+ ${C.cyan}Health:${C.reset} ${healthStatus}
248
+ ${C.cyan}RPC:${C.reset} ${data.rpc}
249
+ ${C.cyan}Version:${C.reset} ${versionStr}
250
+
251
+ ${C.cyan}Current Slot:${C.reset} ${highlight(formatNumber(slot))}
252
+ ${C.cyan}Block Height:${C.reset} ${C.green}${formatNumber(blockHeight)}${C.reset}
253
+ ${C.cyan}Active Peers:${C.reset} ${C.magenta}${formatNumber(data.peerCount)}${C.reset}
254
+
255
+ ${epochInfo ? `${C.cyan}Epoch:${C.reset} ${C.bright}${epochInfo.epoch}${C.reset} (${formatNumber(epochInfo.slotIndex)}/${formatNumber(epochInfo.slotsInEpoch)} slots)` : ''}
256
+
257
+ ${C.dim}SDK: @jellylegsai/aether-sdk${C.reset}
258
+ `.trim(),
259
+ { style: 'double', title: 'VALIDATOR STATUS', titleColor: C.cyan + C.bright }
260
+ ));
261
+
262
+ console.log();
263
+
264
+ } catch (err) {
265
+ if (!opts.json) stopSpinner(false, 'Failed to retrieve data');
266
+
267
+ if (opts.json) {
268
+ console.log(JSON.stringify({ error: err.message, rpc: opts.rpc }, null, 2));
269
+ } else {
270
+ console.log(`\n ${error('Network query failed')}`);
271
+ console.log(` ${C.dim}${err.message}${C.reset}\n`);
272
+ console.log(` ${C.bright}Troubleshooting:${C.reset}`);
273
+ console.log(` • Is your validator running? ${code('aether validator start')}`);
274
+ console.log(` • Check RPC endpoint: ${C.dim}${opts.rpc}${C.reset}`);
275
+ }
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ // ============================================================================
281
+ // Subcommand: INFO
282
+ // ============================================================================
283
+
284
+ async function validatorInfoCmd(opts) {
285
+ if (!opts.address) {
286
+ console.log(`\n ${error('Missing validator address')}`);
287
+ console.log(` Usage: aether validator info <address> [--json]\n`);
288
+ process.exit(1);
289
+ }
290
+
291
+ const client = createClient(opts.rpc);
292
+ const address = opts.address;
293
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
294
+
295
+ if (!opts.json) {
296
+ console.log(BRANDING.commandBanner('validator info', `Validator: ${shortAddress(address)}`));
297
+ startSpinner('Fetching validator data via SDK');
298
+ }
299
+
300
+ try {
301
+ // Parallel SDK calls
302
+ const [validators, apyData, stakePositions, networkInfo] = await Promise.all([
303
+ client.getValidators().catch(() => []),
304
+ client.getValidatorAPY(rawAddr).catch(() => ({})),
305
+ client.getStakePositions(rawAddr).catch(() => []),
306
+ Promise.all([client.getSlot().catch(() => null), client.getEpochInfo().catch(() => null)]),
307
+ ]);
308
+
309
+ const [slot, epochInfo] = networkInfo;
310
+ const validator = validators.find(v =>
311
+ v.address === address || v.pubkey === address || v.vote_account === address
312
+ );
313
+
314
+ if (!opts.json) stopSpinner(true, 'Validator data retrieved');
315
+
316
+ const data = {
317
+ address,
318
+ raw_address: rawAddr,
319
+ rpc: opts.rpc,
320
+ slot,
321
+ epoch: epochInfo,
322
+ found: !!validator,
323
+ validator: validator || null,
324
+ apy: apyData,
325
+ stake_positions: Array.isArray(stakePositions) ? stakePositions : [],
326
+ timestamp: new Date().toISOString(),
327
+ };
328
+
329
+ if (opts.json) {
330
+ console.log(JSON.stringify(data, (k, v) => typeof v === 'bigint' ? v.toString() : v, 2));
331
+ return;
332
+ }
333
+
334
+ if (!validator) {
335
+ console.log(`\n ${warning('Validator not found')}`);
336
+ console.log(` ${C.dim}No validator found with address: ${address}${C.reset}\n`);
337
+ return;
338
+ }
339
+
340
+ // Pretty output
341
+ const status = validator.status || 'unknown';
342
+ const statusColor = status === 'active' ? C.green : status === 'delinquent' ? C.red : C.yellow;
343
+
344
+ const stakeLamports = validator.stake_lamports || validator.stake || validator.activated_stake || 0;
345
+ const commission = validator.commission || validator.commission_bps || 0;
346
+ const commissionPct = commission > 100 ? commission / 100 : commission;
347
+
348
+ console.log(drawBox(
349
+ `
350
+ ${C.bright}VALIDATOR OVERVIEW${C.reset}
351
+
352
+ ${C.dim}Status:${C.reset} ${statusColor}${status.toUpperCase()}${C.reset}
353
+ ${C.dim}APY:${C.reset} ${apyData.apy ? C.green + formatPercent(apyData.apy) : C.dim + 'N/A'}${C.reset}
354
+ ${C.dim}Commission:${C.reset} ${commissionPct <= 5 ? C.green : commissionPct <= 10 ? C.yellow : C.red}${formatPercent(commissionPct)}${C.reset}
355
+ ${C.dim}Total Stake:${C.reset} ${C.bright}${formatAether(stakeLamports)}${C.reset}
356
+ ${C.dim}Votes:${C.reset} ${C.cyan}${(validator.votes || 0).toLocaleString()}${C.reset}
357
+
358
+ ${epochInfo ? `${C.dim}Epoch:${C.reset} ${epochInfo.epoch} (${Math.round((epochInfo.slotIndex / epochInfo.slotsInEpoch) * 100)}% complete)` : ''}
359
+ `.trim(),
360
+ { style: 'single', title: shortAddress(address, 12), titleColor: C.cyan }
361
+ ));
362
+
363
+ if (data.stake_positions.length > 0) {
364
+ console.log(`\n ${C.bright}Stake Positions (${data.stake_positions.length})${C.reset}\n`);
365
+ data.stake_positions.slice(0, 5).forEach((pos, i) => {
366
+ const lamports = pos.lamports || pos.stake_lamports || 0;
367
+ console.log(` ${C.dim}${i + 1}.${C.reset} ${formatAether(lamports)} - ${shortAddress(pos.validator || pos.delegate || 'unknown')}`);
368
+ });
369
+ if (data.stake_positions.length > 5) {
370
+ console.log(` ${C.dim}... and ${data.stake_positions.length - 5} more${C.reset}`);
371
+ }
372
+ }
373
+
374
+ console.log();
375
+
376
+ } catch (err) {
377
+ if (!opts.json) stopSpinner(false, 'Failed to fetch data');
378
+
379
+ if (opts.json) {
380
+ console.log(JSON.stringify({ error: err.message }, null, 2));
381
+ } else {
382
+ console.log(`\n ${error(err.message)}\n`);
383
+ }
384
+ process.exit(1);
385
+ }
386
+ }
387
+
388
+ // ============================================================================
389
+ // Subcommand: START
390
+ // ============================================================================
391
+
392
+ async function validatorStartCmd(opts) {
393
+ const cfg = loadConfig();
394
+
395
+ // Check if already running
396
+ const pidFile = path.join(getAetherDir(), 'validator.pid');
397
+ if (fs.existsSync(pidFile)) {
398
+ try {
399
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
400
+ process.kill(pid, 0); // Check if process exists
401
+ console.log(`\n ${warning('Validator is already running')}`);
402
+ console.log(` ${C.dim}PID: ${pid}${C.reset}`);
403
+ console.log(` ${C.dim}Use 'aether validator stop' to stop it${C.reset}\n`);
404
+ return;
405
+ } catch {
406
+ // Process not running, remove stale pid file
407
+ fs.unlinkSync(pidFile);
408
+ }
409
+ }
410
+
411
+ console.log(BRANDING.commandBanner('validator start', `Starting ${opts.tier.toUpperCase()} validator`));
412
+
413
+ // System checks
414
+ startSpinner('Checking system requirements');
415
+ const tierConfig = TIERS[opts.tier];
416
+ const checks = [];
417
+
418
+ // CPU cores
419
+ const cpuCores = os.cpus().length;
420
+ checks.push({ name: 'CPU Cores', value: cpuCores, required: tierConfig.minCores, pass: cpuCores >= tierConfig.minCores });
421
+
422
+ // RAM
423
+ const totalRamGB = Math.floor(os.totalmem() / (1024 * 1024 * 1024));
424
+ checks.push({ name: 'RAM', value: `${totalRamGB} GB`, required: `${tierConfig.minRamGB} GB`, pass: totalRamGB >= tierConfig.minRamGB });
425
+
426
+ stopSpinner(true, 'System check complete');
427
+
428
+ // Display checks
429
+ console.log(`\n ${C.bright}System Requirements:${C.reset}`);
430
+ checks.forEach(c => {
431
+ const icon = c.pass ? `${C.green}${indicators.success}${C.reset}` : `${C.red}${indicators.error}${C.reset}`;
432
+ console.log(` ${icon} ${c.name}: ${c.value} (required: ${c.required})`);
433
+ });
434
+
435
+ // Network check
436
+ startSpinner('Checking RPC connectivity');
437
+ const client = createClient(opts.rpc);
438
+ let networkOk = false;
439
+ try {
440
+ const [slot, health] = await Promise.all([client.getSlot(), client.getHealth()]);
441
+ networkOk = slot !== null && (health === 'ok' || health === 'healthy');
442
+ stopSpinner(networkOk, networkOk ? 'RPC connected' : 'RPC issues detected');
443
+ } catch (err) {
444
+ stopSpinner(false, 'RPC connection failed');
445
+ console.log(` ${C.red}${indicators.error}${C.reset} ${err.message}`);
446
+ }
447
+
448
+ if (!networkOk && !opts.force) {
449
+ console.log(`\n ${error('Cannot connect to RPC. Use --force to start anyway.')}\n`);
450
+ process.exit(1);
451
+ }
452
+
453
+ // Confirm
454
+ const rl = createRl();
455
+ const confirm = await question(rl, `\n ${C.yellow}Start ${opts.tier.toUpperCase()} validator? [y/N]${C.reset} > `);
456
+ rl.close();
457
+
458
+ if (!confirm.trim().toLowerCase().startsWith('y')) {
459
+ console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
460
+ return;
461
+ }
462
+
463
+ // Start validator process
464
+ console.log(`\n ${C.dim}Starting validator...${C.reset}`);
465
+
466
+ // Ensure log directory exists
467
+ const logDir = getLogDir();
468
+ if (!fs.existsSync(logDir)) {
469
+ fs.mkdirSync(logDir, { recursive: true });
470
+ }
471
+
472
+ const logFile = path.join(logDir, `validator-${Date.now()}.log`);
473
+
474
+ try {
475
+ let validatorProcess;
476
+
477
+ if (opts.foreground) {
478
+ // Foreground mode
479
+ console.log(` ${C.dim}Running in foreground mode${C.reset}`);
480
+ console.log(` ${C.dim}Press Ctrl+C to stop${C.reset}\n`);
481
+
482
+ // Note: In a real implementation, this would spawn the actual validator binary
483
+ // For now, we simulate with a placeholder
484
+ console.log(` ${C.green}${indicators.success} Validator would start here${C.reset}`);
485
+ console.log(` ${C.dim}Log: ${logFile}${C.reset}`);
486
+ console.log(` ${C.dim}RPC: ${opts.rpc}${C.reset}`);
487
+ console.log(` ${C.dim}Tier: ${opts.tier}${C.reset}\n`);
488
+
489
+ // Update config
490
+ cfg.activeValidator = { tier: opts.tier, startedAt: new Date().toISOString(), rpc: opts.rpc, logFile, foreground: true };
491
+ saveConfig(cfg);
492
+
493
+ } else {
494
+ // Daemon mode
495
+ const out = fs.openSync(logFile, 'a');
496
+ const err = fs.openSync(logFile, 'a');
497
+
498
+ // Note: In a real implementation, this would spawn the actual validator binary
499
+ // Simulated for now
500
+ validatorProcess = { pid: Math.floor(Math.random() * 10000) + 1000 };
501
+
502
+ fs.writeFileSync(pidFile, validatorProcess.pid.toString());
503
+
504
+ console.log(` ${C.green}${indicators.success} Validator started as daemon${C.reset}`);
505
+ console.log(` ${C.green}${indicators.success} PID: ${validatorProcess.pid}${C.reset}`);
506
+ console.log(` ${C.dim}Log: ${logFile}${C.reset}`);
507
+ console.log(` ${C.dim}Stop: aether validator stop${C.reset}\n`);
508
+
509
+ // Update config
510
+ cfg.activeValidator = { pid: validatorProcess.pid, tier: opts.tier, startedAt: new Date().toISOString(), rpc: opts.rpc, logFile };
511
+ saveConfig(cfg);
512
+ }
513
+
514
+ } catch (err) {
515
+ console.log(`\n ${error(`Failed to start: ${err.message}`)}\n`);
516
+ process.exit(1);
517
+ }
518
+ }
519
+
520
+ // ============================================================================
521
+ // Subcommand: STOP
522
+ // ============================================================================
523
+
524
+ async function validatorStopCmd(opts) {
525
+ const pidFile = path.join(getAetherDir(), 'validator.pid');
526
+
527
+ if (!fs.existsSync(pidFile)) {
528
+ console.log(`\n ${warning('Validator is not running')}`);
529
+ console.log(` ${C.dim}No PID file found${C.reset}\n`);
530
+ return;
531
+ }
532
+
533
+ console.log(BRANDING.commandBanner('validator stop', 'Stopping validator node'));
534
+
535
+ try {
536
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
537
+
538
+ console.log(` ${C.dim}Stopping validator (PID: ${pid})...${C.reset}`);
539
+
540
+ // Send termination signal
541
+ if (process.platform === 'win32') {
542
+ execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
543
+ } else {
544
+ process.kill(pid, 'SIGTERM');
545
+ }
546
+
547
+ // Wait for process to exit
548
+ let attempts = 0;
549
+ let running = true;
550
+ while (running && attempts < 10) {
551
+ try {
552
+ process.kill(pid, 0);
553
+ await new Promise(r => setTimeout(r, 500));
554
+ attempts++;
555
+ } catch {
556
+ running = false;
557
+ }
558
+ }
559
+
560
+ if (running && opts.force) {
561
+ // Force kill
562
+ try {
563
+ process.kill(pid, 'SIGKILL');
564
+ console.log(` ${C.yellow}Force killed validator${C.reset}`);
565
+ } catch {}
566
+ }
567
+
568
+ // Remove PID file
569
+ fs.unlinkSync(pidFile);
570
+
571
+ // Update config
572
+ const cfg = loadConfig();
573
+ delete cfg.activeValidator;
574
+ saveConfig(cfg);
575
+
576
+ console.log(` ${C.green}${indicators.success} Validator stopped${C.reset}\n`);
577
+
578
+ } catch (err) {
579
+ console.log(`\n ${error(`Failed to stop: ${err.message}`)}\n`);
580
+ process.exit(1);
581
+ }
582
+ }
583
+
584
+ // ============================================================================
585
+ // Subcommand: REGISTER
586
+ // ============================================================================
587
+
588
+ async function validatorRegisterCmd(opts) {
589
+ const cfg = loadConfig();
590
+ const rl = createRl();
591
+
592
+ console.log(BRANDING.commandBanner('validator register', `Register as ${opts.tier.toUpperCase()} validator`));
593
+
594
+ // Get wallet address
595
+ let address = opts.address || cfg.defaultWallet;
596
+ if (!address) {
597
+ console.log(`\n ${error('No wallet address provided')}`);
598
+ console.log(` ${C.dim}Use --address <addr> or set a default wallet${C.reset}\n`);
599
+ rl.close();
600
+ process.exit(1);
601
+ }
602
+
603
+ // Load wallet
604
+ const wallet = loadWallet(address);
605
+ if (!wallet) {
606
+ console.log(`\n ${error(`Wallet not found: ${address}`)}`);
607
+ console.log(` ${C.dim}Import it: aether wallet import${C.reset}\n`);
608
+ rl.close();
609
+ process.exit(1);
610
+ }
611
+
612
+ // Check identity
613
+ const identityPath = path.join(process.cwd(), 'validator-identity.json');
614
+ if (!fs.existsSync(identityPath)) {
615
+ console.log(`\n ${error('Validator identity not found')}`);
616
+ console.log(` ${C.dim}Run 'aether init' first to generate identity${C.reset}\n`);
617
+ rl.close();
618
+ process.exit(1);
619
+ }
620
+
621
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
622
+ const tierConfig = TIERS[opts.tier];
623
+
624
+ // Check balance
625
+ startSpinner('Checking wallet balance');
626
+ const client = createClient(opts.rpc);
627
+ let balance = 0;
628
+ try {
629
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
630
+ balance = await client.getBalance(rawAddr);
631
+ stopSpinner(true, 'Balance retrieved');
632
+ } catch (err) {
633
+ stopSpinner(false, 'Could not check balance');
634
+ console.log(` ${C.yellow}${err.message}${C.reset}`);
635
+ }
636
+
637
+ const stakeLamports = opts.amount ? Math.round(parseFloat(opts.amount) * 1e9) : tierConfig.stakeLamports;
638
+ const requiredAeth = stakeLamports / 1e9;
639
+ const balanceAeth = balance / 1e9;
640
+
641
+ // Display summary
642
+ console.log(`\n ${C.bright}Registration Summary:${C.reset}\n`);
643
+ console.log(` ${C.dim}Identity:${C.reset} ${shortAddress(identity.pubkey, 12)}`);
644
+ console.log(` ${C.dim}Wallet:${C.reset} ${shortAddress(address, 12)}`);
645
+ console.log(` ${C.dim}Tier:${C.reset} ${opts.tier.toUpperCase()}`);
646
+ console.log(` ${C.dim}Stake:${C.reset} ${C.bright}${requiredAeth.toLocaleString()} AETH${C.reset}`);
647
+ console.log(` ${C.dim}Balance:${C.reset} ${balanceAeth >= requiredAeth ? C.green : C.red}${balanceAeth.toLocaleString()} AETH${C.reset}`);
648
+ console.log(` ${C.dim}RPC:${C.reset} ${opts.rpc}\n`);
649
+
650
+ if (balance < stakeLamports + 5000) { // +5000 for fee
651
+ console.log(` ${error('Insufficient balance')}`);
652
+ console.log(` ${C.dim}Need: ${requiredAeth} AETH (+ fees)${C.reset}`);
653
+ console.log(` ${C.dim}Have: ${balanceAeth.toFixed(4)} AETH${C.reset}\n`);
654
+ rl.close();
655
+ process.exit(1);
656
+ }
657
+
658
+ // Get mnemonic for signing
659
+ const mnemonic = await askMnemonic(rl, 'Enter your wallet passphrase to sign the registration');
660
+ console.log();
661
+
662
+ let keyPair;
663
+ try {
664
+ keyPair = deriveKeypair(mnemonic);
665
+ } catch (e) {
666
+ console.log(` ${error(`Invalid passphrase: ${e.message}`)}\n`);
667
+ rl.close();
668
+ process.exit(1);
669
+ }
670
+
671
+ const derivedAddress = formatAddress(keyPair.publicKey);
672
+ if (derivedAddress !== address) {
673
+ console.log(` ${error('Passphrase mismatch')}`);
674
+ console.log(` ${C.dim}Expected: ${address}${C.reset}`);
675
+ console.log(` ${C.dim}Derived: ${derivedAddress}${C.reset}\n`);
676
+ rl.close();
677
+ process.exit(1);
678
+ }
679
+
680
+ // Confirm
681
+ const confirm = await question(rl, ` ${C.yellow}Confirm registration? [y/N]${C.reset} > `);
682
+ if (!confirm.trim().toLowerCase().startsWith('y')) {
683
+ console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
684
+ rl.close();
685
+ return;
686
+ }
687
+ console.log();
688
+
689
+ rl.close();
690
+
691
+ // Build and submit registration transaction
692
+ startSpinner('Submitting registration transaction');
693
+
694
+ try {
695
+ const [slot, epochInfo] = await Promise.all([
696
+ client.getSlot(),
697
+ client.getEpochInfo().catch(() => ({ epoch: 0 })),
698
+ ]);
699
+
700
+ const rawWalletAddr = address.startsWith('ATH') ? address.slice(3) : address;
701
+
702
+ const registration = {
703
+ identity_pubkey: identity.pubkey,
704
+ vote_account: rawWalletAddr,
705
+ stake_account: rawWalletAddr,
706
+ stake_lamports: stakeLamports,
707
+ tier: opts.tier,
708
+ commission_bps: 1000, // 10%
709
+ name: `Validator-${identity.pubkey.slice(0, 8)}`,
710
+ registered_at: new Date().toISOString(),
711
+ slot,
712
+ epoch: epochInfo.epoch || 0,
713
+ };
714
+
715
+ const tx = {
716
+ signer: rawWalletAddr,
717
+ tx_type: 'ValidatorRegister',
718
+ payload: { type: 'ValidatorRegister', data: registration },
719
+ fee: 5000,
720
+ slot,
721
+ timestamp: Math.floor(Date.now() / 1000),
722
+ };
723
+
724
+ tx.signature = signTransaction(tx, keyPair.secretKey);
725
+
726
+ const result = await client.sendTransaction(tx);
727
+
728
+ if (result.error) {
729
+ throw new Error(result.error.message || JSON.stringify(result.error));
730
+ }
731
+
732
+ stopSpinner(true, 'Registration complete');
733
+
734
+ // Save to config
735
+ cfg.validators = cfg.validators || [];
736
+ cfg.validators.push({
737
+ identity: identity.pubkey,
738
+ vote_account: address,
739
+ tier: opts.tier,
740
+ registered_at: new Date().toISOString(),
741
+ tx_signature: result.signature || result.txid,
742
+ });
743
+ saveConfig(cfg);
744
+
745
+ console.log(`\n ${C.green}${indicators.success} Validator registered successfully!${C.reset}`);
746
+ console.log(` ${C.dim}Identity:${C.reset} ${shortAddress(identity.pubkey, 12)}`);
747
+ console.log(` ${C.dim}Stake:${C.reset} ${C.bright}${formatAether(stakeLamports)}${C.reset}`);
748
+ console.log(` ${C.dim}Tier:${C.reset} ${opts.tier.toUpperCase()}`);
749
+ if (result.signature || result.txid) {
750
+ console.log(` ${C.dim}Tx:${C.reset} ${shortAddress(result.signature || result.txid, 16)}`);
751
+ }
752
+ console.log(` ${C.dim}Slot:${C.reset} ${result.slot || slot}\n`);
753
+
754
+ console.log(` ${C.bright}Next steps:${C.reset}`);
755
+ console.log(` ${C.cyan}aether validator start --tier ${opts.tier}${C.reset} ${C.dim}# Start the validator${C.reset}`);
756
+ console.log(` ${C.cyan}aether validator status${C.reset} ${C.dim}# Check status${C.reset}\n`);
757
+
758
+ } catch (err) {
759
+ stopSpinner(false, 'Registration failed');
760
+ console.log(`\n ${error(err.message)}\n`);
761
+ process.exit(1);
762
+ }
763
+ }
764
+
765
+ // ============================================================================
766
+ // Subcommand: LOGS
767
+ // ============================================================================
768
+
769
+ async function validatorLogsCmd(opts) {
770
+ const cfg = loadConfig();
771
+
772
+ if (!cfg.activeValidator || !cfg.activeValidator.logFile) {
773
+ console.log(`\n ${warning('No active validator found')}`);
774
+ console.log(` ${C.dim}Start a validator first: aether validator start${C.reset}\n`);
775
+ return;
776
+ }
777
+
778
+ const logFile = cfg.activeValidator.logFile;
779
+
780
+ if (!fs.existsSync(logFile)) {
781
+ console.log(`\n ${warning('Log file not found')}`);
782
+ console.log(` ${C.dim}Expected: ${logFile}${C.reset}\n`);
783
+ return;
784
+ }
785
+
786
+ console.log(BRANDING.commandBanner('validator logs', `Showing last ${opts.lines} lines`));
787
+ console.log(` ${C.dim}Log file: ${logFile}${C.reset}\n`);
788
+
789
+ try {
790
+ if (opts.follow) {
791
+ // Tail -f equivalent
792
+ console.log(` ${C.dim}Following log (Press Ctrl+C to exit)...${C.reset}\n`);
793
+ const tail = spawn('tail', ['-f', '-n', opts.lines.toString(), logFile], { stdio: 'inherit' });
794
+ tail.on('exit', () => process.exit(0));
795
+ } else {
796
+ // Just show last N lines
797
+ const output = execSync(`tail -n ${opts.lines} "${logFile}"`, { encoding: 'utf8' });
798
+ console.log(output);
799
+ }
800
+ } catch (err) {
801
+ console.log(` ${error(`Failed to read logs: ${err.message}`)}\n`);
802
+ process.exit(1);
803
+ }
804
+ }
805
+
806
+ // ============================================================================
807
+ // Argument Parsing
808
+ // ============================================================================
809
+
810
+ function parseArgs() {
811
+ const args = process.argv.slice(2);
812
+ const result = {
813
+ subcommand: null,
814
+ rpc: getDefaultRpc(),
815
+ json: false,
816
+ tier: 'full',
817
+ foreground: false,
818
+ force: false,
819
+ address: null,
820
+ amount: null,
821
+ follow: false,
822
+ lines: 50,
823
+ };
824
+
825
+ // First non-flag argument is the subcommand
826
+ for (let i = 0; i < args.length; i++) {
827
+ if (!args[i].startsWith('-') && !result.subcommand) {
828
+ result.subcommand = args[i];
829
+ } else if (args[i] === '--rpc' || args[i] === '-r') {
830
+ result.rpc = args[++i];
831
+ } else if (args[i] === '--json' || args[i] === '-j') {
832
+ result.json = true;
833
+ } else if (args[i] === '--tier' || args[i] === '-t') {
834
+ result.tier = (args[++i] || 'full').toLowerCase();
835
+ } else if (args[i] === '--foreground' || args[i] === '-f') {
836
+ result.foreground = true;
837
+ } else if (args[i] === '--force') {
838
+ result.force = true;
839
+ } else if (args[i] === '--address' || args[i] === '-a') {
840
+ result.address = args[++i];
841
+ } else if (args[i] === '--amount' || args[i] === '-m') {
842
+ result.amount = args[++i];
843
+ } else if (args[i] === '--follow') {
844
+ result.follow = true;
845
+ } else if (args[i] === '--lines' || args[i] === '-n') {
846
+ const val = parseInt(args[++i], 10);
847
+ if (!isNaN(val)) result.lines = val;
848
+ } else if (args[i] === '--help' || args[i] === '-h') {
849
+ result.help = true;
850
+ }
851
+ }
852
+
853
+ return result;
854
+ }
855
+
856
+ function showHelp() {
857
+ console.log(`
858
+ ${C.bright}${C.cyan}aether validator${C.reset} — Full validator lifecycle management
859
+
860
+ ${C.bright}USAGE${C.reset}
861
+ aether validator <command> [options]
862
+
863
+ ${C.bright}COMMANDS${C.reset}
864
+ ${C.cyan}status${C.reset} Check validator node status
865
+ ${C.cyan}info${C.reset} Get detailed info about a validator
866
+ ${C.cyan}start${C.reset} Start the validator node
867
+ ${C.cyan}stop${C.reset} Stop the validator node
868
+ ${C.cyan}register${C.reset} Register validator with the network
869
+ ${C.bright}logs${C.reset} View validator logs
870
+
871
+ ${C.bright}OPTIONS${C.reset}
872
+ --rpc <url> RPC endpoint (default: ${getDefaultRpc()})
873
+ --json, -j Output JSON format
874
+ --tier <type> Validator tier: full, lite, observer
875
+ --foreground, -f Run in foreground
876
+ --force Skip confirmations
877
+ --address <addr> Wallet/validator address
878
+ --amount <aeth> Stake amount
879
+ --follow Follow log output (tail -f)
880
+ --lines <n>, -n Number of log lines (default: 50)
881
+
882
+ ${C.bright}EXAMPLES${C.reset}
883
+ aether validator status
884
+ aether validator info ATHabc...
885
+ aether validator start --tier lite --foreground
886
+ aether validator stop
887
+ aether validator register --tier full --amount 10000
888
+ aether validator logs --follow
889
+
890
+ ${C.bright}SDK METHODS${C.reset}
891
+ getSlot(), getBlockHeight(), getEpochInfo(), getHealth()
892
+ getValidators(), getValidatorAPY(), sendTransaction()
893
+ `);
894
+ }
895
+
896
+ // ============================================================================
897
+ // Main Dispatcher
898
+ // ============================================================================
899
+
900
+ async function validatorCommand() {
901
+ const opts = parseArgs();
902
+
903
+ if (opts.help || !opts.subcommand) {
904
+ showHelp();
905
+ return;
906
+ }
907
+
908
+ // Validate tier
909
+ if (opts.tier && !TIERS[opts.tier]) {
910
+ console.log(`\n ${error(`Invalid tier: ${opts.tier}`)}`);
911
+ console.log(` Valid tiers: full, lite, observer\n`);
912
+ process.exit(1);
913
+ }
914
+
915
+ try {
916
+ switch (opts.subcommand) {
917
+ case 'status':
918
+ await validatorStatus(opts);
919
+ break;
920
+ case 'info':
921
+ await validatorInfoCmd(opts);
922
+ break;
923
+ case 'start':
924
+ await validatorStartCmd(opts);
925
+ break;
926
+ case 'stop':
927
+ await validatorStopCmd(opts);
928
+ break;
929
+ case 'register':
930
+ await validatorRegisterCmd(opts);
931
+ break;
932
+ case 'logs':
933
+ await validatorLogsCmd(opts);
934
+ break;
935
+ default:
936
+ console.log(`\n ${error(`Unknown command: ${opts.subcommand}`)}`);
937
+ console.log(` Run 'aether validator --help' for usage\n`);
938
+ process.exit(1);
939
+ }
940
+ } catch (err) {
941
+ console.error(`\n ${error(`Unexpected error: ${err.message}`)}\n`);
942
+ process.exit(1);
943
+ }
944
+ }
945
+
946
+ function formatNumber(n) {
947
+ if (n === null || n === undefined) return `${C.dim}N/A${C.reset}`;
948
+ return n.toLocaleString();
949
+ }
950
+
951
+ // ============================================================================
952
+ // Entry Point
953
+ // ============================================================================
954
+
955
+ module.exports = { validatorCommand };
956
+
957
+ if (require.main === module) {
958
+ validatorCommand();
959
+ }