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.
package/commands/apy.js CHANGED
@@ -1,480 +1,480 @@
1
- #!/usr/bin/env node
2
- /**
3
- * aether-cli apy
4
- *
5
- * Estimate APY for a validator or wallet's stake positions.
6
- * Fetches recent reward history, computes average yield over the current
7
- * epoch, and annualises it to an APY figure.
8
- *
9
- * Usage:
10
- * aether apy Show network-wide average APY
11
- * aether apy --validator <addr> APY for a specific validator
12
- * aether apy --address <addr> APY for a wallet's stake delegations
13
- * aether apy --json JSON output for scripting/monitoring
14
- * aether apy --rpc <url> Override default RPC
15
- *
16
- * Examples:
17
- * aether apy --validator ATH3xyz... # Check validator APY
18
- * aether apy --address ATHabc... # Check your wallet's weighted APY
19
- * aether apy --json # Machine-readable output
20
- */
21
-
22
- const http = require('http');
23
- const https = require('https');
24
-
25
- // ANSI colours
26
- const C = {
27
- reset: '\x1b[0m',
28
- bright: '\x1b[1m',
29
- dim: '\x1b[2m',
30
- red: '\x1b[31m',
31
- green: '\x1b[32m',
32
- yellow: '\x1b[33m',
33
- cyan: '\x1b[36m',
34
- magenta: '\x1b[35m',
35
- };
36
-
37
- const CLI_VERSION = '1.0.0';
38
-
39
- // ---------------------------------------------------------------------------
40
- // Config
41
- // ---------------------------------------------------------------------------
42
-
43
- function getDefaultRpc() {
44
- return process.env.AETHER_RPC || 'http://127.0.0.1:8899';
45
- }
46
-
47
- function getDefaultConfig() {
48
- const fs = require('fs');
49
- const path = require('path');
50
- const os = require('os');
51
- const cfgPath = path.join(os.homedir(), '.aether', 'config.json');
52
- if (!fs.existsSync(cfgPath)) return { defaultWallet: null };
53
- try {
54
- return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
55
- } catch {
56
- return { defaultWallet: null };
57
- }
58
- }
59
-
60
- // ---------------------------------------------------------------------------
61
- // HTTP helpers
62
- // ---------------------------------------------------------------------------
63
-
64
- function httpRequest(rpcUrl, pathStr, timeoutMs = 10000) {
65
- return new Promise((resolve, reject) => {
66
- const url = new URL(pathStr, rpcUrl);
67
- const lib = url.protocol === 'https:' ? https : http;
68
- const req = lib.request({
69
- hostname: url.hostname,
70
- port: url.port || (url.protocol === 'https:' ? 443 : 80),
71
- path: url.pathname + url.search,
72
- method: 'GET',
73
- timeout: timeoutMs,
74
- headers: { 'Content-Type': 'application/json' },
75
- }, (res) => {
76
- let data = '';
77
- res.on('data', (chunk) => data += chunk);
78
- res.on('end', () => {
79
- try { resolve(JSON.parse(data)); }
80
- catch { resolve({ _raw: data }); }
81
- });
82
- });
83
- req.on('error', reject);
84
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
85
- req.end();
86
- });
87
- }
88
-
89
- function httpPost(rpcUrl, pathStr, body, timeoutMs = 10000) {
90
- return new Promise((resolve, reject) => {
91
- const url = new URL(pathStr, rpcUrl);
92
- const lib = url.protocol === 'https:' ? https : http;
93
- const bodyStr = JSON.stringify(body);
94
- const req = lib.request({
95
- hostname: url.hostname,
96
- port: url.port || (url.protocol === 'https:' ? 443 : 80),
97
- path: url.pathname + url.search,
98
- method: 'POST',
99
- timeout: timeoutMs,
100
- headers: {
101
- 'Content-Type': 'application/json',
102
- 'Content-Length': Buffer.byteLength(bodyStr),
103
- },
104
- }, (res) => {
105
- let data = '';
106
- res.on('data', (chunk) => data += chunk);
107
- res.on('end', () => {
108
- try { resolve(JSON.parse(data)); }
109
- catch { resolve({ _raw: data }); }
110
- });
111
- });
112
- req.on('error', reject);
113
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
114
- req.write(bodyStr);
115
- req.end();
116
- });
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // Argument parsing
121
- // ---------------------------------------------------------------------------
122
-
123
- function parseArgs() {
124
- const args = process.argv.slice(2);
125
- const opts = {
126
- rpc: getDefaultRpc(),
127
- validator: null,
128
- address: null,
129
- asJson: false,
130
- epochs: 14, // default lookback for APY calc
131
- };
132
-
133
- for (let i = 0; i < args.length; i++) {
134
- const arg = args[i];
135
- if (arg === '--rpc' || arg === '-r') {
136
- opts.rpc = args[++i];
137
- } else if (arg === '--validator' || arg === '-v') {
138
- opts.validator = args[++i];
139
- } else if (arg === '--address' || arg === '-a') {
140
- opts.address = args[++i];
141
- } else if (arg === '--json' || arg === '-j') {
142
- opts.asJson = true;
143
- } else if (arg === '--epochs' || arg === '-e') {
144
- const v = parseInt(args[++i], 10);
145
- if (!isNaN(v) && v > 0 && v <= 100) opts.epochs = v;
146
- } else if (arg === '--help' || arg === '-h') {
147
- showHelp();
148
- process.exit(0);
149
- }
150
- }
151
-
152
- // Fall back to default wallet if no address provided
153
- if (!opts.address && !opts.validator) {
154
- const cfg = getDefaultConfig();
155
- opts.address = cfg.defaultWallet;
156
- }
157
-
158
- return opts;
159
- }
160
-
161
- function showHelp() {
162
- console.log(`
163
- ${C.bright}${C.cyan}aether-cli apy${C.reset} - Validator APY Estimator
164
-
165
- ${C.bright}Usage:${C.reset}
166
- aether apy Network-wide average APY
167
- aether apy --validator <addr> APY for a specific validator
168
- aether apy --address <addr> APY for wallet's stake positions
169
- aether apy --rpc <url> Override default RPC
170
- aether apy --json JSON output for scripting
171
- aether apy --epochs <n> Lookback epochs (default: 14, max: 100)
172
-
173
- ${C.bright}Examples:${C.reset}
174
- aether apy --validator ATH3J8... Check a validator's APY
175
- aether apy --address ATHabc... Check your wallet's weighted APY
176
- aether apy --json Machine-readable output
177
- `.trim());
178
- }
179
-
180
- // ---------------------------------------------------------------------------
181
- // APY calculation
182
- // ---------------------------------------------------------------------------
183
-
184
- /**
185
- * Estimate APY from reward history.
186
- *
187
- * Strategy:
188
- * 1. Fetch last N epochs of reward history for the target
189
- * 2. Compute per-epoch yield: rewards_staked
190
- * 3. Average the yield, then annualise: APY = ((1 + avg_yield) ^ epochs_per_year) - 1
191
- *
192
- * Aether epochs are ~4 hours → ~2190 epochs/year
193
- */
194
- async function estimateApy({ rpc, validator, address, epochs }) {
195
- const EPOCHS_PER_YEAR = 2190;
196
-
197
- // Normalise address: strip ATH prefix for API calls
198
- const rawAddr = (validator || address || '').startsWith('ATH')
199
- ? (validator || address || '').slice(3)
200
- : (validator || address || '');
201
-
202
- // Step 1: fetch epoch info to get current epoch
203
- let currentEpoch = null;
204
- let epochLength = null;
205
- try {
206
- const epochInfo = await httpRequest(rpc, '/v1/epoch/info');
207
- if (!epochInfo.error) {
208
- currentEpoch = epochInfo.epoch;
209
- epochLength = epochInfo.slots_in_epoch || epochInfo.epoch_length;
210
- }
211
- } catch { /* use defaults */ }
212
-
213
- // Step 2: fetch reward history
214
- // Try /v1/rewards?address=<addr>&epochs=<N> first
215
- let rewardHistory = [];
216
- try {
217
- const fromEpoch = Math.max(0, (currentEpoch || epochs) - epochs);
218
- const rewardsRes = await httpRequest(
219
- rpc,
220
- `/v1/rewards?address=${encodeURIComponent(rawAddr)}&from_epoch=${fromEpoch}&limit=${epochs}`
221
- );
222
- if (!rewardsRes.error) {
223
- rewardHistory = Array.isArray(rewardsRes)
224
- ? rewardsRes
225
- : (rewardsRes.rewards || []);
226
- }
227
- } catch { /* try alternate endpoint */ }
228
-
229
- // Fallback: /v1/stake?address=<addr> (contains accumulated rewards)
230
- if (rewardHistory.length === 0) {
231
- try {
232
- const stakeRes = await httpRequest(
233
- rpc,
234
- `/v1/stake?address=${encodeURIComponent(rawAddr)}`
235
- );
236
- if (!stakeRes.error) {
237
- const accounts = Array.isArray(stakeRes) ? stakeRes : (stakeRes.accounts || []);
238
- for (const acc of accounts) {
239
- if (acc.rewards !== undefined) {
240
- rewardHistory.push({
241
- epoch: acc.epoch || currentEpoch,
242
- rewards: acc.rewards,
243
- lamports: acc.stake_lamports || acc.lamports || 0,
244
- });
245
- }
246
- }
247
- }
248
- } catch { /* no stake data */ }
249
- }
250
-
251
- // Step 3: calculate APY
252
- if (rewardHistory.length === 0) {
253
- // No reward data — try fetching network-wide stats for a ballpark
254
- try {
255
- const supplyRes = await httpRequest(rpc, '/v1/supply');
256
- const epochRes = await httpRequest(rpc, '/v1/epoch/info');
257
- if (!supplyRes.error && !epochRes.error) {
258
- // Rough estimate: total_reward_rate from inflation schedule
259
- // Aether uses ~7% inflation Year 1, declining. Use 7% as starting point.
260
- const inflationRate = 0.07; // 7% base, would need real data for precision
261
- return {
262
- apy: inflationRate,
263
- apy_pct: inflationRate * 100,
264
- method: 'inflation_model',
265
- epoch: currentEpoch,
266
- epochs_used: 0,
267
- epochs_available: 0,
268
- total_staked: supplyRes.total || 0,
269
- validator,
270
- address,
271
- note: 'No reward history available. APY estimated from network inflation model.',
272
- };
273
- }
274
- } catch { /* no network data either */ }
275
-
276
- return {
277
- apy: null,
278
- apy_pct: null,
279
- method: 'none',
280
- epoch: currentEpoch,
281
- epochs_used: 0,
282
- error: 'No reward history or network data available. Ensure your validator is running and has reward data.',
283
- };
284
- }
285
-
286
- // Compute weighted average yield per epoch
287
- let totalRewards = 0;
288
- let totalStaked = 0;
289
- let epochsWithRewards = 0;
290
-
291
- for (const entry of rewardHistory) {
292
- const rewards = entry.rewards || 0;
293
- const staked = entry.lamports || entry.stake_lamports || 0;
294
- totalRewards += rewards;
295
- if (staked > 0) totalStaked += staked;
296
- if (rewards > 0) epochsWithRewards++;
297
- }
298
-
299
- if (totalStaked === 0 || totalRewards === 0) {
300
- return {
301
- apy: null,
302
- apy_pct: null,
303
- method: 'insufficient_data',
304
- epoch: currentEpoch,
305
- epochs_used: rewardHistory.length,
306
- epochs_with_rewards: epochsWithRewards,
307
- validator,
308
- address,
309
- note: 'Stake positions exist but no reward data yet. Check back after epoch ends.',
310
- };
311
- }
312
-
313
- // Average epoch yield = total rewards / (total staked * num epochs)
314
- const avgYieldPerEpoch = totalRewards / (totalStaked * rewardHistory.length);
315
-
316
- // Annualise: APY = (1 + yield_per_epoch) ^ epochs_per_year - 1
317
- const apy = Math.pow(1 + avgYieldPerEpoch, EPOCHS_PER_YEAR) - 1;
318
- const apyPct = apy * 100;
319
-
320
- return {
321
- apy,
322
- apy_pct: parseFloat(apyPct.toFixed(2)),
323
- method: 'reward_history',
324
- epoch: currentEpoch,
325
- epochs_used: rewardHistory.length,
326
- epochs_with_rewards: epochsWithRewards,
327
- total_rewards_lamports: totalRewards,
328
- total_staked_lamports: totalStaked,
329
- avg_yield_per_epoch_pct: parseFloat((avgYieldPerEpoch * 100).toFixed(4)),
330
- validator,
331
- address,
332
- };
333
- }
334
-
335
- /**
336
- * Format APY bar: ████░░░░░░
337
- */
338
- function formatApyBar(apyPct, maxPct = 20) {
339
- const totalBars = 20;
340
- const filled = Math.min(totalBars, Math.round((apyPct / maxPct) * totalBars));
341
- return C.green + '█'.repeat(filled) + C.dim + '░'.repeat(totalBars - filled) + C.reset;
342
- }
343
-
344
- // ---------------------------------------------------------------------------
345
- // Output formatters
346
- // ---------------------------------------------------------------------------
347
-
348
- function outputHuman(apyData, opts) {
349
- const { rpc, validator, address, asJson } = opts;
350
-
351
- console.log(`\n${C.bright}${C.cyan}── Validator APY Estimate ──────────────────────────────${C.reset}\n`);
352
-
353
- const targetLabel = validator
354
- ? `Validator: ${C.bright}${validator}${C.reset}`
355
- : `Address: ${C.bright}${address}${C.reset}`;
356
- console.log(` ${C.green}★${C.reset} ${targetLabel}`);
357
- console.log(` ${C.dim} RPC: ${rpc}${C.reset}`);
358
- console.log();
359
-
360
- if (apyData.error) {
361
- console.log(` ${C.yellow}⚠ ${apyData.error}${C.reset}\n`);
362
- return;
363
- }
364
-
365
- if (apyData.note) {
366
- console.log(` ${C.yellow}⚠ ${apyData.note}${C.reset}`);
367
- console.log();
368
- }
369
-
370
- if (apyData.apy_pct === null) {
371
- console.log(` ${C.yellow}⚠ APY data unavailable.${C.reset}`);
372
- if (apyData.method === 'inflation_model') {
373
- console.log(` ${C.dim} Using network inflation model as estimate.${C.reset}`);
374
- }
375
- console.log();
376
- return;
377
- }
378
-
379
- // Main APY display
380
- const apyColor = apyData.apy_pct >= 8
381
- ? C.green
382
- : apyData.apy_pct >= 4
383
- ? C.cyan
384
- : apyData.apy_pct >= 2
385
- ? C.yellow
386
- : C.red;
387
-
388
- console.log(` ${C.bright}${apyColor}${apyData.apy_pct.toFixed(2)}%${C.reset} ${C.dim}APY${C.reset}`);
389
- console.log();
390
-
391
- // Visual bar
392
- console.log(` ${C.dim}Yield:${C.reset} ${formatApyBar(apyData.apy_pct)}`);
393
- console.log();
394
-
395
- // Stats
396
- console.log(` ${C.dim}Method:${C.reset} ${C.bright}${apyData.method === 'reward_history' ? 'Reward history annualised' : 'Inflation model'}${C.reset}`);
397
- if (apyData.epoch !== undefined && apyData.epoch !== null) {
398
- console.log(` ${C.dim}Epoch:${C.reset} ${C.bright}#${apyData.epoch}${C.reset}`);
399
- }
400
- console.log(` ${C.dim}Epochs used:${C.reset} ${apyData.epochs_used}`);
401
-
402
- if (apyData.avg_yield_per_epoch_pct !== undefined) {
403
- console.log(` ${C.dim}Avg/epoch:${C.reset} ${C.bright}${apyData.avg_yield_per_epoch_pct.toFixed(4)}%${C.reset}`);
404
- }
405
-
406
- if (apyData.total_rewards_lamports !== undefined && apyData.total_staked_lamports !== undefined) {
407
- const totalAeth = (apyData.total_rewards_lamports / 1e9).toFixed(4);
408
- const stakedAeth = (apyData.total_staked_lamports / 1e9).toFixed(2);
409
- console.log(` ${C.dim}Total rewards:${C.reset} ${C.green}${totalAeth} AETH${C.reset}`);
410
- console.log(` ${C.dim}Total staked:${C.reset} ${stakedAeth} AETH`);
411
- }
412
-
413
- console.log();
414
-
415
- // Disclaimer
416
- console.log(` ${C.dim}Note: APY is an estimate based on ${apyData.epochs_used} epoch(s) of reward data.${C.reset}`);
417
- console.log(` ${C.dim}Actual returns may vary. Check aether rewards for precise figures.${C.reset}`);
418
- console.log();
419
- }
420
-
421
- function outputJson(apyData, opts) {
422
- console.log(JSON.stringify({
423
- apy_pct: apyData.apy_pct,
424
- apy: apyData.apy,
425
- method: apyData.method,
426
- epoch: apyData.epoch,
427
- epochs_used: apyData.epochs_used,
428
- epochs_with_rewards: apyData.epochs_with_rewards,
429
- avg_yield_per_epoch_pct: apyData.avg_yield_per_epoch_pct,
430
- total_rewards_lamports: apyData.total_rewards_lamports,
431
- total_staked_lamports: apyData.total_staked_lamports,
432
- validator: apyData.validator,
433
- address: apyData.address,
434
- note: apyData.note || null,
435
- error: apyData.error || null,
436
- rpc: opts.rpc,
437
- cli_version: CLI_VERSION,
438
- timestamp: new Date().toISOString(),
439
- }, null, 2));
440
- }
441
-
442
- // ---------------------------------------------------------------------------
443
- // Main
444
- // ---------------------------------------------------------------------------
445
-
446
- async function main() {
447
- const opts = parseArgs();
448
-
449
- try {
450
- const apyData = await estimateApy(opts);
451
-
452
- if (opts.asJson) {
453
- outputJson(apyData, opts);
454
- } else {
455
- outputHuman(apyData, opts);
456
- }
457
- } catch (err) {
458
- if (opts.asJson) {
459
- console.log(JSON.stringify({
460
- apy_pct: null,
461
- apy: null,
462
- error: err.message,
463
- validator: opts.validator,
464
- address: opts.address,
465
- rpc: opts.rpc,
466
- cli_version: CLI_VERSION,
467
- timestamp: new Date().toISOString(),
468
- }, null, 2));
469
- } else {
470
- console.log(`\n ${C.red}✗ APY calculation failed:${C.reset} ${err.message}`);
471
- console.log(` ${C.dim} RPC: ${opts.rpc}${C.reset}`);
472
- console.log(` ${C.dim} Is your validator running?${C.reset}\n`);
473
- }
474
- process.exit(1);
475
- }
476
- }
477
-
478
- main();
479
-
480
- module.exports = { apyCommand: main };
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli apy
4
+ *
5
+ * Estimate APY for a validator or wallet's stake positions.
6
+ * Fetches recent reward history, computes average yield over the current
7
+ * epoch, and annualises it to an APY figure.
8
+ *
9
+ * Usage:
10
+ * aether apy Show network-wide average APY
11
+ * aether apy --validator <addr> APY for a specific validator
12
+ * aether apy --address <addr> APY for a wallet's stake delegations
13
+ * aether apy --json JSON output for scripting/monitoring
14
+ * aether apy --rpc <url> Override default RPC
15
+ *
16
+ * Examples:
17
+ * aether apy --validator ATH3xyz... # Check validator APY
18
+ * aether apy --address ATHabc... # Check your wallet's weighted APY
19
+ * aether apy --json # Machine-readable output
20
+ */
21
+
22
+ const http = require('http');
23
+ const https = require('https');
24
+
25
+ // ANSI colours
26
+ const C = {
27
+ reset: '\x1b[0m',
28
+ bright: '\x1b[1m',
29
+ dim: '\x1b[2m',
30
+ red: '\x1b[31m',
31
+ green: '\x1b[32m',
32
+ yellow: '\x1b[33m',
33
+ cyan: '\x1b[36m',
34
+ magenta: '\x1b[35m',
35
+ };
36
+
37
+ const CLI_VERSION = '1.0.0';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Config
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function getDefaultRpc() {
44
+ return process.env.AETHER_RPC || 'http://127.0.0.1:8899';
45
+ }
46
+
47
+ function getDefaultConfig() {
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+ const os = require('os');
51
+ const cfgPath = path.join(os.homedir(), '.aether', 'config.json');
52
+ if (!fs.existsSync(cfgPath)) return { defaultWallet: null };
53
+ try {
54
+ return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
55
+ } catch {
56
+ return { defaultWallet: null };
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // HTTP helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function httpRequest(rpcUrl, pathStr, timeoutMs = 10000) {
65
+ return new Promise((resolve, reject) => {
66
+ const url = new URL(pathStr, rpcUrl);
67
+ const lib = url.protocol === 'https:' ? https : http;
68
+ const req = lib.request({
69
+ hostname: url.hostname,
70
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
71
+ path: url.pathname + url.search,
72
+ method: 'GET',
73
+ timeout: timeoutMs,
74
+ headers: { 'Content-Type': 'application/json' },
75
+ }, (res) => {
76
+ let data = '';
77
+ res.on('data', (chunk) => data += chunk);
78
+ res.on('end', () => {
79
+ try { resolve(JSON.parse(data)); }
80
+ catch { resolve({ _raw: data }); }
81
+ });
82
+ });
83
+ req.on('error', reject);
84
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
85
+ req.end();
86
+ });
87
+ }
88
+
89
+ function httpPost(rpcUrl, pathStr, body, timeoutMs = 10000) {
90
+ return new Promise((resolve, reject) => {
91
+ const url = new URL(pathStr, rpcUrl);
92
+ const lib = url.protocol === 'https:' ? https : http;
93
+ const bodyStr = JSON.stringify(body);
94
+ const req = lib.request({
95
+ hostname: url.hostname,
96
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
97
+ path: url.pathname + url.search,
98
+ method: 'POST',
99
+ timeout: timeoutMs,
100
+ headers: {
101
+ 'Content-Type': 'application/json',
102
+ 'Content-Length': Buffer.byteLength(bodyStr),
103
+ },
104
+ }, (res) => {
105
+ let data = '';
106
+ res.on('data', (chunk) => data += chunk);
107
+ res.on('end', () => {
108
+ try { resolve(JSON.parse(data)); }
109
+ catch { resolve({ _raw: data }); }
110
+ });
111
+ });
112
+ req.on('error', reject);
113
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
114
+ req.write(bodyStr);
115
+ req.end();
116
+ });
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Argument parsing
121
+ // ---------------------------------------------------------------------------
122
+
123
+ function parseArgs() {
124
+ const args = process.argv.slice(2);
125
+ const opts = {
126
+ rpc: getDefaultRpc(),
127
+ validator: null,
128
+ address: null,
129
+ asJson: false,
130
+ epochs: 14, // default lookback for APY calc
131
+ };
132
+
133
+ for (let i = 0; i < args.length; i++) {
134
+ const arg = args[i];
135
+ if (arg === '--rpc' || arg === '-r') {
136
+ opts.rpc = args[++i];
137
+ } else if (arg === '--validator' || arg === '-v') {
138
+ opts.validator = args[++i];
139
+ } else if (arg === '--address' || arg === '-a') {
140
+ opts.address = args[++i];
141
+ } else if (arg === '--json' || arg === '-j') {
142
+ opts.asJson = true;
143
+ } else if (arg === '--epochs' || arg === '-e') {
144
+ const v = parseInt(args[++i], 10);
145
+ if (!isNaN(v) && v > 0 && v <= 100) opts.epochs = v;
146
+ } else if (arg === '--help' || arg === '-h') {
147
+ showHelp();
148
+ process.exit(0);
149
+ }
150
+ }
151
+
152
+ // Fall back to default wallet if no address provided
153
+ if (!opts.address && !opts.validator) {
154
+ const cfg = getDefaultConfig();
155
+ opts.address = cfg.defaultWallet;
156
+ }
157
+
158
+ return opts;
159
+ }
160
+
161
+ function showHelp() {
162
+ console.log(`
163
+ ${C.bright}${C.cyan}aether-cli apy${C.reset} - Validator APY Estimator
164
+
165
+ ${C.bright}Usage:${C.reset}
166
+ aether apy Network-wide average APY
167
+ aether apy --validator <addr> APY for a specific validator
168
+ aether apy --address <addr> APY for wallet's stake positions
169
+ aether apy --rpc <url> Override default RPC
170
+ aether apy --json JSON output for scripting
171
+ aether apy --epochs <n> Lookback epochs (default: 14, max: 100)
172
+
173
+ ${C.bright}Examples:${C.reset}
174
+ aether apy --validator ATH3J8... Check a validator's APY
175
+ aether apy --address ATHabc... Check your wallet's weighted APY
176
+ aether apy --json Machine-readable output
177
+ `.trim());
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // APY calculation
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * Estimate APY from reward history.
186
+ *
187
+ * Strategy:
188
+ * 1. Fetch last N epochs of reward history for the target
189
+ * 2. Compute per-epoch yield: rewards_staked
190
+ * 3. Average the yield, then annualise: APY = ((1 + avg_yield) ^ epochs_per_year) - 1
191
+ *
192
+ * Aether epochs are ~4 hours → ~2190 epochs/year
193
+ */
194
+ async function estimateApy({ rpc, validator, address, epochs }) {
195
+ const EPOCHS_PER_YEAR = 2190;
196
+
197
+ // Normalise address: strip ATH prefix for API calls
198
+ const rawAddr = (validator || address || '').startsWith('ATH')
199
+ ? (validator || address || '').slice(3)
200
+ : (validator || address || '');
201
+
202
+ // Step 1: fetch epoch info to get current epoch
203
+ let currentEpoch = null;
204
+ let epochLength = null;
205
+ try {
206
+ const epochInfo = await httpRequest(rpc, '/v1/epoch/info');
207
+ if (!epochInfo.error) {
208
+ currentEpoch = epochInfo.epoch;
209
+ epochLength = epochInfo.slots_in_epoch || epochInfo.epoch_length;
210
+ }
211
+ } catch { /* use defaults */ }
212
+
213
+ // Step 2: fetch reward history
214
+ // Try /v1/rewards?address=<addr>&epochs=<N> first
215
+ let rewardHistory = [];
216
+ try {
217
+ const fromEpoch = Math.max(0, (currentEpoch || epochs) - epochs);
218
+ const rewardsRes = await httpRequest(
219
+ rpc,
220
+ `/v1/rewards?address=${encodeURIComponent(rawAddr)}&from_epoch=${fromEpoch}&limit=${epochs}`
221
+ );
222
+ if (!rewardsRes.error) {
223
+ rewardHistory = Array.isArray(rewardsRes)
224
+ ? rewardsRes
225
+ : (rewardsRes.rewards || []);
226
+ }
227
+ } catch { /* try alternate endpoint */ }
228
+
229
+ // Fallback: /v1/stake?address=<addr> (contains accumulated rewards)
230
+ if (rewardHistory.length === 0) {
231
+ try {
232
+ const stakeRes = await httpRequest(
233
+ rpc,
234
+ `/v1/stake?address=${encodeURIComponent(rawAddr)}`
235
+ );
236
+ if (!stakeRes.error) {
237
+ const accounts = Array.isArray(stakeRes) ? stakeRes : (stakeRes.accounts || []);
238
+ for (const acc of accounts) {
239
+ if (acc.rewards !== undefined) {
240
+ rewardHistory.push({
241
+ epoch: acc.epoch || currentEpoch,
242
+ rewards: acc.rewards,
243
+ lamports: acc.stake_lamports || acc.lamports || 0,
244
+ });
245
+ }
246
+ }
247
+ }
248
+ } catch { /* no stake data */ }
249
+ }
250
+
251
+ // Step 3: calculate APY
252
+ if (rewardHistory.length === 0) {
253
+ // No reward data — try fetching network-wide stats for a ballpark
254
+ try {
255
+ const supplyRes = await httpRequest(rpc, '/v1/supply');
256
+ const epochRes = await httpRequest(rpc, '/v1/epoch/info');
257
+ if (!supplyRes.error && !epochRes.error) {
258
+ // Rough estimate: total_reward_rate from inflation schedule
259
+ // Aether uses ~7% inflation Year 1, declining. Use 7% as starting point.
260
+ const inflationRate = 0.07; // 7% base, would need real data for precision
261
+ return {
262
+ apy: inflationRate,
263
+ apy_pct: inflationRate * 100,
264
+ method: 'inflation_model',
265
+ epoch: currentEpoch,
266
+ epochs_used: 0,
267
+ epochs_available: 0,
268
+ total_staked: supplyRes.total || 0,
269
+ validator,
270
+ address,
271
+ note: 'No reward history available. APY estimated from network inflation model.',
272
+ };
273
+ }
274
+ } catch { /* no network data either */ }
275
+
276
+ return {
277
+ apy: null,
278
+ apy_pct: null,
279
+ method: 'none',
280
+ epoch: currentEpoch,
281
+ epochs_used: 0,
282
+ error: 'No reward history or network data available. Ensure your validator is running and has reward data.',
283
+ };
284
+ }
285
+
286
+ // Compute weighted average yield per epoch
287
+ let totalRewards = 0;
288
+ let totalStaked = 0;
289
+ let epochsWithRewards = 0;
290
+
291
+ for (const entry of rewardHistory) {
292
+ const rewards = entry.rewards || 0;
293
+ const staked = entry.lamports || entry.stake_lamports || 0;
294
+ totalRewards += rewards;
295
+ if (staked > 0) totalStaked += staked;
296
+ if (rewards > 0) epochsWithRewards++;
297
+ }
298
+
299
+ if (totalStaked === 0 || totalRewards === 0) {
300
+ return {
301
+ apy: null,
302
+ apy_pct: null,
303
+ method: 'insufficient_data',
304
+ epoch: currentEpoch,
305
+ epochs_used: rewardHistory.length,
306
+ epochs_with_rewards: epochsWithRewards,
307
+ validator,
308
+ address,
309
+ note: 'Stake positions exist but no reward data yet. Check back after epoch ends.',
310
+ };
311
+ }
312
+
313
+ // Average epoch yield = total rewards / (total staked * num epochs)
314
+ const avgYieldPerEpoch = totalRewards / (totalStaked * rewardHistory.length);
315
+
316
+ // Annualise: APY = (1 + yield_per_epoch) ^ epochs_per_year - 1
317
+ const apy = Math.pow(1 + avgYieldPerEpoch, EPOCHS_PER_YEAR) - 1;
318
+ const apyPct = apy * 100;
319
+
320
+ return {
321
+ apy,
322
+ apy_pct: parseFloat(apyPct.toFixed(2)),
323
+ method: 'reward_history',
324
+ epoch: currentEpoch,
325
+ epochs_used: rewardHistory.length,
326
+ epochs_with_rewards: epochsWithRewards,
327
+ total_rewards_lamports: totalRewards,
328
+ total_staked_lamports: totalStaked,
329
+ avg_yield_per_epoch_pct: parseFloat((avgYieldPerEpoch * 100).toFixed(4)),
330
+ validator,
331
+ address,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Format APY bar: ████░░░░░░
337
+ */
338
+ function formatApyBar(apyPct, maxPct = 20) {
339
+ const totalBars = 20;
340
+ const filled = Math.min(totalBars, Math.round((apyPct / maxPct) * totalBars));
341
+ return C.green + '█'.repeat(filled) + C.dim + '░'.repeat(totalBars - filled) + C.reset;
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Output formatters
346
+ // ---------------------------------------------------------------------------
347
+
348
+ function outputHuman(apyData, opts) {
349
+ const { rpc, validator, address, asJson } = opts;
350
+
351
+ console.log(`\n${C.bright}${C.cyan}── Validator APY Estimate ──────────────────────────────${C.reset}\n`);
352
+
353
+ const targetLabel = validator
354
+ ? `Validator: ${C.bright}${validator}${C.reset}`
355
+ : `Address: ${C.bright}${address}${C.reset}`;
356
+ console.log(` ${C.green}★${C.reset} ${targetLabel}`);
357
+ console.log(` ${C.dim} RPC: ${rpc}${C.reset}`);
358
+ console.log();
359
+
360
+ if (apyData.error) {
361
+ console.log(` ${C.yellow}⚠ ${apyData.error}${C.reset}\n`);
362
+ return;
363
+ }
364
+
365
+ if (apyData.note) {
366
+ console.log(` ${C.yellow}⚠ ${apyData.note}${C.reset}`);
367
+ console.log();
368
+ }
369
+
370
+ if (apyData.apy_pct === null) {
371
+ console.log(` ${C.yellow}⚠ APY data unavailable.${C.reset}`);
372
+ if (apyData.method === 'inflation_model') {
373
+ console.log(` ${C.dim} Using network inflation model as estimate.${C.reset}`);
374
+ }
375
+ console.log();
376
+ return;
377
+ }
378
+
379
+ // Main APY display
380
+ const apyColor = apyData.apy_pct >= 8
381
+ ? C.green
382
+ : apyData.apy_pct >= 4
383
+ ? C.cyan
384
+ : apyData.apy_pct >= 2
385
+ ? C.yellow
386
+ : C.red;
387
+
388
+ console.log(` ${C.bright}${apyColor}${apyData.apy_pct.toFixed(2)}%${C.reset} ${C.dim}APY${C.reset}`);
389
+ console.log();
390
+
391
+ // Visual bar
392
+ console.log(` ${C.dim}Yield:${C.reset} ${formatApyBar(apyData.apy_pct)}`);
393
+ console.log();
394
+
395
+ // Stats
396
+ console.log(` ${C.dim}Method:${C.reset} ${C.bright}${apyData.method === 'reward_history' ? 'Reward history annualised' : 'Inflation model'}${C.reset}`);
397
+ if (apyData.epoch !== undefined && apyData.epoch !== null) {
398
+ console.log(` ${C.dim}Epoch:${C.reset} ${C.bright}#${apyData.epoch}${C.reset}`);
399
+ }
400
+ console.log(` ${C.dim}Epochs used:${C.reset} ${apyData.epochs_used}`);
401
+
402
+ if (apyData.avg_yield_per_epoch_pct !== undefined) {
403
+ console.log(` ${C.dim}Avg/epoch:${C.reset} ${C.bright}${apyData.avg_yield_per_epoch_pct.toFixed(4)}%${C.reset}`);
404
+ }
405
+
406
+ if (apyData.total_rewards_lamports !== undefined && apyData.total_staked_lamports !== undefined) {
407
+ const totalAeth = (apyData.total_rewards_lamports / 1e9).toFixed(4);
408
+ const stakedAeth = (apyData.total_staked_lamports / 1e9).toFixed(2);
409
+ console.log(` ${C.dim}Total rewards:${C.reset} ${C.green}${totalAeth} AETH${C.reset}`);
410
+ console.log(` ${C.dim}Total staked:${C.reset} ${stakedAeth} AETH`);
411
+ }
412
+
413
+ console.log();
414
+
415
+ // Disclaimer
416
+ console.log(` ${C.dim}Note: APY is an estimate based on ${apyData.epochs_used} epoch(s) of reward data.${C.reset}`);
417
+ console.log(` ${C.dim}Actual returns may vary. Check aether rewards for precise figures.${C.reset}`);
418
+ console.log();
419
+ }
420
+
421
+ function outputJson(apyData, opts) {
422
+ console.log(JSON.stringify({
423
+ apy_pct: apyData.apy_pct,
424
+ apy: apyData.apy,
425
+ method: apyData.method,
426
+ epoch: apyData.epoch,
427
+ epochs_used: apyData.epochs_used,
428
+ epochs_with_rewards: apyData.epochs_with_rewards,
429
+ avg_yield_per_epoch_pct: apyData.avg_yield_per_epoch_pct,
430
+ total_rewards_lamports: apyData.total_rewards_lamports,
431
+ total_staked_lamports: apyData.total_staked_lamports,
432
+ validator: apyData.validator,
433
+ address: apyData.address,
434
+ note: apyData.note || null,
435
+ error: apyData.error || null,
436
+ rpc: opts.rpc,
437
+ cli_version: CLI_VERSION,
438
+ timestamp: new Date().toISOString(),
439
+ }, null, 2));
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Main
444
+ // ---------------------------------------------------------------------------
445
+
446
+ async function main() {
447
+ const opts = parseArgs();
448
+
449
+ try {
450
+ const apyData = await estimateApy(opts);
451
+
452
+ if (opts.asJson) {
453
+ outputJson(apyData, opts);
454
+ } else {
455
+ outputHuman(apyData, opts);
456
+ }
457
+ } catch (err) {
458
+ if (opts.asJson) {
459
+ console.log(JSON.stringify({
460
+ apy_pct: null,
461
+ apy: null,
462
+ error: err.message,
463
+ validator: opts.validator,
464
+ address: opts.address,
465
+ rpc: opts.rpc,
466
+ cli_version: CLI_VERSION,
467
+ timestamp: new Date().toISOString(),
468
+ }, null, 2));
469
+ } else {
470
+ console.log(`\n ${C.red}✗ APY calculation failed:${C.reset} ${err.message}`);
471
+ console.log(` ${C.dim} RPC: ${opts.rpc}${C.reset}`);
472
+ console.log(` ${C.dim} Is your validator running?${C.reset}\n`);
473
+ }
474
+ process.exit(1);
475
+ }
476
+ }
477
+
478
+ main();
479
+
480
+ module.exports = { apyCommand: main };