@jellylegsai/aether-cli 1.9.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/stake.js CHANGED
@@ -1,516 +1,581 @@
1
- #!/usr/bin/env node
2
- /**
3
- * aether-cli stake
4
- *
5
- * First-class stake command - stake AETH to a validator.
6
- * Fully wired to @jellylegs/aether-sdk for real blockchain RPC calls.
7
- *
8
- * Usage:
9
- * aether stake --validator <addr> --amount <aeth> [--address <wallet>]
10
- * aether stake --validator ATHxxx... --amount 1000
11
- * aether stake --validator ATHxxx... --amount 1000 --address ATHxxx...
12
- * aether stake --list-validators # Show available validators to stake to
13
- * aether stake --dry-run # Preview without submitting
14
- */
15
-
16
- const fs = require('fs');
17
- const path = require('path');
18
- const os = require('os');
19
- const readline = require('readline');
20
- const nacl = require('tweetnacl');
21
- const bs58 = require('bs58').default;
22
- const bip39 = require('bip39');
23
-
24
- // Import SDK for real blockchain RPC calls
25
- const sdkPath = path.join(__dirname, '..', 'sdk', 'index.js');
26
- const aether = require(sdkPath);
27
-
28
- // ANSI colours
29
- const C = {
30
- reset: '\x1b[0m',
31
- bright: '\x1b[1m',
32
- dim: '\x1b[2m',
33
- red: '\x1b[31m',
34
- green: '\x1b[32m',
35
- yellow: '\x1b[33m',
36
- cyan: '\x1b[36m',
37
- magenta: '\x1b[35m',
38
- };
39
-
40
- const CLI_VERSION = '1.0.0';
41
- const DERIVATION_PATH = "m/44'/7777777'/0'/0'";
42
-
43
- // ============================================================================
44
- // SDK Setup
45
- // ============================================================================
46
-
47
- function getDefaultRpc() {
48
- return process.env.AETHER_RPC || aether.DEFAULT_RPC_URL || 'http://127.0.0.1:8899';
49
- }
50
-
51
- function createClient(rpcUrl) {
52
- return new aether.AetherClient({ rpcUrl });
53
- }
54
-
55
- // ============================================================================
56
- // Config & Wallet
57
- // ============================================================================
58
-
59
- function getAetherDir() {
60
- return path.join(os.homedir(), '.aether');
61
- }
62
-
63
- function getConfigPath() {
64
- return path.join(getAetherDir(), 'config.json');
65
- }
66
-
67
- function loadConfig() {
68
- if (!fs.existsSync(getConfigPath())) {
69
- return { defaultWallet: null };
70
- }
71
- try {
72
- return JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
73
- } catch {
74
- return { defaultWallet: null };
75
- }
76
- }
77
-
78
- function loadWallet(address) {
79
- const fp = path.join(getAetherDir(), 'wallets', `${address}.json`);
80
- if (!fs.existsSync(fp)) return null;
81
- try {
82
- return JSON.parse(fs.readFileSync(fp, 'utf8'));
83
- } catch {
84
- return null;
85
- }
86
- }
87
-
88
- // ============================================================================
89
- // Crypto Helpers
90
- // ============================================================================
91
-
92
- function deriveKeypair(mnemonic) {
93
- if (!bip39.validateMnemonic(mnemonic)) {
94
- throw new Error('Invalid mnemonic phrase');
95
- }
96
- const seedBuffer = bip39.mnemonicToSeedSync(mnemonic, '');
97
- const seed32 = seedBuffer.slice(0, 32);
98
- const keyPair = nacl.sign.keyPair.fromSeed(seed32);
99
- return {
100
- publicKey: Buffer.from(keyPair.publicKey),
101
- secretKey: Buffer.from(keyPair.secretKey),
102
- };
103
- }
104
-
105
- function formatAddress(publicKey) {
106
- return 'ATH' + bs58.encode(publicKey);
107
- }
108
-
109
- function signTransaction(tx, secretKey) {
110
- const txBytes = Buffer.from(JSON.stringify(tx));
111
- const sig = nacl.sign.detached(txBytes, secretKey);
112
- return bs58.encode(sig);
113
- }
114
-
115
- // ============================================================================
116
- // Format Helpers
117
- // ============================================================================
118
-
119
- function formatAether(lamports) {
120
- const aeth = Number(lamports) / 1e9;
121
- if (aeth === 0) return '0 AETH';
122
- return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
123
- }
124
-
125
- function shortAddress(addr) {
126
- if (!addr || addr.length < 16) return addr || 'unknown';
127
- return addr.slice(0, 8) + '...' + addr.slice(-8);
128
- }
129
-
130
- function formatPercent(val) {
131
- if (val === undefined || val === null) return 'N/A';
132
- return val.toFixed(2) + '%';
133
- }
134
-
135
- // ============================================================================
136
- // Readline Helpers
137
- // ============================================================================
138
-
139
- function createRl() {
140
- return readline.createInterface({ input: process.stdin, output: process.stdout });
141
- }
142
-
143
- function question(rl, q) {
144
- return new Promise((res) => rl.question(q, res));
145
- }
146
-
147
- async function askMnemonic(rl, promptText) {
148
- console.log(`\n${C.cyan}${promptText}${C.reset}`);
149
- console.log(`${C.dim}Enter your 12 or 24-word passphrase:${C.reset}`);
150
- const raw = await question(rl, ` > ${C.reset}`);
151
- return raw.trim().toLowerCase();
152
- }
153
-
154
- // ============================================================================
155
- // Fetch Validators via SDK
156
- // ============================================================================
157
-
158
- async function fetchValidators(rpcUrl) {
159
- const client = createClient(rpcUrl);
160
- try {
161
- const validators = await client.getValidators();
162
- if (!Array.isArray(validators)) return [];
163
- return validators.map(v => ({
164
- address: v.vote_account || v.pubkey || v.address || v.identity,
165
- identity: v.identity || v.node_pubkey,
166
- stake: v.stake_lamports || v.activated_stake || v.stake || 0,
167
- commission: v.commission || v.commission_bps || 0,
168
- apy: v.apy || v.return_rate || 0,
169
- name: v.name || v.moniker || 'Unknown',
170
- tier: v.tier || 'unknown',
171
- active: v.active !== false && v.delinquent !== true,
172
- }));
173
- } catch (err) {
174
- return [];
175
- }
176
- }
177
-
178
- // ============================================================================
179
- // Show Help
180
- // ============================================================================
181
-
182
- function showHelp() {
183
- console.log(`
184
- ${C.bright}${C.cyan}aether-cli stake${C.reset} — Stake AETH to a validator
185
-
186
- ${C.bright}USAGE${C.reset}
187
- aether stake --validator <addr> --amount <aeth> [--address <wallet>]
188
-
189
- ${C.bright}REQUIRED${C.reset}
190
- --validator <addr> Validator address to stake to
191
- --amount <aeth> Amount to stake in AETH
192
-
193
- ${C.bright}OPTIONS${C.reset}
194
- --address <addr> Wallet address (default: configured default)
195
- --rpc <url> RPC endpoint (default: AETHER_RPC env or localhost:8899)
196
- --json Output JSON for scripting
197
- --dry-run Preview stake without submitting
198
- --list-validators Show available validators on the network
199
- --force Skip confirmation prompts
200
-
201
- ${C.bright}SDK METHODS USED${C.reset}
202
- client.getValidators() → GET /v1/validators
203
- client.getAccountInfo() → GET /v1/account/<addr>
204
- client.getSlot() → GET /v1/slot
205
- client.sendTransaction() → POST /v1/transaction
206
-
207
- ${C.bright}EXAMPLES${C.reset}
208
- aether stake --validator ATHxxx... --amount 1000
209
- aether stake --validator ATHxxx... --amount 1000 --address ATHxxx...
210
- aether stake --list-validators
211
- aether stake --validator ATHxxx... --amount 1000 --dry-run
212
-
213
- ${C.bright}MINIMUM STAKE AMOUNTS${C.reset}
214
- Full: 10,000 AETH
215
- Lite: 1,000 AETH
216
- Observer: 0 AETH
217
-
218
- ${C.bright}NOTES${C.reset}
219
- Staked AETH begins earning rewards after one epoch (~2 days)
220
- • Use 'aether stake-positions' to view your delegations
221
- Use 'aether unstake' to withdraw (has cooldown period)
222
- `);
223
- }
224
-
225
- // ============================================================================
226
- // List Validators Command
227
- // ============================================================================
228
-
229
- async function listValidatorsCommand(opts) {
230
- if (!opts.json) {
231
- console.log(`\n${C.bright}${C.cyan}── Available Validators ──────────────────────────────────────${C.reset}\n`);
232
- console.log(` ${C.dim}Fetching validators from ${opts.rpc}...${C.reset}\n`);
233
- }
234
-
235
- const validators = await fetchValidators(opts.rpc);
236
-
237
- if (validators.length === 0) {
238
- if (opts.json) {
239
- console.log(JSON.stringify({
240
- success: false,
241
- error: 'No validators found',
242
- rpc: opts.rpc,
243
- }, null, 2));
244
- } else {
245
- console.log(` ${C.yellow}⚠ No validators found.${C.reset}`);
246
- console.log(` ${C.dim} Check your RPC endpoint: ${opts.rpc}${C.reset}\n`);
247
- }
248
- return;
249
- }
250
-
251
- validators.sort((a, b) => b.stake - a.stake);
252
-
253
- if (opts.json) {
254
- console.log(JSON.stringify({
255
- success: true,
256
- count: validators.length,
257
- validators: validators.map(v => ({
258
- address: v.address,
259
- name: v.name,
260
- stake_aeth: v.stake / 1e9,
261
- apy: v.apy,
262
- tier: v.tier,
263
- })),
264
- }, null, 2));
265
- return;
266
- }
267
-
268
- console.log(` ${C.bright}Found ${validators.length} validators${C.reset}\n`);
269
- validators.slice(0, 15).forEach((v, i) => {
270
- const status = v.active ? C.green + '●' : C.yellow + '○';
271
- const name = (v.name || 'Unknown').slice(0, 20).padEnd(20);
272
- const addr = shortAddress(v.address);
273
- const stake = formatAether(v.stake);
274
- const apy = formatPercent(v.apy);
275
- console.log(` ${status}${C.reset} ${(i + 1).toString().padStart(2)} ${name} ${addr} ${stake} ${apy}`);
276
- });
277
-
278
- console.log(`\n ${C.dim}To stake: aether stake --validator <addr> --amount <aeth>${C.reset}\n`);
279
- }
280
-
281
- // ============================================================================
282
- // Main Stake Logic
283
- // ============================================================================
284
-
285
- async function stakeCommand() {
286
- const opts = {
287
- validator: null,
288
- amount: null,
289
- address: null,
290
- rpc: getDefaultRpc(),
291
- json: false,
292
- dryRun: false,
293
- listValidators: false,
294
- force: false,
295
- };
296
-
297
- // Parse args
298
- const args = process.argv.slice(3);
299
- for (let i = 0; i < args.length; i++) {
300
- const arg = args[i];
301
- if (arg === '--validator' || arg === '-v') opts.validator = args[++i];
302
- else if (arg === '--amount' || arg === '-m') {
303
- const val = parseFloat(args[++i]);
304
- if (!isNaN(val) && val > 0) opts.amount = val;
305
- }
306
- else if (arg === '--address' || arg === '-a') opts.address = args[++i];
307
- else if (arg === '--rpc' || arg === '-r') opts.rpc = args[++i];
308
- else if (arg === '--json' || arg === '-j') opts.json = true;
309
- else if (arg === '--dry-run') opts.dryRun = true;
310
- else if (arg === '--list-validators' || arg === '-l') opts.listValidators = true;
311
- else if (arg === '--force' || arg === '-f') opts.force = true;
312
- else if (arg === '--help' || arg === '-h') {
313
- showHelp();
314
- return;
315
- }
316
- }
317
-
318
- // List validators mode
319
- if (opts.listValidators) {
320
- await listValidatorsCommand(opts);
321
- return;
322
- }
323
-
324
- const rl = createRl();
325
-
326
- // Resolve wallet address
327
- if (!opts.address) {
328
- const cfg = loadConfig();
329
- opts.address = cfg.defaultWallet;
330
- }
331
-
332
- if (!opts.address) {
333
- console.log(`\n ${C.red}✗ No wallet address.${C.reset} Use --address <addr> or set a default.`);
334
- console.log(` ${C.dim}Usage: aether stake --validator <addr> --amount <aeth>${C.reset}\n`);
335
- rl.close();
336
- return;
337
- }
338
-
339
- // Check wallet exists
340
- const wallet = loadWallet(opts.address);
341
- if (!wallet) {
342
- console.log(`\n ${C.red} Wallet not found:${C.reset} ${opts.address}`);
343
- console.log(` ${C.dim}Import it: aether wallet import${C.reset}\n`);
344
- rl.close();
345
- return;
346
- }
347
-
348
- // Fetch balance via SDK
349
- let balance = 0;
350
- const client = createClient(opts.rpc);
351
- const rawAddr = opts.address.startsWith('ATH') ? opts.address.slice(3) : opts.address;
352
- try {
353
- const account = await client.getAccountInfo(rawAddr);
354
- balance = account.lamports || 0;
355
- } catch (err) {
356
- if (!opts.json) console.log(` ${C.yellow}⚠ Could not fetch balance: ${err.message}${C.reset}`);
357
- }
358
-
359
- // Interactive validator selection
360
- let validator = opts.validator;
361
- if (!validator) {
362
- console.log(`\n ${C.dim}Fetching validators...${C.reset}`);
363
- const validators = await fetchValidators(opts.rpc);
364
- if (validators.length === 0) {
365
- console.log(` ${C.red}✗ No validators found.${C.reset}\n`);
366
- rl.close();
367
- return;
368
- }
369
- validators.sort((a, b) => b.stake - a.stake);
370
- console.log(`\n ${C.bright}Select a validator:${C.reset}`);
371
- validators.slice(0, 10).forEach((v, i) => {
372
- const name = (v.name || 'Unknown').slice(0, 18).padEnd(18);
373
- const stake = formatAether(v.stake);
374
- const apy = formatPercent(v.apy);
375
- console.log(` ${C.green}${i + 1})${C.reset} ${name} | ${stake} | ${apy}`);
376
- });
377
- console.log(`\n ${C.dim}Enter number [1-10] or validator address${C.reset}`);
378
- const choice = await question(rl, ` Validator > ${C.reset}`);
379
- const choiceNum = parseInt(choice.trim(), 10);
380
- if (!isNaN(choiceNum) && choiceNum >= 1 && choiceNum <= 10) {
381
- validator = validators[choiceNum - 1].address;
382
- } else {
383
- validator = choice.trim();
384
- }
385
- }
386
-
387
- // Resolve amount
388
- let amount = opts.amount;
389
- if (!amount) {
390
- console.log(`\n Available: ${formatAether(balance)}`);
391
- console.log(` Minimum: Full=10K, Lite=1K, Observer=0`);
392
- const amt = await question(rl, ` Amount (AETH) > ${C.reset}`);
393
- amount = parseFloat(amt);
394
- if (isNaN(amount) || amount <= 0) {
395
- console.log(`\n ${C.red}✗ Invalid amount.${C.reset}\n`);
396
- rl.close();
397
- return;
398
- }
399
- }
400
-
401
- const stakeLamports = Math.round(amount * 1e9);
402
- const feeBuffer = 0.005 * 1e9;
403
-
404
- if (stakeLamports + feeBuffer > balance) {
405
- console.log(`\n ${C.red}✗ Insufficient balance.${C.reset}`);
406
- console.log(` Requested: ${formatAether(stakeLamports)}`);
407
- console.log(` Balance: ${formatAether(balance)}\n`);
408
- rl.close();
409
- return;
410
- }
411
-
412
- // Summary
413
- console.log(`\n ${C.green}★${C.reset} Wallet: ${C.bright}${opts.address}${C.reset}`);
414
- console.log(` ${C.green}★${C.reset} Validator: ${C.bright}${shortAddress(validator)}${C.reset}`);
415
- console.log(` ${C.green}★${C.reset} Amount: ${C.bright}${formatAether(stakeLamports)}${C.reset}`);
416
- console.log();
417
-
418
- // Dry run
419
- if (opts.dryRun) {
420
- console.log(JSON.stringify({
421
- dry_run: true,
422
- wallet: opts.address,
423
- validator: validator,
424
- stake_lamports: stakeLamports,
425
- stake_aeth: amount,
426
- balance_aeth: balance / 1e9,
427
- rpc: opts.rpc,
428
- cli_version: CLI_VERSION,
429
- }, null, 2));
430
- rl.close();
431
- return;
432
- }
433
-
434
- // Sign
435
- console.log(`${C.yellow} ⚠ Signing requires your wallet passphrase.${C.reset}`);
436
- const mnemonic = await askMnemonic(rl, 'Enter passphrase to sign this transaction');
437
-
438
- let keyPair;
439
- try {
440
- keyPair = deriveKeypair(mnemonic);
441
- } catch (e) {
442
- console.log(`\n ${C.red}✗ Failed to derive keypair: ${e.message}${C.reset}\n`);
443
- rl.close();
444
- return;
445
- }
446
-
447
- const derivedAddress = formatAddress(keyPair.publicKey);
448
- if (derivedAddress !== opts.address) {
449
- console.log(`\n ${C.red}✗ Passphrase mismatch!${C.reset}`);
450
- console.log(` Expected: ${opts.address}`);
451
- console.log(` Derived: ${derivedAddress}\n`);
452
- rl.close();
453
- return;
454
- }
455
-
456
- // Confirm
457
- if (!opts.force) {
458
- const confirm = await question(rl, `\n ${C.yellow}Confirm stake? [y/N]${C.reset} > `);
459
- if (!confirm.trim().toLowerCase().startsWith('y')) {
460
- console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
461
- rl.close();
462
- return;
463
- }
464
- }
465
-
466
- // Get slot and build transaction
467
- let slot = 0;
468
- try { slot = await client.getSlot(); } catch (e) {}
469
-
470
- const tx = {
471
- signer: rawAddr,
472
- tx_type: 'Stake',
473
- payload: {
474
- type: 'Stake',
475
- data: { validator: validator, amount: stakeLamports },
476
- },
477
- fee: 5000,
478
- slot: slot,
479
- timestamp: Math.floor(Date.now() / 1000),
480
- };
481
-
482
- tx.signature = signTransaction(tx, keyPair.secretKey);
483
-
484
- console.log(`\n ${C.dim}Submitting to ${opts.rpc}...${C.reset}`);
485
-
486
- try {
487
- const result = await client.sendTransaction(tx);
488
- if (result.error) throw new Error(result.error);
489
-
490
- console.log(`\n${C.green}✓ Stake transaction submitted!${C.reset}`);
491
- console.log(` Wallet: ${opts.address}`);
492
- console.log(` Validator: ${validator}`);
493
- console.log(` Amount: ${formatAether(stakeLamports)}`);
494
- if (result.signature) console.log(` Signature: ${result.signature.slice(0, 40)}...`);
495
- console.log(` Slot: ${result.slot || slot}`);
496
- console.log(`\n${C.green}✓ Stake will activate in the next epoch${C.reset}`);
497
- console.log(` Check: aether stake-positions --address ${opts.address}\n`);
498
- } catch (err) {
499
- console.log(`\n ${C.red}✗ Stake failed:${C.reset} ${err.message}\n`);
500
- }
501
-
502
- rl.close();
503
- }
504
-
505
- // ============================================================================
506
- // Entry Point
507
- // ============================================================================
508
-
509
- module.exports = { stakeCommand };
510
-
511
- if (require.main === module) {
512
- stakeCommand().catch(err => {
513
- console.error(`\n${C.red}✗ Stake command failed:${C.reset} ${err.message}`);
514
- process.exit(1);
515
- });
516
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli stake
4
+ *
5
+ * First-class stake command - stake AETH to a validator.
6
+ * Fully wired to @jellylegsai/aether-sdk for real blockchain RPC calls.
7
+ *
8
+ * Usage:
9
+ * aether stake --validator <addr> --amount <aeth> [--address <wallet>]
10
+ * aether stake --validator ATHxxx... --amount 1000
11
+ * aether stake --validator ATHxxx... --amount 1000 --address ATHxxx...
12
+ * aether stake --list-validators # Show available validators to stake to
13
+ * aether stake --dry-run # Preview without submitting
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const readline = require('readline');
20
+ const nacl = require('tweetnacl');
21
+ const bs58 = require('bs58').default;
22
+ const bip39 = require('bip39');
23
+
24
+ // Import SDK for real blockchain RPC calls
25
+ const sdkPath = path.join(__dirname, '..', 'sdk', 'index.js');
26
+ const aether = require(sdkPath);
27
+
28
+ // Import UI framework for consistent branding
29
+ const { BRANDING, C, indicators, startSpinner, stopSpinner, drawBox, drawTable,
30
+ success, error, warning, info, code, highlight, value } = require('../lib/ui');
31
+
32
+ const CLI_VERSION = '2.0.0';
33
+ const DERIVATION_PATH = "m/44'/7777777'/0'/0'";
34
+
35
+ // ============================================================================
36
+ // SDK Setup
37
+ // ============================================================================
38
+
39
+ function getDefaultRpc() {
40
+ return process.env.AETHER_RPC || aether.DEFAULT_RPC_URL || 'http://127.0.0.1:8899';
41
+ }
42
+
43
+ function createClient(rpcUrl) {
44
+ return new aether.AetherClient({ rpcUrl });
45
+ }
46
+
47
+ // ============================================================================
48
+ // Config & Wallet
49
+ // ============================================================================
50
+
51
+ function getAetherDir() {
52
+ return path.join(os.homedir(), '.aether');
53
+ }
54
+
55
+ function getConfigPath() {
56
+ return path.join(getAetherDir(), 'config.json');
57
+ }
58
+
59
+ function loadConfig() {
60
+ if (!fs.existsSync(getConfigPath())) {
61
+ return { defaultWallet: null };
62
+ }
63
+ try {
64
+ return JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
65
+ } catch {
66
+ return { defaultWallet: null };
67
+ }
68
+ }
69
+
70
+ function loadWallet(address) {
71
+ const fp = path.join(getAetherDir(), 'wallets', `${address}.json`);
72
+ if (!fs.existsSync(fp)) return null;
73
+ try {
74
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ // ============================================================================
81
+ // Crypto Helpers
82
+ // ============================================================================
83
+
84
+ function deriveKeypair(mnemonic) {
85
+ if (!bip39.validateMnemonic(mnemonic)) {
86
+ throw new Error('Invalid mnemonic phrase');
87
+ }
88
+ const seedBuffer = bip39.mnemonicToSeedSync(mnemonic, '');
89
+ const seed32 = seedBuffer.slice(0, 32);
90
+ const keyPair = nacl.sign.keyPair.fromSeed(seed32);
91
+ return {
92
+ publicKey: Buffer.from(keyPair.publicKey),
93
+ secretKey: Buffer.from(keyPair.secretKey),
94
+ };
95
+ }
96
+
97
+ function formatAddress(publicKey) {
98
+ return 'ATH' + bs58.encode(publicKey);
99
+ }
100
+
101
+ function signTransaction(tx, secretKey) {
102
+ const txBytes = Buffer.from(JSON.stringify(tx));
103
+ const sig = nacl.sign.detached(txBytes, secretKey);
104
+ return bs58.encode(sig);
105
+ }
106
+
107
+ // ============================================================================
108
+ // Format Helpers
109
+ // ============================================================================
110
+
111
+ function formatAether(lamports) {
112
+ const aeth = Number(lamports) / 1e9;
113
+ if (aeth === 0) return '0 AETH';
114
+ return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
115
+ }
116
+
117
+ function shortAddress(addr) {
118
+ if (!addr || addr.length < 16) return addr || 'unknown';
119
+ return addr.slice(0, 8) + '...' + addr.slice(-8);
120
+ }
121
+
122
+ function formatPercent(val) {
123
+ if (val === undefined || val === null) return 'N/A';
124
+ return val.toFixed(2) + '%';
125
+ }
126
+
127
+ // ============================================================================
128
+ // Readline Helpers
129
+ // ============================================================================
130
+
131
+ function createRl() {
132
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
133
+ }
134
+
135
+ function question(rl, q) {
136
+ return new Promise((res) => rl.question(q, res));
137
+ }
138
+
139
+ async function askMnemonic(rl, promptText) {
140
+ console.log(`\n${C.cyan}${promptText}${C.reset}`);
141
+ console.log(`${C.dim}Enter your 12 or 24-word passphrase:${C.reset}`);
142
+ const raw = await question(rl, ` > ${C.reset}`);
143
+ return raw.trim().toLowerCase();
144
+ }
145
+
146
+ // ============================================================================
147
+ // Fetch Validators via SDK
148
+ // ============================================================================
149
+
150
+ async function fetchValidators(rpcUrl) {
151
+ const client = createClient(rpcUrl);
152
+
153
+ try {
154
+ const validators = await client.getValidators();
155
+ if (!Array.isArray(validators)) return [];
156
+ return validators.map(v => ({
157
+ address: v.vote_account || v.pubkey || v.address || v.identity,
158
+ identity: v.identity || v.node_pubkey,
159
+ stake: v.stake_lamports || v.activated_stake || v.stake || 0,
160
+ commission: v.commission || v.commission_bps || 0,
161
+ apy: v.apy || v.return_rate || 0,
162
+ name: v.name || v.moniker || 'Unknown',
163
+ tier: v.tier || 'unknown',
164
+ active: v.active !== false && v.delinquent !== true,
165
+ }));
166
+ } catch (err) {
167
+ return [];
168
+ }
169
+ }
170
+
171
+ // ============================================================================
172
+ // Show Help
173
+ // ============================================================================
174
+
175
+ function showHelp() {
176
+ console.log(BRANDING.commandBanner('stake', 'Stake AETH to a validator'));
177
+
178
+ console.log(`\n ${C.bright}USAGE${C.reset}`);
179
+ console.log(` ${code('aether stake --validator <addr> --amount <aeth> [--address <wallet>]')}`);
180
+
181
+ console.log(`\n ${C.bright}REQUIRED${C.reset}`);
182
+ console.log(` ${code('--validator <addr>')} Validator address to stake to`);
183
+ console.log(` ${code('--amount <aeth>')} Amount to stake in AETH`);
184
+
185
+ console.log(`\n ${C.bright}OPTIONS${C.reset}`);
186
+ console.log(` ${code('--address <addr>')} Wallet address (default: configured default)`);
187
+ console.log(` ${code('--rpc <url>')} RPC endpoint (default: AETHER_RPC env or localhost:8899)`);
188
+ console.log(` ${code('--json')} Output JSON for scripting`);
189
+ console.log(` ${code('--dry-run')} Preview stake without submitting`);
190
+ console.log(` ${code('--list-validators')} Show available validators on the network`);
191
+ console.log(` ${code('--force')} Skip confirmation prompts`);
192
+
193
+ console.log(`\n ${C.bright}SDK METHODS USED${C.reset}`);
194
+ console.log(` ${C.dim}client.getValidators() GET /v1/validators${C.reset}`);
195
+ console.log(` ${C.dim}client.getAccountInfo() GET /v1/account/<addr>${C.reset}`);
196
+ console.log(` ${C.dim}client.getSlot() → GET /v1/slot${C.reset}`);
197
+ console.log(` ${C.dim}client.sendTransaction() → POST /v1/transaction${C.reset}`);
198
+
199
+ console.log(`\n ${C.bright}EXAMPLES${C.reset}`);
200
+ console.log(` ${C.dim}$${C.reset} ${code('aether stake --validator ATHxxx... --amount 1000')}`);
201
+ console.log(` ${C.dim}$${C.reset} ${code('aether stake --validator ATHxxx... --amount 1000 --address ATHxxx...')}`);
202
+ console.log(` ${C.dim}$${C.reset} ${code('aether stake --list-validators')}`);
203
+ console.log(` ${C.dim}$${C.reset} ${code('aether stake --validator ATHxxx... --amount 1000 --dry-run')}`);
204
+
205
+ console.log(`\n ${C.bright}MINIMUM STAKE AMOUNTS${C.reset}`);
206
+ console.log(` Full: 10,000 AETH`);
207
+ console.log(` Lite: 1,000 AETH`);
208
+ console.log(` Observer: 0 AETH`);
209
+
210
+ console.log(`\n ${C.bright}NOTES${C.reset}`);
211
+ console.log(` ${C.dim}◆ Staked AETH begins earning rewards after one epoch (~2 days)${C.reset}`);
212
+ console.log(` ${C.dim}◆ Use 'aether stake-positions' to view your delegations${C.reset}`);
213
+ console.log(` ${C.dim} Use 'aether unstake' to withdraw (has cooldown period)${C.reset}`);
214
+ console.log();
215
+ }
216
+
217
+ // ============================================================================
218
+ // List Validators Command
219
+ // ============================================================================
220
+
221
+ async function listValidatorsCommand(opts) {
222
+ if (!opts.json) {
223
+ console.log(BRANDING.commandBanner('stake --list-validators', 'Available Validators'));
224
+ console.log(`\n ${C.dim}Fetching validators from ${opts.rpc}...${C.reset}\n`);
225
+ startSpinner('Querying validators');
226
+ }
227
+
228
+ const validators = await fetchValidators(opts.rpc);
229
+
230
+ if (!opts.json) {
231
+ stopSpinner(true, 'Validators retrieved');
232
+ }
233
+
234
+ if (validators.length === 0) {
235
+ if (opts.json) {
236
+ console.log(JSON.stringify({
237
+ success: false,
238
+ error: 'No validators found',
239
+ rpc: opts.rpc,
240
+ }, null, 2));
241
+ } else {
242
+ console.log(`\n ${warning('No validators found.')}`);
243
+ console.log(` ${C.dim}Check your RPC endpoint: ${opts.rpc}${C.reset}\n`);
244
+ }
245
+ return;
246
+ }
247
+
248
+ validators.sort((a, b) => b.stake - a.stake);
249
+
250
+ if (opts.json) {
251
+ console.log(JSON.stringify({
252
+ success: true,
253
+ count: validators.length,
254
+ validators: validators.map(v => ({
255
+ address: v.address,
256
+ name: v.name,
257
+ stake_aeth: v.stake / 1e9,
258
+ apy: v.apy,
259
+ tier: v.tier,
260
+ })),
261
+ }, null, 2));
262
+ return;
263
+ }
264
+
265
+ // Build table rows
266
+ const rows = validators.slice(0, 15).map((v, i) => {
267
+ const status = v.active ? indicators.success : indicators.warning;
268
+ const name = (v.name || 'Unknown').slice(0, 20);
269
+ const addr = shortAddress(v.address);
270
+ const stake = formatAether(v.stake);
271
+ const apy = formatPercent(v.apy);
272
+ return [status, `${i + 1}`, name, addr, stake, apy];
273
+ });
274
+
275
+ console.log(`\n ${C.bright}Found ${C.cyan}${validators.length}${C.reset} validators\n`);
276
+
277
+ console.log(drawTable(
278
+ ['', '#', 'Name', 'Address', 'Stake', 'APY'],
279
+ rows,
280
+ { borderStyle: 'single', headerColor: C.cyan + C.bright }
281
+ ));
282
+
283
+ if (validators.length > 15) {
284
+ console.log(`\n ${C.dim}... and ${validators.length - 15} more validators (use --json for full list)${C.reset}`);
285
+ }
286
+
287
+ console.log(`\n ${C.dim}To stake: ${code('aether stake --validator <addr> --amount <aeth>')}${C.reset}\n`);
288
+ }
289
+
290
+ // ============================================================================
291
+ // Main Stake Logic
292
+ // ============================================================================
293
+
294
+ async function stakeCommand() {
295
+ const opts = {
296
+ validator: null,
297
+ amount: null,
298
+ address: null,
299
+ rpc: getDefaultRpc(),
300
+ json: false,
301
+ dryRun: false,
302
+ listValidators: false,
303
+ force: false,
304
+ };
305
+
306
+ // Parse args
307
+ const args = process.argv.slice(3);
308
+ for (let i = 0; i < args.length; i++) {
309
+ const arg = args[i];
310
+ if (arg === '--validator' || arg === '-v') opts.validator = args[++i];
311
+ else if (arg === '--amount' || arg === '-m') {
312
+ const val = parseFloat(args[++i]);
313
+ if (!isNaN(val) && val > 0) opts.amount = val;
314
+ }
315
+ else if (arg === '--address' || arg === '-a') opts.address = args[++i];
316
+ else if (arg === '--rpc' || arg === '-r') opts.rpc = args[++i];
317
+ else if (arg === '--json' || arg === '-j') opts.json = true;
318
+ else if (arg === '--dry-run') opts.dryRun = true;
319
+ else if (arg === '--list-validators' || arg === '-l') opts.listValidators = true;
320
+ else if (arg === '--force' || arg === '-f') opts.force = true;
321
+ else if (arg === '--help' || arg === '-h') {
322
+ showHelp();
323
+ return;
324
+ }
325
+ }
326
+
327
+ // List validators mode
328
+ if (opts.listValidators) {
329
+ await listValidatorsCommand(opts);
330
+ return;
331
+ }
332
+
333
+ const rl = createRl();
334
+
335
+ // Resolve wallet address
336
+ if (!opts.address) {
337
+ const cfg = loadConfig();
338
+ opts.address = cfg.defaultWallet;
339
+ }
340
+
341
+ if (!opts.address) {
342
+ console.log(`\n ${error('No wallet address.')} Use --address <addr> or set a default.`);
343
+ console.log(` ${C.dim}Usage: aether stake --validator <addr> --amount <aeth>${C.reset}\n`);
344
+ rl.close();
345
+ return;
346
+ }
347
+
348
+ // Check wallet exists
349
+ const wallet = loadWallet(opts.address);
350
+ if (!wallet) {
351
+ console.log(`\n ${error('Wallet not found:')} ${opts.address}`);
352
+ console.log(` ${C.dim}Import it: ${code('aether wallet import')}${C.reset}\n`);
353
+ rl.close();
354
+ return;
355
+ }
356
+
357
+ // Fetch balance via SDK
358
+ let balance = 0;
359
+ const client = createClient(opts.rpc);
360
+ const rawAddr = opts.address.startsWith('ATH') ? opts.address.slice(3) : opts.address;
361
+
362
+ if (!opts.json) {
363
+ startSpinner('Fetching wallet balance');
364
+ }
365
+
366
+ try {
367
+ const account = await client.getAccountInfo(rawAddr);
368
+ balance = account.lamports || 0;
369
+ } catch (err) {
370
+ if (!opts.json) console.log(` ${warning('Could not fetch balance:')} ${err.message}`);
371
+ }
372
+
373
+ if (!opts.json) {
374
+ stopSpinner(true, 'Balance retrieved');
375
+ }
376
+
377
+ // Interactive validator selection
378
+ let validator = opts.validator;
379
+ if (!validator) {
380
+ if (!opts.json) {
381
+ console.log(`\n ${C.dim}Fetching validators...${C.reset}`);
382
+ startSpinner('Querying network');
383
+ }
384
+
385
+ const validators = await fetchValidators(opts.rpc);
386
+
387
+ if (!opts.json) {
388
+ stopSpinner(true, 'Validators retrieved');
389
+ }
390
+
391
+ if (validators.length === 0) {
392
+ console.log(`\n ${error('No validators found.')}\n`);
393
+ rl.close();
394
+ return;
395
+ }
396
+
397
+ validators.sort((a, b) => b.stake - a.stake);
398
+ console.log(`\n ${C.bright}Select a validator:${C.reset}\n`);
399
+
400
+ validators.slice(0, 10).forEach((v, i) => {
401
+ const name = (v.name || 'Unknown').slice(0, 18).padEnd(18);
402
+ const stake = formatAether(v.stake);
403
+ const apy = formatPercent(v.apy);
404
+ console.log(` ${C.green}${i + 1})${C.reset} ${name} | ${stake} | ${apy}`);
405
+ });
406
+
407
+ console.log(`\n ${C.dim}Enter number [1-10] or validator address${C.reset}`);
408
+ const choice = await question(rl, ` Validator > ${C.reset}`);
409
+ const choiceNum = parseInt(choice.trim(), 10);
410
+
411
+ if (!isNaN(choiceNum) && choiceNum >= 1 && choiceNum <= 10) {
412
+ validator = validators[choiceNum - 1].address;
413
+ } else {
414
+ validator = choice.trim();
415
+ }
416
+ }
417
+
418
+ // Resolve amount
419
+ let amount = opts.amount;
420
+ if (!amount) {
421
+ console.log(`\n ${C.cyan}Available:${C.reset} ${formatAether(balance)}`);
422
+ console.log(` ${C.dim}Minimum: Full=10K, Lite=1K, Observer=0${C.reset}`);
423
+ const amt = await question(rl, ` Amount (AETH) > ${C.reset}`);
424
+ amount = parseFloat(amt);
425
+ if (isNaN(amount) || amount <= 0) {
426
+ console.log(`\n ${error('Invalid amount.')}\n`);
427
+ rl.close();
428
+ return;
429
+ }
430
+ }
431
+
432
+ const stakeLamports = Math.round(amount * 1e9);
433
+ const feeBuffer = 0.005 * 1e9;
434
+
435
+ if (stakeLamports + feeBuffer > balance) {
436
+ console.log(`\n ${error('Insufficient balance.')}`);
437
+ console.log(` Requested: ${formatAether(stakeLamports)}`);
438
+ console.log(` Balance: ${formatAether(balance)}\n`);
439
+ rl.close();
440
+ return;
441
+ }
442
+
443
+ // Display summary in a box
444
+ if (!opts.json) {
445
+ console.log();
446
+ console.log(drawBox(
447
+ `
448
+ ${C.bright}STAKE SUMMARY${C.reset}
449
+
450
+ ${C.cyan}Wallet:${C.reset} ${C.bright}${opts.address}${C.reset}
451
+ ${C.cyan}Validator:${C.reset} ${C.bright}${shortAddress(validator)}${C.reset}
452
+ ${C.cyan}Amount:${C.reset} ${C.green}${formatAether(stakeLamports)}${C.reset}
453
+ ${C.cyan}Balance:${C.reset} ${formatAether(balance)}
454
+ `.trim(),
455
+ { style: 'single', title: 'TRANSACTION', titleColor: C.cyan }
456
+ ));
457
+ console.log();
458
+ }
459
+
460
+ // Dry run
461
+ if (opts.dryRun) {
462
+ console.log(JSON.stringify({
463
+ dry_run: true,
464
+ wallet: opts.address,
465
+ validator: validator,
466
+ stake_lamports: stakeLamports,
467
+ stake_aeth: amount,
468
+ balance_aeth: balance / 1e9,
469
+ rpc: opts.rpc,
470
+ cli_version: CLI_VERSION,
471
+ }, null, 2));
472
+ rl.close();
473
+ return;
474
+ }
475
+
476
+ // Sign
477
+ console.log(`${warning('Signing requires your wallet passphrase.')}`);
478
+ const mnemonic = await askMnemonic(rl, 'Enter passphrase to sign this transaction');
479
+
480
+ let keyPair;
481
+ try {
482
+ keyPair = deriveKeypair(mnemonic);
483
+ } catch (e) {
484
+ console.log(`\n ${error('Failed to derive keypair:')} ${e.message}\n`);
485
+ rl.close();
486
+ return;
487
+ }
488
+
489
+ const derivedAddress = formatAddress(keyPair.publicKey);
490
+ if (derivedAddress !== opts.address) {
491
+ console.log(`\n ${error('Passphrase mismatch!')}`);
492
+ console.log(` Expected: ${opts.address}`);
493
+ console.log(` Derived: ${derivedAddress}\n`);
494
+ rl.close();
495
+ return;
496
+ }
497
+
498
+ // Confirm
499
+ if (!opts.force) {
500
+ const confirm = await question(rl, `\n ${warning('Confirm stake? [y/N]')} > `);
501
+ if (!confirm.trim().toLowerCase().startsWith('y')) {
502
+ console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
503
+ rl.close();
504
+ return;
505
+ }
506
+ }
507
+
508
+ // Get slot and build transaction
509
+ let slot = 0;
510
+ try { slot = await client.getSlot(); } catch (e) {}
511
+
512
+ const tx = {
513
+ signer: rawAddr,
514
+ tx_type: 'Stake',
515
+ payload: {
516
+ type: 'Stake',
517
+ data: { validator: validator, amount: stakeLamports },
518
+ },
519
+ fee: 5000,
520
+ slot: slot,
521
+ timestamp: Math.floor(Date.now() / 1000),
522
+ };
523
+
524
+ tx.signature = signTransaction(tx, keyPair.secretKey);
525
+
526
+ if (!opts.json) {
527
+ console.log(`\n ${C.dim}Submitting to ${opts.rpc}...${C.reset}`);
528
+ startSpinner('Submitting transaction');
529
+ }
530
+
531
+ try {
532
+ const result = await client.sendTransaction(tx);
533
+ if (result.error) throw new Error(result.error);
534
+
535
+ if (!opts.json) {
536
+ stopSpinner(true, 'Transaction submitted');
537
+
538
+ console.log();
539
+ console.log(BRANDING.successBanner('Stake transaction submitted!'));
540
+ console.log(`\n ${C.cyan}Wallet:${C.reset} ${opts.address}`);
541
+ console.log(` ${C.cyan}Validator:${C.reset} ${validator}`);
542
+ console.log(` ${C.cyan}Amount:${C.reset} ${C.bright}${formatAether(stakeLamports)}${C.reset}`);
543
+ if (result.signature) console.log(` ${C.cyan}Signature:${C.reset} ${result.signature.slice(0, 40)}...`);
544
+ console.log(` ${C.cyan}Slot:${C.reset} ${result.slot || slot}`);
545
+ console.log();
546
+ console.log(` ${info('Stake will activate in the next epoch')}`);
547
+ console.log(` ${C.dim}Check: ${code(`aether stake-positions --address ${opts.address}`)}${C.reset}\n`);
548
+ } else {
549
+ console.log(JSON.stringify({
550
+ success: true,
551
+ wallet: opts.address,
552
+ validator: validator,
553
+ amount: stakeLamports,
554
+ signature: result.signature,
555
+ slot: result.slot || slot,
556
+ }, null, 2));
557
+ }
558
+ } catch (err) {
559
+ if (!opts.json) {
560
+ stopSpinner(false, 'Transaction failed');
561
+ console.log(`\n ${error('Stake failed:')} ${err.message}\n`);
562
+ } else {
563
+ console.log(JSON.stringify({ success: false, error: err.message }, null, 2));
564
+ }
565
+ }
566
+
567
+ rl.close();
568
+ }
569
+
570
+ // ============================================================================
571
+ // Entry Point
572
+ // ============================================================================
573
+
574
+ module.exports = { stakeCommand };
575
+
576
+ if (require.main === module) {
577
+ stakeCommand().catch(err => {
578
+ console.error(`\n${error('Stake command failed:')} ${err.message}`);
579
+ process.exit(1);
580
+ });
581
+ }