aether-hub 1.2.8 → 1.3.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,866 +1,479 @@
1
- #!/usr/bin/env node
2
- /**
3
- * aether-cli rewards
4
- *
5
- * View staking rewards earned from delegated stake accounts.
6
- * Shows accumulated rewards, estimated APY, and claimable amounts.
7
- *
8
- * Usage:
9
- * aether rewards list --address <addr> List all rewards per stake account
10
- * aether rewards list --address <addr> --json JSON output for scripting
11
- * aether rewards claim --address <addr> --account <stakeAcct> [--json]
12
- * aether rewards summary --address <addr> One-line summary of total rewards
13
- * aether rewards compound --address <addr> [--account <stakeAcct>] [--json] Claim and auto-re-stake
14
- *
15
- * Requires AETHER_RPC env var or local node running (default: http://127.0.0.1:8899)
16
- */
17
-
18
- const http = require('http');
19
- const https = require('https');
20
- const readline = require('readline');
21
- const crypto = require('crypto');
22
- const bs58 = require('bs58').default;
23
- const bip39 = require('bip39');
24
- const nacl = require('tweetnacl');
25
-
26
- // ANSI colours
27
- const C = {
28
- reset: '\x1b[0m',
29
- bright: '\x1b[1m',
30
- dim: '\x1b[2m',
31
- red: '\x1b[31m',
32
- green: '\x1b[32m',
33
- yellow: '\x1b[33m',
34
- cyan: '\x1b[36m',
35
- magenta: '\x1b[35m',
36
- };
37
-
38
- const DERIVATION_PATH = "m/44'/7777777'/0'/0'";
39
- const CLI_VERSION = '1.0.5';
40
-
41
- // ---------------------------------------------------------------------------
42
- // Paths & config
43
- // ---------------------------------------------------------------------------
44
-
45
- function getAetherDir() {
46
- return require('path').join(require('os').homedir(), '.aether');
47
- }
48
-
49
- function loadConfig() {
50
- const p = require('path').join(getAetherDir(), 'config.json');
51
- if (!require('fs').existsSync(p)) return { defaultWallet: null };
52
- try {
53
- return JSON.parse(require('fs').readFileSync(p, 'utf8'));
54
- } catch {
55
- return { defaultWallet: null };
56
- }
57
- }
58
-
59
- function loadWallet(address) {
60
- const fp = require('path').join(getAetherDir(), 'wallets', `${address}.json`);
61
- if (!require('fs').existsSync(fp)) return null;
62
- return JSON.parse(require('fs').readFileSync(fp, 'utf8'));
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Crypto helpers
67
- // ---------------------------------------------------------------------------
68
-
69
- function deriveKeypair(mnemonic) {
70
- if (!bip39.validateMnemonic(mnemonic)) throw new Error('Invalid mnemonic');
71
- const seedBuffer = bip39.mnemonicToSeedSync(mnemonic, '');
72
- const seed32 = seedBuffer.slice(0, 32);
73
- const keyPair = nacl.sign.keyPair.fromSeed(seed32);
74
- return { publicKey: Buffer.from(keyPair.publicKey), secretKey: Buffer.from(keyPair.secretKey) };
75
- }
76
-
77
- function formatAddress(publicKey) {
78
- return 'ATH' + bs58.encode(publicKey);
79
- }
80
-
81
- // ---------------------------------------------------------------------------
82
- // HTTP helpers
83
- // ---------------------------------------------------------------------------
84
-
85
- function httpRequest(rpcUrl, path) {
86
- return new Promise((resolve, reject) => {
87
- const url = new URL(path, rpcUrl);
88
- const lib = url.protocol === 'https:' ? https : http;
89
- const req = lib.request({
90
- hostname: url.hostname,
91
- port: url.port || (url.protocol === 'https:' ? 443 : 80),
92
- path: url.pathname + url.search,
93
- method: 'GET',
94
- timeout: 8000,
95
- headers: { 'Content-Type': 'application/json' },
96
- }, (res) => {
97
- let data = '';
98
- res.on('data', (chunk) => data += chunk);
99
- res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); } });
100
- });
101
- req.on('error', reject);
102
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
103
- req.end();
104
- });
105
- }
106
-
107
- function httpPost(rpcUrl, path, body) {
108
- return new Promise((resolve, reject) => {
109
- const url = new URL(path, rpcUrl);
110
- const lib = url.protocol === 'https:' ? https : http;
111
- const bodyStr = JSON.stringify(body);
112
- const req = lib.request({
113
- hostname: url.hostname,
114
- port: url.port || (url.protocol === 'https:' ? 443 : 80),
115
- path: url.pathname + url.search,
116
- method: 'POST',
117
- timeout: 15000,
118
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) },
119
- }, (res) => {
120
- let data = '';
121
- res.on('data', (chunk) => data += chunk);
122
- res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); } });
123
- });
124
- req.on('error', reject);
125
- req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
126
- req.write(bodyStr);
127
- req.end();
128
- });
129
- }
130
-
131
- function getDefaultRpc() {
132
- return process.env.AETHER_RPC || 'http://127.0.0.1:8899';
133
- }
134
-
135
- function formatAether(lamports) {
136
- const aeth = lamports / 1e9;
137
- if (aeth === 0) return '0 AETH';
138
- return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
139
- }
140
-
141
- function formatAethFull(lamports) {
142
- return (lamports / 1e9).toFixed(6) + ' AETH';
143
- }
144
-
145
- // ---------------------------------------------------------------------------
146
- // Rewards calculation helpers
147
- // ---------------------------------------------------------------------------
148
-
149
- /**
150
- * Fetch stake account info and compute rewards from epoch history.
151
- * Uses the stake account's delegated stake + activation/deactivation epochs
152
- * to estimate rewards accrued.
153
- */
154
- async function fetchStakeRewards(rpc, stakeAddress) {
155
- try {
156
- // Fetch stake account data
157
- const stakeData = await httpRequest(rpc, `/v1/stake-account/${stakeAddress}`);
158
- if (!stakeData || stakeData.error) {
159
- return { stakeAddress, error: stakeData.error || 'Failed to fetch stake account' };
160
- }
161
-
162
- const delegatedStake = BigInt(stakeData.delegated_stake || 0);
163
- const activationEpoch = stakeData.activation_epoch || 0;
164
- const deactivationEpoch = stakeData.deactivation_epoch || null;
165
- const stakeType = stakeData.stake_type || 'unknown';
166
-
167
- // Fetch epoch info for current epoch
168
- const epochInfo = await httpRequest(rpc, '/v1/epoch-info');
169
- const currentEpoch = epochInfo.epoch || 0;
170
- const rewardsPerEpoch = BigInt(epochInfo.rewards_per_epoch || '2000000000'); // default ~2 AETH
171
-
172
- // Calculate active epochs
173
- const activeFromEpoch = activationEpoch;
174
- const activeToEpoch = deactivationEpoch || currentEpoch;
175
- const activeEpochs = Math.max(0, activeToEpoch - activeFromEpoch);
176
-
177
- // Rewards accrue proportional to stake share (simplified — assumes network-wide pool)
178
- // APY is estimated from rewards_per_epoch vs total staked
179
- const totalNetworkStake = BigInt(epochInfo.total_staked || '1000000000000'); // fallback
180
- const rewardsRate = Number(rewardsPerEpoch * BigInt(365)) / Number(totalNetworkStake);
181
- const apyBps = Math.round(rewardsRate * 10000); // basis points
182
-
183
- // Compute estimated rewards accumulated
184
- const stakeAeth = Number(delegatedStake) / 1e9;
185
- const epochDuration = 432000; // seconds per epoch (approx 3.5 days on Aether)
186
- const yearEpochs = Math.round(31557600 / epochDuration); // ~73 epochs/year
187
- const estimatedAnnualRewards = stakeAeth * (apyBps / 10000);
188
- const estimatedRewards = (estimatedAnnualRewards / yearEpochs) * activeEpochs;
189
-
190
- return {
191
- stakeAddress,
192
- delegatedStake: delegatedStake.toString(),
193
- delegatedStakeFormatted: formatAether(delegatedStake),
194
- activationEpoch: activeFromEpoch,
195
- deactivationEpoch,
196
- isActive: deactivationEpoch === null,
197
- activeEpochs,
198
- estimatedRewards: Math.round(estimatedRewards * 1e9),
199
- estimatedRewardsFormatted: formatAether(Math.round(estimatedRewards * 1e9)),
200
- apyBps,
201
- stakeType,
202
- };
203
- } catch (err) {
204
- return { stakeAddress, error: err.message };
205
- }
206
- }
207
-
208
- /**
209
- * Fetch all stake accounts for a wallet address.
210
- * Returns array of stake account pubkeys from wallet's session data.
211
- */
212
- async function fetchWalletStakeAccounts(walletAddress) {
213
- const sessionsDir = require('path').join(getAetherDir(), 'sessions');
214
- if (!require('fs').existsSync(sessionsDir)) return [];
215
-
216
- const files = require('fs').readdirSync(sessionsDir).filter(f => f.endsWith('.json'));
217
- const stakeAccounts = [];
218
-
219
- for (const file of files) {
220
- try {
221
- const session = JSON.parse(require('fs').readFileSync(require('path').join(sessionsDir, file), 'utf8'));
222
- if (session.wallet_address === walletAddress && session.stake_account) {
223
- stakeAccounts.push(session.stake_account);
224
- }
225
- } catch {}
226
- }
227
-
228
- return stakeAccounts;
229
- }
230
-
231
- /**
232
- * Fetch wallet info from chain (account data)
233
- */
234
- async function fetchAccountInfo(rpc, address) {
235
- try {
236
- return await httpRequest(rpc, `/v1/account/${address}`);
237
- } catch {
238
- return null;
239
- }
240
- }
241
-
242
- // ---------------------------------------------------------------------------
243
- // Rewards list command
244
- // ---------------------------------------------------------------------------
245
-
246
- async function rewardsList(args) {
247
- const rpc = args.rpc || getDefaultRpc();
248
- const isJson = args.json || false;
249
- let address = args.address || null;
250
-
251
- // Interactive address prompt if not provided
252
- if (!address) {
253
- const config = loadConfig();
254
- const rl = createRl();
255
- const answer = await question(rl, `\n${C.cyan}Enter wallet address (or press Enter for default): ${C.reset}`);
256
- rl.close();
257
-
258
- if (!answer.trim()) {
259
- if (!config.defaultWallet) {
260
- console.log(`\n${C.red}✗ No default wallet and no address provided.${C.reset}`);
261
- console.log(` ${C.dim}Set a default wallet first: aether wallet default${C.reset}\n`);
262
- return;
263
- }
264
- address = config.defaultWallet;
265
- } else {
266
- address = answer.trim();
267
- }
268
- }
269
-
270
- // Validate address format (ATH...)
271
- if (!address.startsWith('ATH') || address.length < 30) {
272
- // Try loading from config if it looks like a nickname
273
- const config = loadConfig();
274
- if (config.defaultWallet) address = config.defaultWallet;
275
- }
276
-
277
- console.log(`\n${C.bright}${C.cyan}╔═══════════════════════════════════════════════════════╗${C.reset}`);
278
- console.log(`${C.bright}${C.cyan}║ Staking Rewards ${address.substring(0, 12)}... ║${C.reset}`);
279
- console.log(`${C.bright}${C.cyan}╚═══════════════════════════════════════════════════════╝${C.reset}\n`);
280
-
281
- // Fetch stake accounts for this wallet
282
- const stakeAccounts = await fetchWalletStakeAccounts(address);
283
-
284
- if (stakeAccounts.length === 0) {
285
- console.log(` ${C.yellow} No stake accounts found for this wallet.${C.reset}`);
286
- console.log(` ${C.dim}Stake AETH first: aether stake --address ${address} --validator <val> --amount <aeth>${C.reset}\n`);
287
- return;
288
- }
289
-
290
- // Fetch rewards for each stake account
291
- const rewardsResults = await Promise.all(
292
- stakeAccounts.map(sa => fetchStakeRewards(rpc, sa))
293
- );
294
-
295
- let totalEstimatedRewards = BigInt(0);
296
- let totalDelegatedStake = BigInt(0);
297
- let activeCount = 0;
298
- const rows = [];
299
-
300
- for (const result of rewardsResults) {
301
- if (result.error) {
302
- rows.push({ status: 'error', ...result });
303
- continue;
304
- }
305
-
306
- totalEstimatedRewards += BigInt(result.estimatedRewards);
307
- totalDelegatedStake += BigInt(result.delegatedStake);
308
- if (result.isActive) activeCount++;
309
-
310
- rows.push(result);
311
- }
312
-
313
- if (isJson) {
314
- console.log(JSON.stringify({
315
- address,
316
- totalEstimatedRewards: totalEstimatedRewards.toString(),
317
- totalEstimatedRewardsFormatted: formatAether(totalEstimatedRewards),
318
- totalDelegatedStake: totalDelegatedStake.toString(),
319
- totalDelegatedStakeFormatted: formatAether(totalDelegatedStake),
320
- activeStakeAccounts: activeCount,
321
- totalStakeAccounts: rows.length,
322
- stakeAccounts: rows,
323
- }, null, 2));
324
- return;
325
- }
326
-
327
- // ASCII table header
328
- console.log(` ${C.dim}┌─────────────────────────────────────────────────────────────┐${C.reset}`);
329
- console.log(` ${C.dim}│${C.reset} ${C.bright}Stake Account${C.reset} ${C.bright}Delegated${C.reset} ${C.bright}Est. Rewards${C.reset} ${C.bright}APY${C.reset} ${C.bright}Status${C.reset} ${C.dim}│${C.reset}`);
330
- console.log(` ${C.dim}├─────────────────────────────────────────────────────────────┤${C.reset}`);
331
-
332
- for (const r of rows) {
333
- const shortAddr = r.stakeAddress ? r.stakeAddress.substring(0, 14) + '...' : 'unknown';
334
- const delegated = r.delegatedStakeFormatted || '—';
335
- const estRew = r.estimatedRewardsFormatted || '—';
336
- const apy = r.apyBps ? `${(r.apyBps / 100).toFixed(2)}%` : '—';
337
- const status = r.isActive
338
- ? `${C.green}● Active${C.reset}`
339
- : r.deactivationEpoch
340
- ? `${C.yellow}○ Deactivated${C.reset}`
341
- : `${C.red}✗ Error${C.reset}`;
342
- const statusColor = r.isActive ? C.green : r.deactivationEpoch ? C.yellow : C.red;
343
-
344
- console.log(
345
- ` ${C.dim}│${C.reset} ${shortAddr.padEnd(20)} ${delegated.padEnd(13)} ${estRew.padEnd(15)} ${apy.padEnd(7)} ${statusColor}${r.isActive ? '● Active' : r.deactivationEpoch ? '○ Deact.' : '✗ Err'}${C.reset} ${C.dim}│${C.reset}`
346
- );
347
- }
348
-
349
- console.log(` ${C.dim}└─────────────────────────────────────────────────────────────┘${C.reset}`);
350
- console.log();
351
- console.log(` ${C.bright}Total Delegated:${C.reset} ${C.cyan}${formatAether(totalDelegatedStake)}${C.reset}`);
352
- console.log(` ${C.bright}Total Est. Rewards:${C.reset} ${C.green}${formatAether(totalEstimatedRewards)}${C.reset}`);
353
- console.log(` ${C.bright}Active Accounts:${C.reset} ${activeCount} of ${rows.length}`);
354
- console.log();
355
- console.log(` ${C.dim}Run "aether rewards claim --address ${address}" to claim unclaimed rewards.${C.reset}\n`);
356
- }
357
-
358
- // ---------------------------------------------------------------------------
359
- // Rewards summary command (one-line)
360
- // ---------------------------------------------------------------------------
361
-
362
- async function rewardsSummary(args) {
363
- const rpc = args.rpc || getDefaultRpc();
364
- let address = args.address || null;
365
-
366
- if (!address) {
367
- const config = loadConfig();
368
- if (!config.defaultWallet) {
369
- console.log(`${C.red}✗ No default wallet and no address provided.${C.reset}`);
370
- return;
371
- }
372
- address = config.defaultWallet;
373
- }
374
-
375
- const stakeAccounts = await fetchWalletStakeAccounts(address);
376
- if (stakeAccounts.length === 0) {
377
- console.log(`${C.yellow}⚠ No stake accounts for ${address.substring(0, 12)}...${C.reset}`);
378
- return;
379
- }
380
-
381
- const results = await Promise.all(stakeAccounts.map(sa => fetchStakeRewards(rpc, sa)));
382
- let totalRewards = BigInt(0);
383
- let totalStake = BigInt(0);
384
- let activeCount = 0;
385
-
386
- for (const r of results) {
387
- if (!r.error) {
388
- totalRewards += BigInt(r.estimatedRewards);
389
- totalStake += BigInt(r.delegatedStake);
390
- if (r.isActive) activeCount++;
391
- }
392
- }
393
-
394
- console.log(`${C.cyan}${address.substring(0, 12)}...${C.reset} │ Stake: ${C.cyan}${formatAether(totalStake)}${C.reset} │ Est.Rewards: ${C.green}${formatAether(totalRewards)}${C.reset} │ Active: ${activeCount}/${results.length}`);
395
- }
396
-
397
- // ---------------------------------------------------------------------------
398
- // Rewards claim command
399
- // ---------------------------------------------------------------------------
400
-
401
- async function rewardsPending(args) {
402
- const rpc = args.rpc || getDefaultRpc();
403
- const isJson = args.json || false;
404
- let address = args.address || null;
405
-
406
- const config = loadConfig();
407
- const rl = createRl();
408
-
409
- if (!address) {
410
- const ans = await question(rl, `\n${C.cyan}Enter wallet address: ${C.reset}`);
411
- address = ans.trim();
412
- }
413
-
414
- if (!address) {
415
- console.log(`\n${C.red}✗ No address provided.${C.reset}\n`);
416
- rl.close();
417
- return;
418
- }
419
-
420
- rl.close();
421
-
422
- const stakeAccounts = await fetchWalletStakeAccounts(address);
423
- if (stakeAccounts.length === 0) {
424
- if (isJson) {
425
- console.log(JSON.stringify({ address, pending: [], total_pending: '0' }, null, 2));
426
- } else {
427
- console.log(`\n${C.red}✗ No stake accounts found for ${address}${C.reset}\n`);
428
- }
429
- return;
430
- }
431
-
432
- const results = [];
433
- let totalPending = BigInt(0);
434
-
435
- for (const sa of stakeAccounts) {
436
- const rd = await fetchStakeRewards(rpc, sa);
437
- if (!rd.error) {
438
- const pending = BigInt(rd.estimatedRewards || 0);
439
- totalPending += pending;
440
- results.push({
441
- stake_account: sa,
442
- validator: rd.validator || 'unknown',
443
- delegated_stake: rd.delegatedStakeFormatted || '0',
444
- pending_rewards: rd.estimatedRewardsFormatted || '0',
445
- pending_lamports: pending.toString(),
446
- apy_bps: rd.apyBps || 0,
447
- });
448
- }
449
- }
450
-
451
- if (isJson) {
452
- console.log(JSON.stringify({
453
- address,
454
- total_pending: totalPending.toString(),
455
- total_pending_formatted: formatAether(totalPending.toString()),
456
- accounts: results,
457
- }, null, 2));
458
- return;
459
- }
460
-
461
- console.log(`\n${C.bright}${C.cyan}╔══════════════════════════════════════════════════════════════╗${C.reset}`);
462
- console.log(`${C.bright}${C.cyan}║ Pending Staking Rewards ║${C.reset}`);
463
- console.log(`${C.bright}${C.cyan}╚══════════════════════════════════════════════════════════════╝${C.reset}\n`);
464
- console.log(` ${C.dim}Wallet:${C.reset} ${C.bright}${address}${C.reset}`);
465
- console.log();
466
- console.log(` ${C.yellow}Stake Account${C.reset.padEnd(48)} ${C.yellow}Pending${C.reset} ${C.yellow}APY${C.reset}`);
467
- console.log(` ${C.dim}${'─'.repeat(72)}${C.reset}`);
468
-
469
- for (const r of results) {
470
- const shortSa = r.stake_account.substring(0, 12) + '...' + r.stake_account.slice(-6);
471
- console.log(` ${C.cyan}${shortSa}${C.reset.padEnd(52)} ${C.green}${r.pending_rewards.padStart(12)}${C.reset} ${(r.apy_bps / 100).toFixed(2)}%`);
472
- }
473
-
474
- console.log(` ${C.dim}${'─'.repeat(72)}${C.reset}`);
475
- console.log(` ${C.bright}TOTAL PENDING${C.reset.padEnd(52)} ${C.green}${formatAethFull(totalPending.toString()).padStart(12)}${C.reset}`);
476
- console.log();
477
- console.log(` ${C.dim}Run ${C.cyan}aether rewards claim${C.dim} to claim.${C.reset}\n`);
478
- }
479
-
480
- async function rewardsClaim(args) {
481
- const rpc = args.rpc || getDefaultRpc();
482
- const isJson = args.json || false;
483
- let address = args.address || null;
484
- let stakeAccount = args.account || null;
485
-
486
- const config = loadConfig();
487
- const rl = createRl();
488
-
489
- if (!address) {
490
- const ans = await question(rl, `\n${C.cyan}Enter wallet address: ${C.reset}`);
491
- address = ans.trim();
492
- }
493
-
494
- if (!stakeAccount) {
495
- const stakeAccounts = await fetchWalletStakeAccounts(address);
496
- if (stakeAccounts.length === 0) {
497
- console.log(`\n${C.red}✗ No stake accounts found for this wallet.${C.reset}\n`);
498
- rl.close();
499
- return;
500
- }
501
- if (stakeAccounts.length === 1) {
502
- stakeAccount = stakeAccounts[0];
503
- } else {
504
- console.log(`\n${C.cyan}Select stake account:${C.reset}`);
505
- stakeAccounts.forEach((sa, i) => {
506
- console.log(` ${i + 1}) ${sa.substring(0, 20)}...`);
507
- });
508
- const ans = await question(rl, `${C.cyan}Enter number: ${C.reset}`);
509
- const idx = parseInt(ans.trim()) - 1;
510
- if (idx < 0 || idx >= stakeAccounts.length) {
511
- console.log(`\n${C.red}Invalid selection.${C.reset}\n`);
512
- rl.close();
513
- return;
514
- }
515
- stakeAccount = stakeAccounts[idx];
516
- }
517
- }
518
-
519
- rl.close();
520
-
521
- // Load wallet for signing
522
- const wallet = loadWallet(address);
523
- if (!wallet) {
524
- console.log(`\n${C.red}✗ Wallet not found locally: ${address}${C.reset}`);
525
- console.log(` ${C.dim}Import it: aether wallet import${C.reset}\n`);
526
- return;
527
- }
528
-
529
- console.log(`\n${C.bright}${C.cyan}╔════════════════════════════════════════╗${C.reset}`);
530
- console.log(`${C.bright}${C.cyan}║ Claim Staking Rewards ║${C.reset}`);
531
- console.log(`${C.bright}${C.cyan}╚════════════════════════════════════════╝${C.reset}\n`);
532
- console.log(` ${C.dim}Wallet:${C.reset} ${address.substring(0, 16)}...`);
533
- console.log(` ${C.dim}Stake Account:${C.reset} ${stakeAccount.substring(0, 16)}...`);
534
-
535
- // Fetch current rewards for this stake account
536
- const rewardData = await fetchStakeRewards(rpc, stakeAccount);
537
- if (rewardData.error) {
538
- console.log(`\n${C.red}✗ Failed to fetch stake account: ${rewardData.error}${C.reset}\n`);
539
- return;
540
- }
541
-
542
- console.log(` ${C.dim}Delegated Stake:${C.reset} ${rewardData.delegatedStakeFormatted}`);
543
- console.log(` ${C.dim}Est. Accumulated:${C.reset} ${rewardData.estimatedRewardsFormatted}`);
544
- console.log(` ${C.dim}APY:${C.reset} ${(rewardData.apyBps / 100).toFixed(2)}%`);
545
-
546
- const estimatedRewards = rewardData.estimatedRewards;
547
- if (BigInt(estimatedRewards) === BigInt(0)) {
548
- console.log(`\n${C.yellow}⚠ No rewards accumulated yet.${C.reset}\n`);
549
- return;
550
- }
551
-
552
- const confirm = await question(rl, `\n ${C.yellow}Claim ${rewardData.estimatedRewardsFormatted}? [y/N]${C.reset} > `);
553
- if (confirm.trim().toLowerCase() !== 'y') {
554
- console.log(`${C.dim}Cancelled.${C.reset}\n`);
555
- return;
556
- }
557
-
558
- // Derive keypair from mnemonic for signing
559
- let keypair;
560
- try {
561
- const mnemonic = wallet.mnemonic;
562
- keypair = deriveKeypair(mnemonic);
563
- } catch (err) {
564
- console.log(`\n${C.red}✗ Failed to derive keypair: ${err.message}${C.reset}\n`);
565
- return;
566
- }
567
-
568
- // Build claim transaction
569
- const tx = {
570
- type: 'ClaimRewards',
571
- from: address,
572
- stake_account: stakeAccount,
573
- lamports: estimatedRewards,
574
- timestamp: Math.floor(Date.now() / 1000),
575
- };
576
-
577
- // Sign transaction
578
- const txData = JSON.stringify(tx);
579
- const txHash = crypto.createHash('sha256').update(txData).digest('hex');
580
- const signature = nacl.hash(Buffer.from(txHash, 'hex'));
581
- const signatureB58 = bs58.encode(signature.slice(0, 64));
582
-
583
- tx.signature = signatureB58;
584
-
585
- // Submit transaction
586
- try {
587
- const result = await httpPost(rpc, '/v1/tx', tx);
588
-
589
- if (isJson) {
590
- console.log(JSON.stringify({ success: true, tx: tx, result }, null, 2));
591
- } else {
592
- if (result.success || result.txid) {
593
- console.log(`\n${C.green}✓ Rewards claimed successfully!${C.reset}`);
594
- console.log(` ${C.dim}TX ID: ${result.txid || signatureB58.substring(0, 20)}...${C.reset}`);
595
- console.log(` ${C.dim}Amount: ${rewardData.estimatedRewardsFormatted}${C.reset}`);
596
- console.log(` ${C.dim}Check balance: aether wallet balance --address ${address}${C.reset}\n`);
597
- } else {
598
- console.log(`\n${C.red}✗ Claim failed: ${result.error || JSON.stringify(result)}${C.reset}\n`);
599
- }
600
- }
601
- } catch (err) {
602
- if (isJson) {
603
- console.log(JSON.stringify({ success: false, error: err.message }, null, 2));
604
- } else {
605
- console.log(`\n${C.red}✗ Failed to submit claim transaction: ${err.message}${C.reset}`);
606
- console.log(` ${C.dim}The rewards are accumulated on-chain and can be claimed later.${C.reset}\n`);
607
- }
608
- }
609
- }
610
-
611
- // ---------------------------------------------------------------------------
612
- // Rewards compound command — claim and auto-re-stake
613
- // ---------------------------------------------------------------------------
614
-
615
- async function rewardsCompound(args) {
616
- const rpc = args.rpc || getDefaultRpc();
617
- const isJson = args.json || false;
618
- let address = args.address || null;
619
- let stakeAccount = args.account || null;
620
-
621
- const config = loadConfig();
622
- const rl = createRl();
623
-
624
- if (!address) {
625
- const ans = await question(rl, `\n${C.cyan}Enter wallet address: ${C.reset}`);
626
- address = ans.trim();
627
- }
628
-
629
- if (!address) {
630
- console.log(`\n${C.red}✗ No address provided.${C.reset}\n`);
631
- rl.close();
632
- return;
633
- }
634
-
635
- // Load wallet for signing
636
- const wallet = loadWallet(address);
637
- if (!wallet) {
638
- console.log(`\n${C.red}✗ Wallet not found locally: ${address}${C.reset}`);
639
- console.log(` ${C.dim}Import it: aether wallet import${C.reset}\n`);
640
- rl.close();
641
- return;
642
- }
643
-
644
- // Fetch stake accounts
645
- let stakeAccounts = await fetchWalletStakeAccounts(address);
646
- if (stakeAccounts.length === 0) {
647
- console.log(`\n${C.red}✗ No stake accounts found for this wallet.${C.reset}\n`);
648
- rl.close();
649
- return;
650
- }
651
-
652
- // If --account specified, filter to that one
653
- if (stakeAccount) {
654
- stakeAccounts = stakeAccounts.filter(sa => sa === stakeAccount);
655
- if (stakeAccounts.length === 0) {
656
- console.log(`\n${C.red}✗ Stake account not found: ${stakeAccount}${C.reset}\n`);
657
- rl.close();
658
- return;
659
- }
660
- }
661
-
662
- console.log(`\n${C.bright}${C.cyan}╔══════════════════════════════════════════════════════════════╗${C.reset}`);
663
- console.log(`${C.bright}${C.cyan}║ Compound Staking Rewards ║${C.reset}`);
664
- console.log(`${C.bright}${C.cyan}╚══════════════════════════════════════════════════════════════╝${C.reset}\n`);
665
- console.log(` ${C.dim}Wallet:${C.reset} ${C.bright}${address}${C.reset}`);
666
- console.log(` ${C.dim}RPC:${C.reset} ${rpc}`);
667
- console.log(` ${C.dim}Stake accounts to process:${C.reset} ${stakeAccounts.length}\n`);
668
-
669
- // Ask for mnemonic upfront
670
- console.log(`${C.yellow} ⚠ Compound requires your wallet passphrase to sign transactions.${C.reset}`);
671
- const mnemonic = await question(rl, ` ${C.cyan}Enter your 12/24-word mnemonic:${C.reset} `);
672
- console.log();
673
-
674
- let keypair;
675
- try {
676
- if (!bip39.validateMnemonic(mnemonic)) {
677
- throw new Error('Invalid BIP39 mnemonic');
678
- }
679
- keypair = deriveKeypair(mnemonic);
680
- } catch (err) {
681
- console.log(` ${C.red}✗ Failed to derive keypair: ${err.message}${C.reset}\n`);
682
- rl.close();
683
- return;
684
- }
685
-
686
- // Verify address matches
687
- const derivedAddress = formatAddress(keypair.publicKey);
688
- if (derivedAddress !== address) {
689
- console.log(` ${C.red}✗ Passphrase mismatch.${C.reset}`);
690
- console.log(` ${C.dim} Derived: ${derivedAddress}${C.reset}`);
691
- console.log(` ${C.dim} Expected: ${address}${C.reset}`);
692
- console.log(` ${C.dim}Check your passphrase and try again.${C.reset}\n`);
693
- rl.close();
694
- return;
695
- }
696
-
697
- const compoundResults = [];
698
- let totalCompounded = BigInt(0);
699
- let successCount = 0;
700
-
701
- for (const sa of stakeAccounts) {
702
- console.log(` ${C.dim}Processing stake account:${C.reset} ${sa.substring(0, 20)}...`);
703
-
704
- try {
705
- // Fetch rewards for this stake account
706
- const rewardData = await fetchStakeRewards(rpc, sa);
707
- if (rewardData.error) {
708
- console.log(` ${C.red}✗ Failed to fetch rewards: ${rewardData.error}${C.reset}`);
709
- compoundResults.push({ stake_account: sa, status: 'error', error: rewardData.error });
710
- continue;
711
- }
712
-
713
- const estimatedRewards = BigInt(rewardData.estimatedRewards);
714
- if (estimatedRewards === BigInt(0)) {
715
- console.log(` ${C.yellow}⚠ No rewards to compound${C.reset}`);
716
- compoundResults.push({ stake_account: sa, status: 'no_rewards', rewards: '0' });
717
- continue;
718
- }
719
-
720
- console.log(` ${C.dim}Rewards to compound:${C.reset} ${rewardData.estimatedRewardsFormatted}`);
721
- console.log(` ${C.dim}Validator:${C.reset} ${rewardData.validator || 'unknown'}`);
722
-
723
- // Build compound transaction (ClaimRewards + Stake in one)
724
- const tx = {
725
- type: 'CompoundRewards',
726
- from: address,
727
- stake_account: sa,
728
- lamports: estimatedRewards.toString(),
729
- validator: rewardData.validator || null,
730
- timestamp: Math.floor(Date.now() / 1000),
731
- };
732
-
733
- // Sign transaction
734
- const txData = JSON.stringify(tx);
735
- const txHash = crypto.createHash('sha256').update(txData).digest('hex');
736
- const signature = nacl.hash(Buffer.from(txHash, 'hex'));
737
- const signatureB58 = bs58.encode(signature.slice(0, 64));
738
- tx.signature = signatureB58;
739
-
740
- // Submit transaction
741
- const result = await httpPost(rpc, '/v1/tx', tx);
742
-
743
- if (result.success || result.txid || result.signature) {
744
- console.log(` ${C.green}✓ Compounded successfully${C.reset}`);
745
- console.log(` ${C.dim}TX: ${(result.txid || result.signature || signatureB58).substring(0, 20)}...${C.reset}`);
746
- totalCompounded += estimatedRewards;
747
- successCount++;
748
- compoundResults.push({
749
- stake_account: sa,
750
- status: 'compounded',
751
- rewards: estimatedRewards.toString(),
752
- rewards_formatted: rewardData.estimatedRewardsFormatted,
753
- tx: result.txid || result.signature || signatureB58,
754
- });
755
- } else {
756
- console.log(` ${C.red}✗ Compound failed: ${result.error || JSON.stringify(result)}${C.reset}`);
757
- compoundResults.push({ stake_account: sa, status: 'failed', error: result.error });
758
- }
759
- } catch (err) {
760
- console.log(` ${C.red}✗ Error: ${err.message}${C.reset}`);
761
- compoundResults.push({ stake_account: sa, status: 'error', error: err.message });
762
- }
763
-
764
- console.log();
765
- }
766
-
767
- rl.close();
768
-
769
- // Summary
770
- console.log(`${C.bright}${C.cyan}╔══════════════════════════════════════════════════════════════╗${C.reset}`);
771
- console.log(`${C.bright}${C.cyan}║ Compound Summary ║${C.reset}`);
772
- console.log(`${C.bright}${C.cyan}╚══════════════════════════════════════════════════════════════╝${C.reset}\n`);
773
- console.log(` ${C.dim}Accounts processed:${C.reset} ${stakeAccounts.length}`);
774
- console.log(` ${C.green}✓ Successful:${C.reset} ${successCount}`);
775
- console.log(` ${C.dim}Total compounded:${C.reset} ${C.green}${formatAether(totalCompounded.toString())}${C.reset}\n`);
776
-
777
- if (isJson) {
778
- console.log(JSON.stringify({
779
- address,
780
- total_compounded_lamports: totalCompounded.toString(),
781
- total_compounded_formatted: formatAether(totalCompounded.toString()),
782
- accounts_processed: stakeAccounts.length,
783
- successful: successCount,
784
- results: compoundResults,
785
- }, null, 2));
786
- }
787
- }
788
-
789
- // ---------------------------------------------------------------------------
790
- // Parse CLI args
791
- // ---------------------------------------------------------------------------
792
-
793
- function parseArgs() {
794
- const args = process.argv.slice(3); // [node, index.js, rewards, <subcmd>, ...]
795
- return args;
796
- }
797
-
798
- function createRl() {
799
- return readline.createInterface({ input: process.stdin, output: process.stdout });
800
- }
801
-
802
- function question(rl, q) {
803
- return new Promise((res) => rl.question(q, res));
804
- }
805
-
806
- // ---------------------------------------------------------------------------
807
- // Main entry point
808
- // ---------------------------------------------------------------------------
809
-
810
- async function main() {
811
- const rawArgs = parseArgs();
812
- const subcmd = rawArgs[0] || 'list';
813
-
814
- // Parse common flags from all args
815
- const allArgs = rawArgs.slice(1);
816
- const rpcIndex = allArgs.findIndex(a => a === '--rpc');
817
- const rpc = rpcIndex !== -1 ? allArgs[rpcIndex + 1] : getDefaultRpc();
818
-
819
- const parsed = {
820
- rpc,
821
- json: allArgs.includes('--json'),
822
- address: null,
823
- account: null,
824
- };
825
-
826
- // Extract --address and --account flags
827
- const addrIdx = allArgs.findIndex(a => a === '--address');
828
- if (addrIdx !== -1 && allArgs[addrIdx + 1]) parsed.address = allArgs[addrIdx + 1];
829
-
830
- const acctIdx = allArgs.findIndex(a => a === '--account');
831
- if (acctIdx !== -1 && allArgs[acctIdx + 1]) parsed.account = allArgs[acctIdx + 1];
832
-
833
- switch (subcmd) {
834
- case 'list':
835
- await rewardsList(parsed);
836
- break;
837
- case 'summary':
838
- await rewardsSummary(parsed);
839
- break;
840
- case 'pending':
841
- await rewardsPending(parsed);
842
- break;
843
- case 'claim':
844
- await rewardsClaim(parsed);
845
- break;
846
- case 'compound':
847
- await rewardsCompound(parsed);
848
- break;
849
- default:
850
- console.log(`\n${C.cyan}Usage:${C.reset}`);
851
- console.log(` aether rewards list --address <addr> List all staking rewards`);
852
- console.log(` aether rewards summary --address <addr> One-line rewards summary`);
853
- console.log(` aether rewards pending --address <addr> Show pending (unclaimed) rewards`);
854
- console.log(` aether rewards claim --address <addr> [--account <stakeAcct>] Claim rewards`);
855
- console.log(` aether rewards compound --address <addr> [--account <stakeAcct>] Claim and re-stake rewards`);
856
- console.log();
857
- console.log(` ${C.dim}--json Output as JSON`);
858
- console.log(` --rpc <url> Use specific RPC endpoint${C.reset}\n`);
859
- }
860
- }
861
-
862
- main().catch(err => {
863
- console.error(`\n${C.red}Error running rewards command:${C.reset}`, err.message, '\n');
864
- });
865
-
866
- module.exports = { rewardsCommand: main };
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli rewards
4
+ *
5
+ * View staking rewards earned from delegated stake accounts.
6
+ * Shows accumulated rewards, estimated APY, and claimable amounts.
7
+ * Uses @jellylegsai/aether-sdk for REAL blockchain RPC calls.
8
+ *
9
+ * Usage:
10
+ * aether rewards list --address <addr> List all rewards per stake account
11
+ * aether rewards list --address <addr> --json JSON output for scripting
12
+ * aether rewards summary --address <addr> One-line summary of total rewards
13
+ * aether rewards pending --address <addr> Show pending (unclaimed) rewards
14
+ *
15
+ * Requires AETHER_RPC env var or local node running (default: http://127.0.0.1:8899)
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const readline = require('readline');
22
+
23
+ // Import SDK for real blockchain RPC calls
24
+ const sdkPath = path.join(__dirname, '..', 'sdk', 'index.js');
25
+ const { AetherClient } = require(sdkPath);
26
+
27
+ // Import theme
28
+ const theme = require('../theme');
29
+ const { C, BANNERS, ICONS } = theme;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Paths & config
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function getAetherDir() {
36
+ return path.join(os.homedir(), '.aether');
37
+ }
38
+
39
+ function loadConfig() {
40
+ const p = path.join(getAetherDir(), 'config.json');
41
+ if (!fs.existsSync(p)) return { defaultWallet: null };
42
+ try {
43
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
44
+ } catch {
45
+ return { defaultWallet: null };
46
+ }
47
+ }
48
+
49
+ function loadWallet(address) {
50
+ const fp = path.join(getAetherDir(), 'wallets', `${address}.json`);
51
+ if (!fs.existsSync(fp)) return null;
52
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // SDK helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function getDefaultRpc() {
60
+ return process.env.AETHER_RPC || 'http://127.0.0.1:8899';
61
+ }
62
+
63
+ function createClient(rpcUrl) {
64
+ return new AetherClient({ rpcUrl });
65
+ }
66
+
67
+ function formatAether(lamports) {
68
+ const aeth = lamports / 1e9;
69
+ if (aeth === 0) return '0 AETH';
70
+ return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
71
+ }
72
+
73
+ function formatAethFull(lamports) {
74
+ return (lamports / 1e9).toFixed(6) + ' AETH';
75
+ }
76
+
77
+ function shortAddress(addr) {
78
+ if (!addr || addr.length < 16) return addr || 'unknown';
79
+ return addr.slice(0, 8) + '...' + addr.slice(-8);
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Rewards calculation using SDK
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Fetch stake account info and compute rewards using SDK.
88
+ * Uses real RPC calls to get stake positions and rewards data.
89
+ */
90
+ async function fetchStakeRewards(rpc, stakeAddress) {
91
+ const client = createClient(rpc);
92
+ try {
93
+ // Fetch stake positions using SDK
94
+ const stakeData = await client.getStakePositions(stakeAddress);
95
+
96
+ // Get epoch info for APY calculation
97
+ const epochInfo = await client.getEpochInfo();
98
+ const currentEpoch = epochInfo.epoch || 0;
99
+
100
+ // Calculate rewards from stake data
101
+ let delegatedStake = BigInt(0);
102
+ let activationEpoch = 0;
103
+ let deactivationEpoch = null;
104
+ let pendingRewards = BigInt(0);
105
+ let validator = 'unknown';
106
+
107
+ if (Array.isArray(stakeData)) {
108
+ stakeData.forEach(stake => {
109
+ delegatedStake += BigInt(stake.lamports || stake.delegated_stake || 0);
110
+ pendingRewards += BigInt(stake.pending_rewards || stake.rewards || 0);
111
+ if (stake.validator) validator = stake.validator;
112
+ if (stake.activation_epoch) activationEpoch = stake.activation_epoch;
113
+ if (stake.deactivation_epoch) deactivationEpoch = stake.deactivation_epoch;
114
+ });
115
+ } else if (stakeData) {
116
+ delegatedStake = BigInt(stakeData.lamports || stakeData.delegated_stake || 0);
117
+ pendingRewards = BigInt(stakeData.pending_rewards || stakeData.rewards || 0);
118
+ validator = stakeData.validator || 'unknown';
119
+ activationEpoch = stakeData.activation_epoch || 0;
120
+ deactivationEpoch = stakeData.deactivation_epoch || null;
121
+ }
122
+
123
+ // Get rewards for APY calculation
124
+ const rewardsInfo = await client.getRewards(stakeAddress);
125
+ const rewardsPerEpoch = BigInt(rewardsInfo.rewards_per_epoch || '2000000000');
126
+
127
+ // Calculate active epochs
128
+ const activeFromEpoch = activationEpoch;
129
+ const activeToEpoch = deactivationEpoch || currentEpoch;
130
+ const activeEpochs = Math.max(0, activeToEpoch - activeFromEpoch);
131
+
132
+ // APY calculation
133
+ const totalNetworkStake = BigInt(epochInfo.total_staked || '1000000000000');
134
+ const rewardsRate = Number(rewardsPerEpoch * BigInt(365)) / Number(totalNetworkStake);
135
+ const apyBps = Math.round(rewardsRate * 10000);
136
+
137
+ // Estimated rewards
138
+ const stakeAeth = Number(delegatedStake) / 1e9;
139
+ const yearEpochs = 73; // ~73 epochs/year
140
+ const estimatedAnnualRewards = stakeAeth * (apyBps / 10000);
141
+ const estimatedRewards = (estimatedAnnualRewards / yearEpochs) * activeEpochs;
142
+
143
+ return {
144
+ stakeAddress,
145
+ delegatedStake: delegatedStake.toString(),
146
+ delegatedStakeFormatted: formatAether(Number(delegatedStake)),
147
+ activationEpoch: activeFromEpoch,
148
+ deactivationEpoch,
149
+ isActive: deactivationEpoch === null,
150
+ activeEpochs,
151
+ estimatedRewards: Math.round(estimatedRewards * 1e9),
152
+ estimatedRewardsFormatted: formatAether(Math.round(estimatedRewards * 1e9)),
153
+ pendingRewards: pendingRewards.toString(),
154
+ apyBps,
155
+ validator,
156
+ };
157
+ } catch (err) {
158
+ return { stakeAddress, error: err.message };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Fetch all stake accounts for a wallet address using SDK.
164
+ */
165
+ async function fetchWalletStakeAccounts(rpc, walletAddress) {
166
+ const client = createClient(rpc);
167
+ try {
168
+ const stakeAccounts = await client.getStakeAccounts(walletAddress);
169
+ if (Array.isArray(stakeAccounts)) {
170
+ return stakeAccounts.map(acc => acc.pubkey || acc.address || acc);
171
+ }
172
+ return [];
173
+ } catch {
174
+ return [];
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Rewards list command
180
+ // ---------------------------------------------------------------------------
181
+
182
+ async function rewardsList(args) {
183
+ const rpc = args.rpc || getDefaultRpc();
184
+ const isJson = args.json || false;
185
+ let address = args.address || null;
186
+
187
+ // Interactive address prompt if not provided
188
+ if (!address) {
189
+ const config = loadConfig();
190
+ const rl = createRl();
191
+ const answer = await question(rl, `\n${C.cyan}Enter wallet address (or press Enter for default): ${C.reset}`);
192
+ rl.close();
193
+
194
+ if (!answer.trim()) {
195
+ if (!config.defaultWallet) {
196
+ console.log(`\n${ICONS.error} ${C.red}No default wallet and no address provided.${C.reset}`);
197
+ console.log(` ${C.dim}Set a default wallet first: aether wallet default${C.reset}\n`);
198
+ return;
199
+ }
200
+ address = config.defaultWallet;
201
+ } else {
202
+ address = answer.trim();
203
+ }
204
+ }
205
+
206
+ // Validate address format (ATH...)
207
+ if (!address.startsWith('ATH') || address.length < 30) {
208
+ const config = loadConfig();
209
+ if (config.defaultWallet) address = config.defaultWallet;
210
+ }
211
+
212
+ console.log(`\n${BANNERS.rewards}`);
213
+ console.log(` ${C.dim}Address:${C.reset} ${address}\n`);
214
+
215
+ // Fetch stake accounts using SDK
216
+ const stakeAccounts = await fetchWalletStakeAccounts(rpc, address);
217
+
218
+ if (stakeAccounts.length === 0) {
219
+ console.log(` ${ICONS.warning} ${C.yellow}No stake accounts found for this wallet.${C.reset}`);
220
+ console.log(` ${C.dim}Stake AETH first: aether stake --address ${address} --validator <val> --amount <aeth>${C.reset}\n`);
221
+ return;
222
+ }
223
+
224
+ // Fetch rewards for each stake account using SDK
225
+ const rewardsResults = await Promise.all(
226
+ stakeAccounts.map(sa => fetchStakeRewards(rpc, sa))
227
+ );
228
+
229
+ let totalEstimatedRewards = BigInt(0);
230
+ let totalDelegatedStake = BigInt(0);
231
+ let activeCount = 0;
232
+ const rows = [];
233
+
234
+ for (const result of rewardsResults) {
235
+ if (result.error) {
236
+ rows.push({ status: 'error', ...result });
237
+ continue;
238
+ }
239
+
240
+ totalEstimatedRewards += BigInt(result.estimatedRewards);
241
+ totalDelegatedStake += BigInt(result.delegatedStake);
242
+ if (result.isActive) activeCount++;
243
+
244
+ rows.push(result);
245
+ }
246
+
247
+ if (isJson) {
248
+ console.log(JSON.stringify({
249
+ address,
250
+ totalEstimatedRewards: totalEstimatedRewards.toString(),
251
+ totalEstimatedRewardsFormatted: formatAether(Number(totalEstimatedRewards)),
252
+ totalDelegatedStake: totalDelegatedStake.toString(),
253
+ totalDelegatedStakeFormatted: formatAether(Number(totalDelegatedStake)),
254
+ activeStakeAccounts: activeCount,
255
+ totalStakeAccounts: rows.length,
256
+ stakeAccounts: rows,
257
+ }, null, 2));
258
+ return;
259
+ }
260
+
261
+ // ASCII table header
262
+ console.log(` ${C.dim}┌─────────────────────────────────────────────────────────────┐${C.reset}`);
263
+ console.log(` ${C.dim}│${C.reset} ${C.bright}Stake Account${C.reset} ${C.bright}Delegated${C.reset} ${C.bright}Est. Rewards${C.reset} ${C.bright}APY${C.reset} ${C.bright}Status${C.reset} ${C.dim}│${C.reset}`);
264
+ console.log(` ${C.dim}├─────────────────────────────────────────────────────────────┤${C.reset}`);
265
+
266
+ for (const r of rows) {
267
+ const shortAddr = r.stakeAddress ? r.stakeAddress.substring(0, 14) + '...' : 'unknown';
268
+ const delegated = r.delegatedStakeFormatted || '—';
269
+ const estRew = r.estimatedRewardsFormatted || '—';
270
+ const apy = r.apyBps ? `${(r.apyBps / 100).toFixed(2)}%` : '—';
271
+ const status = r.isActive
272
+ ? `${C.green}● Active${C.reset}`
273
+ : r.deactivationEpoch
274
+ ? `${C.yellow}○ Deactivated${C.reset}`
275
+ : `${C.red}✗ Error${C.reset}`;
276
+
277
+ console.log(
278
+ ` ${C.dim}│${C.reset} ${shortAddr.padEnd(20)} ${delegated.padEnd(13)} ${estRew.padEnd(15)} ${apy.padEnd(7)} ${status} ${C.dim}│${C.reset}`
279
+ );
280
+ }
281
+
282
+ console.log(` ${C.dim}└─────────────────────────────────────────────────────────────┘${C.reset}`);
283
+ console.log();
284
+ console.log(` ${C.bright}Total Delegated:${C.reset} ${C.cyan}${formatAether(Number(totalDelegatedStake))}${C.reset}`);
285
+ console.log(` ${C.bright}Total Est. Rewards:${C.reset} ${C.green}${formatAether(Number(totalEstimatedRewards))}${C.reset}`);
286
+ console.log(` ${C.bright}Active Accounts:${C.reset} ${activeCount} of ${rows.length}`);
287
+ console.log();
288
+ console.log(` ${C.dim}Run "aether claim --address ${address}" to claim rewards.${C.reset}\n`);
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Rewards summary command (one-line)
293
+ // ---------------------------------------------------------------------------
294
+
295
+ async function rewardsSummary(args) {
296
+ const rpc = args.rpc || getDefaultRpc();
297
+ let address = args.address || null;
298
+
299
+ if (!address) {
300
+ const config = loadConfig();
301
+ if (!config.defaultWallet) {
302
+ console.log(`${C.red}✗ No default wallet and no address provided.${C.reset}`);
303
+ return;
304
+ }
305
+ address = config.defaultWallet;
306
+ }
307
+
308
+ const stakeAccounts = await fetchWalletStakeAccounts(rpc, address);
309
+ if (stakeAccounts.length === 0) {
310
+ console.log(`${C.yellow}⚠ No stake accounts for ${address.substring(0, 12)}...${C.reset}`);
311
+ return;
312
+ }
313
+
314
+ const results = await Promise.all(stakeAccounts.map(sa => fetchStakeRewards(rpc, sa)));
315
+ let totalRewards = BigInt(0);
316
+ let totalStake = BigInt(0);
317
+ let activeCount = 0;
318
+
319
+ for (const r of results) {
320
+ if (!r.error) {
321
+ totalRewards += BigInt(r.estimatedRewards);
322
+ totalStake += BigInt(r.delegatedStake);
323
+ if (r.isActive) activeCount++;
324
+ }
325
+ }
326
+
327
+ console.log(`${C.cyan}${address.substring(0, 12)}...${C.reset} Stake: ${C.cyan}${formatAether(Number(totalStake))}${C.reset} │ Est.Rewards: ${C.green}${formatAether(Number(totalRewards))}${C.reset} │ Active: ${activeCount}/${results.length}`);
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Rewards pending command
332
+ // ---------------------------------------------------------------------------
333
+
334
+ async function rewardsPending(args) {
335
+ const rpc = args.rpc || getDefaultRpc();
336
+ const isJson = args.json || false;
337
+ let address = args.address || null;
338
+
339
+ const config = loadConfig();
340
+ const rl = createRl();
341
+
342
+ if (!address) {
343
+ const ans = await question(rl, `\n${C.cyan}Enter wallet address: ${C.reset}`);
344
+ address = ans.trim();
345
+ }
346
+
347
+ if (!address) {
348
+ console.log(`\n${ICONS.error} ${C.red}No address provided.${C.reset}\n`);
349
+ rl.close();
350
+ return;
351
+ }
352
+
353
+ rl.close();
354
+
355
+ const stakeAccounts = await fetchWalletStakeAccounts(rpc, address);
356
+ if (stakeAccounts.length === 0) {
357
+ if (isJson) {
358
+ console.log(JSON.stringify({ address, pending: [], total_pending: '0' }, null, 2));
359
+ } else {
360
+ console.log(`\n${ICONS.error} ${C.red}No stake accounts found for ${address}${C.reset}\n`);
361
+ }
362
+ return;
363
+ }
364
+
365
+ const results = [];
366
+ let totalPending = BigInt(0);
367
+
368
+ for (const sa of stakeAccounts) {
369
+ const rd = await fetchStakeRewards(rpc, sa);
370
+ if (!rd.error) {
371
+ const pending = BigInt(rd.pendingRewards || 0);
372
+ totalPending += pending;
373
+ results.push({
374
+ stake_account: sa,
375
+ validator: rd.validator || 'unknown',
376
+ delegated_stake: rd.delegatedStakeFormatted || '0',
377
+ pending_rewards: formatAether(Number(pending)),
378
+ pending_lamports: pending.toString(),
379
+ apy_bps: rd.apyBps || 0,
380
+ });
381
+ }
382
+ }
383
+
384
+ if (isJson) {
385
+ console.log(JSON.stringify({
386
+ address,
387
+ total_pending: totalPending.toString(),
388
+ total_pending_formatted: formatAether(Number(totalPending)),
389
+ accounts: results,
390
+ }, null, 2));
391
+ return;
392
+ }
393
+
394
+ console.log(`\n${C.bright}${C.cyan}╔══════════════════════════════════════════════════════════════╗${C.reset}`);
395
+ console.log(`${C.bright}${C.cyan}║ Pending Staking Rewards ║${C.reset}`);
396
+ console.log(`${C.bright}${C.cyan}╚══════════════════════════════════════════════════════════════╝${C.reset}\n`);
397
+ console.log(` ${C.dim}Wallet:${C.reset} ${C.bright}${address}${C.reset}`);
398
+ console.log();
399
+ console.log(` ${C.yellow}Stake Account${C.reset.padEnd(48)} ${C.yellow}Pending${C.reset} ${C.yellow}APY${C.reset}`);
400
+ console.log(` ${C.dim}${'─'.repeat(72)}${C.reset}`);
401
+
402
+ for (const r of results) {
403
+ const shortSa = r.stake_account.substring(0, 12) + '...' + r.stake_account.slice(-6);
404
+ console.log(` ${C.cyan}${shortSa}${C.reset.padEnd(52)} ${C.green}${r.pending_rewards.padStart(12)}${C.reset} ${(r.apy_bps / 100).toFixed(2)}%`);
405
+ }
406
+
407
+ console.log(` ${C.dim}${'─'.repeat(72)}${C.reset}`);
408
+ console.log(` ${C.bright}TOTAL PENDING${C.reset.padEnd(52)} ${C.green}${formatAethFull(Number(totalPending)).padStart(12)}${C.reset}`);
409
+ console.log();
410
+ console.log(` ${C.dim}Run ${C.cyan}aether claim --address ${address}${C.dim} to claim.${C.reset}\n`);
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Parse CLI args
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function parseArgs() {
418
+ const args = process.argv.slice(3); // [node, index.js, rewards, <subcmd>, ...]
419
+ return args;
420
+ }
421
+
422
+ function createRl() {
423
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
424
+ }
425
+
426
+ function question(rl, q) {
427
+ return new Promise((res) => rl.question(q, res));
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Main entry point
432
+ // ---------------------------------------------------------------------------
433
+
434
+ async function main() {
435
+ const rawArgs = parseArgs();
436
+ const subcmd = rawArgs[0] || 'list';
437
+
438
+ // Parse common flags from all args
439
+ const allArgs = rawArgs.slice(1);
440
+ const rpcIndex = allArgs.findIndex(a => a === '--rpc');
441
+ const rpc = rpcIndex !== -1 ? allArgs[rpcIndex + 1] : getDefaultRpc();
442
+
443
+ const parsed = {
444
+ rpc,
445
+ json: allArgs.includes('--json'),
446
+ address: null,
447
+ account: null,
448
+ };
449
+
450
+ // Extract --address flag
451
+ const addrIdx = allArgs.findIndex(a => a === '--address');
452
+ if (addrIdx !== -1 && allArgs[addrIdx + 1]) parsed.address = allArgs[addrIdx + 1];
453
+
454
+ switch (subcmd) {
455
+ case 'list':
456
+ await rewardsList(parsed);
457
+ break;
458
+ case 'summary':
459
+ await rewardsSummary(parsed);
460
+ break;
461
+ case 'pending':
462
+ await rewardsPending(parsed);
463
+ break;
464
+ default:
465
+ console.log(`\n${C.cyan}Usage:${C.reset}`);
466
+ console.log(` aether rewards list --address <addr> List all staking rewards`);
467
+ console.log(` aether rewards summary --address <addr> One-line rewards summary`);
468
+ console.log(` aether rewards pending --address <addr> Show pending (unclaimed) rewards`);
469
+ console.log();
470
+ console.log(` ${C.dim}--json Output as JSON`);
471
+ console.log(` --rpc <url> Use specific RPC endpoint${C.reset}\n`);
472
+ }
473
+ }
474
+
475
+ main().catch(err => {
476
+ console.error(`\n${C.red}Error running rewards command:${C.reset}`, err.message, '\n');
477
+ });
478
+
479
+ module.exports = { rewardsCommand: main };