aether-hub 1.2.3 → 1.2.4
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 +480 -0
- package/commands/stake-positions.js +220 -0
- package/index.js +10 -5
- package/package.json +1 -1
package/commands/apy.js
ADDED
|
@@ -0,0 +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 };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aether-cli stake-positions
|
|
4
|
+
*
|
|
5
|
+
* Query and display current stake positions/delegations for a wallet.
|
|
6
|
+
* Shows validator, amount, status, and accumulated rewards.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* aether stake-positions --address <addr> [--json]
|
|
10
|
+
* aether wallet stake-positions --address <addr> [--json]
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* aether stake-positions --address ATHxxx
|
|
14
|
+
* aether wallet stake-positions --address ATHxxx --json
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const https = require('https');
|
|
18
|
+
const http = require('http');
|
|
19
|
+
|
|
20
|
+
// ANSI colours
|
|
21
|
+
const C = {
|
|
22
|
+
reset: '\x1b[0m',
|
|
23
|
+
bright: '\x1b[1m',
|
|
24
|
+
dim: '\x1b[2m',
|
|
25
|
+
red: '\x1b[31m',
|
|
26
|
+
green: '\x1b[32m',
|
|
27
|
+
yellow: '\x1b[33m',
|
|
28
|
+
cyan: '\x1b[36m',
|
|
29
|
+
magenta: '\x1b[35m',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const CLI_VERSION = '1.0.0';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Config
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function getDefaultRpc() {
|
|
39
|
+
return process.env.AETHER_RPC || 'http://127.0.0.1:8899';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// HTTP helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function httpRequest(rpcUrl, pathStr, timeoutMs = 8000) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const url = new URL(pathStr, rpcUrl);
|
|
49
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
50
|
+
const req = lib.request({
|
|
51
|
+
hostname: url.hostname,
|
|
52
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
53
|
+
path: url.pathname + url.search,
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
}, (res) => {
|
|
57
|
+
let data = '';
|
|
58
|
+
res.on('data', (chunk) => data += chunk);
|
|
59
|
+
res.on('end', () => {
|
|
60
|
+
try { resolve(JSON.parse(data)); }
|
|
61
|
+
catch { resolve(data); }
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
req.on('error', reject);
|
|
65
|
+
req.setTimeout(timeoutMs, () => {
|
|
66
|
+
req.destroy();
|
|
67
|
+
reject(new Error(`Request timeout after ${timeoutMs}ms`));
|
|
68
|
+
});
|
|
69
|
+
req.end();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Argument parsing
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function parseArgs() {
|
|
78
|
+
const args = process.argv.slice(2);
|
|
79
|
+
const result = { address: null, json: false };
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < args.length; i++) {
|
|
82
|
+
if ((args[i] === '--address' || args[i] === '-a') && args[i + 1]) {
|
|
83
|
+
result.address = args[i + 1];
|
|
84
|
+
i++;
|
|
85
|
+
} else if (args[i] === '--json' || args[i] === '--json-output') {
|
|
86
|
+
result.json = true;
|
|
87
|
+
} else if (args[i] === '--rpc' && args[i + 1]) {
|
|
88
|
+
result.rpc = args[i + 1];
|
|
89
|
+
i++;
|
|
90
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
91
|
+
result.help = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Balance formatting
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function formatAether(lamports) {
|
|
103
|
+
const aeth = (lamports || 0) / 1e9;
|
|
104
|
+
return aeth.toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 }) + ' AETH';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Main
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
async function stakePositionsCommand() {
|
|
112
|
+
const opts = parseArgs();
|
|
113
|
+
|
|
114
|
+
if (opts.help) {
|
|
115
|
+
console.log(`
|
|
116
|
+
${C.bright}${C.cyan}stake-positions${C.reset} — Query active stake delegations for a wallet
|
|
117
|
+
|
|
118
|
+
${C.bright}USAGE${C.reset}
|
|
119
|
+
aether stake-positions --address <addr> [--json] [--rpc <url>]
|
|
120
|
+
|
|
121
|
+
${C.bright}OPTIONS${C.reset}
|
|
122
|
+
--address <addr> Wallet address (ATH...)
|
|
123
|
+
--json Output raw JSON
|
|
124
|
+
--rpc <url> RPC endpoint (default: AETHER_RPC or localhost:8899)
|
|
125
|
+
--help Show this help
|
|
126
|
+
|
|
127
|
+
${C.bright}EXAMPLES${C.reset}
|
|
128
|
+
aether stake-positions --address ATH3abc...
|
|
129
|
+
aether stake-positions --address ATH3abc... --json
|
|
130
|
+
`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!opts.address) {
|
|
135
|
+
console.log(` ${C.red}✗ Missing --address${C.reset}\n`);
|
|
136
|
+
console.log(` Usage: aether stake-positions --address <addr> [--json]\n`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const rpcUrl = opts.rpc || getDefaultRpc();
|
|
141
|
+
const address = opts.address;
|
|
142
|
+
const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
|
|
143
|
+
|
|
144
|
+
if (!opts.json) {
|
|
145
|
+
console.log(`\n${C.bright}${C.cyan}── Stake Positions ──────────────────────────────────────${C.reset}\n`);
|
|
146
|
+
console.log(` ${C.dim}Wallet:${C.reset} ${address}`);
|
|
147
|
+
console.log(` ${C.dim}RPC: ${C.reset} ${rpcUrl}\n`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
// Fetch stake accounts
|
|
152
|
+
const res = await httpRequest(rpcUrl, `/v1/stake?address=${encodeURIComponent(rawAddr)}`);
|
|
153
|
+
|
|
154
|
+
let stakeAccounts = [];
|
|
155
|
+
if (Array.isArray(res)) {
|
|
156
|
+
stakeAccounts = res;
|
|
157
|
+
} else if (res && typeof res === 'object') {
|
|
158
|
+
stakeAccounts = res.accounts || res.stake_accounts || res.data || [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (opts.json) {
|
|
162
|
+
const totalLamports = stakeAccounts.reduce((sum, acc) => sum + (acc.stake_lamports || acc.lamports || 0), 0);
|
|
163
|
+
console.log(JSON.stringify({
|
|
164
|
+
wallet_address: address,
|
|
165
|
+
stake_accounts: stakeAccounts.map(acc => ({
|
|
166
|
+
stake_account: acc.pubkey || acc.publicKey || acc.account || 'unknown',
|
|
167
|
+
validator: acc.validator || acc.delegate || acc.validator_address || 'unknown',
|
|
168
|
+
stake_lamports: acc.stake_lamports || acc.lamports || 0,
|
|
169
|
+
stake_aeth: ((acc.stake_lamports || acc.lamports || 0) / 1e9).toFixed(4),
|
|
170
|
+
status: acc.status || acc.state || 'active',
|
|
171
|
+
updated_epoch: acc.epoch || acc.last_update_epoch || null,
|
|
172
|
+
})),
|
|
173
|
+
total_staked_lamports: totalLamports,
|
|
174
|
+
total_staked_aeth: (totalLamports / 1e9).toFixed(4),
|
|
175
|
+
count: stakeAccounts.length,
|
|
176
|
+
}, null, 2));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!stakeAccounts || stakeAccounts.length === 0) {
|
|
181
|
+
console.log(` ${C.yellow}? No active stake positions found.${C.reset}`);
|
|
182
|
+
console.log(` ${C.dim} Stake AETH with: ${C.cyan}aether stake --validator <addr> --amount <aeth>${C.reset}\n`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let totalStaked = 0;
|
|
187
|
+
console.log(` ${C.bright}Stake Positions (${stakeAccounts.length})${C.reset}\n`);
|
|
188
|
+
|
|
189
|
+
for (const acc of stakeAccounts) {
|
|
190
|
+
const stakeAcct = acc.pubkey || acc.publicKey || acc.account || 'unknown';
|
|
191
|
+
const validator = acc.validator || acc.delegate || acc.validator_address || 'unknown';
|
|
192
|
+
const lamports = acc.stake_lamports || acc.lamports || 0;
|
|
193
|
+
const status = (acc.status || acc.state || 'active').toLowerCase();
|
|
194
|
+
const epoch = acc.epoch || acc.last_update_epoch || null;
|
|
195
|
+
|
|
196
|
+
totalStaked += lamports;
|
|
197
|
+
|
|
198
|
+
const statusColor = status === 'active' ? C.green : status === 'deactivating' ? C.yellow : C.dim;
|
|
199
|
+
const shortAcct = stakeAcct.length > 20 ? stakeAcct.slice(0, 8) + '.' + stakeAcct.slice(-8) : stakeAcct;
|
|
200
|
+
const shortVal = validator.length > 20 ? validator.slice(0, 8) + '.' + validator.slice(-8) : validator;
|
|
201
|
+
const aeth = (lamports / 1e9).toFixed(4);
|
|
202
|
+
|
|
203
|
+
console.log(` ${C.dim}┌─${C.bright}${statusColor} ${status.toUpperCase()}${C.reset}`);
|
|
204
|
+
console.log(` │ ${C.dim}Stake acct:${C.reset} ${shortAcct}`);
|
|
205
|
+
console.log(` │ ${C.dim}Validator:${C.reset} ${shortVal}`);
|
|
206
|
+
console.log(` │ ${C.dim}Staked:${C.reset} ${C.bright}${aeth} AETH${C.reset} (${lamports.toLocaleString()} lamports)`);
|
|
207
|
+
if (epoch) console.log(` │ ${C.dim}Epoch:${C.reset} ${C.bright}#${epoch}${C.reset}`);
|
|
208
|
+
console.log(` ${C.dim}└${C.reset}\n`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(` ${C.dim}────────────────────────────────────────${C.reset}`);
|
|
212
|
+
console.log(` ${C.bright}Total Staked:${C.reset} ${C.green}${formatAether(totalStaked)}${C.reset}\n`);
|
|
213
|
+
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.log(` ${C.red}? Failed to fetch stake positions:${C.reset} ${err.message}\n`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
stakePositionsCommand();
|
package/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const { epochCommand } = require('./commands/epoch');
|
|
|
27
27
|
const { supplyCommand } = require('./commands/supply');
|
|
28
28
|
const { statusCommand } = require('./commands/status');
|
|
29
29
|
const { broadcastCommand } = require('./commands/broadcast');
|
|
30
|
+
const { apyCommand } = require('./commands/apy');
|
|
30
31
|
const readline = require('readline');
|
|
31
32
|
|
|
32
33
|
// CLI version
|
|
@@ -197,11 +198,8 @@ const COMMANDS = {
|
|
|
197
198
|
'stake-positions': {
|
|
198
199
|
description: 'Show current stake positions/delegations — aether stake-positions --address <addr> [--json]',
|
|
199
200
|
handler: () => {
|
|
200
|
-
const {
|
|
201
|
-
|
|
202
|
-
process.argv = [...originalArgv.slice(0, 2), 'wallet', 'stake-positions', ...originalArgv.slice(3)];
|
|
203
|
-
walletCommand();
|
|
204
|
-
process.argv = originalArgv;
|
|
201
|
+
const { stakePositionsCommand } = require('./commands/stake-positions');
|
|
202
|
+
stakePositionsCommand();
|
|
205
203
|
},
|
|
206
204
|
},
|
|
207
205
|
unstake: {
|
|
@@ -369,6 +367,13 @@ const COMMANDS = {
|
|
|
369
367
|
broadcastCommand();
|
|
370
368
|
},
|
|
371
369
|
},
|
|
370
|
+
apy: {
|
|
371
|
+
description: 'Validator APY estimator — aether apy [--validator <addr>] [--address <addr>] [--json] [--epochs <n>]',
|
|
372
|
+
handler: () => {
|
|
373
|
+
const { apyCommand } = require('./commands/apy');
|
|
374
|
+
apyCommand();
|
|
375
|
+
},
|
|
376
|
+
},
|
|
372
377
|
ping: {
|
|
373
378
|
description: 'Ping RPC endpoint — measure latency, check node health — aether ping [--rpc <url>] [--count <n>] [--json]',
|
|
374
379
|
handler: () => {
|