aether-hub 1.2.4 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,727 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli multisig
4
+ *
5
+ * Multi-signature wallet management for Aether.
6
+ * Create 2-of-3, 3-of-5, or any M-of-N multisig wallets,
7
+ * add signers, view threshold info, and send transactions.
8
+ *
9
+ * Usage:
10
+ * aether multisig create --threshold <m> --signers <addr1,addr2,...>
11
+ * aether multisig list List all multisig wallets
12
+ * aether multisig info --address <addr> Show threshold, signers, balance
13
+ * aether multisig add-signer --address <addr> --signer <newAddr>
14
+ * aether multisig send --address <addr> --to <recipient> --amount <aeth> [--json]
15
+ *
16
+ * Requires AETHER_RPC env var or local node (default: http://127.0.0.1:8899)
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const readline = require('readline');
23
+ const crypto = require('crypto');
24
+ const bs58 = require('bs58').default;
25
+ const bip39 = require('bip39');
26
+ const nacl = require('tweetnacl');
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.2.5';
41
+ const MULTISIG_VERSION = 1;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Paths & config
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function getAetherDir() {
48
+ return path.join(os.homedir(), '.aether');
49
+ }
50
+
51
+ function getMultisigDir() {
52
+ return path.join(getAetherDir(), 'multisig');
53
+ }
54
+
55
+ function ensureDir(p) {
56
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
57
+ }
58
+
59
+ function getMultisigFilePath(address) {
60
+ return path.join(getMultisigDir(), `${address}.json`);
61
+ }
62
+
63
+ function loadConfig() {
64
+ const p = path.join(getAetherDir(), 'config.json');
65
+ if (!fs.existsSync(p)) return { defaultWallet: null };
66
+ try {
67
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
68
+ } catch {
69
+ return { defaultWallet: null };
70
+ }
71
+ }
72
+
73
+ function loadWallet(address) {
74
+ const fp = path.join(getAetherDir(), 'wallets', `${address}.json`);
75
+ if (!fs.existsSync(fp)) return null;
76
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Crypto helpers
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function deriveKeypair(mnemonic) {
84
+ if (!bip39.validateMnemonic(mnemonic)) throw new Error('Invalid mnemonic phrase.');
85
+ const seedBuffer = bip39.mnemonicToSeedSync(mnemonic, '');
86
+ const seed32 = seedBuffer.slice(0, 32);
87
+ const keyPair = nacl.sign.keyPair.fromSeed(seed32);
88
+ return { publicKey: Buffer.from(keyPair.publicKey), secretKey: Buffer.from(keyPair.secretKey) };
89
+ }
90
+
91
+ function formatAddress(publicKey) {
92
+ return 'ATH' + bs58.encode(publicKey);
93
+ }
94
+
95
+ function shortAddress(addr) {
96
+ if (!addr || addr.length < 16) return addr;
97
+ return addr.slice(0, 8) + '…' + addr.slice(-8);
98
+ }
99
+
100
+ function isValidAddress(addr) {
101
+ return addr && addr.startsWith('ATH') && addr.length >= 36;
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // HTTP helpers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function getDefaultRpc() {
109
+ return process.env.AETHER_RPC || 'http://127.0.0.1:8899';
110
+ }
111
+
112
+ function httpRequest(rpcUrl, pathStr, timeoutMs = 10000) {
113
+ return new Promise((resolve, reject) => {
114
+ const url = new URL(pathStr, rpcUrl);
115
+ const lib = url.protocol === 'https:' ? require('https') : require('http');
116
+ const req = lib.request({
117
+ hostname: url.hostname,
118
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
119
+ path: url.pathname + url.search,
120
+ method: 'GET',
121
+ timeout: timeoutMs,
122
+ headers: { 'Content-Type': 'application/json' },
123
+ }, (res) => {
124
+ let data = '';
125
+ res.on('data', (chunk) => data += chunk);
126
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ _raw: data }); } });
127
+ });
128
+ req.on('error', reject);
129
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
130
+ req.end();
131
+ });
132
+ }
133
+
134
+ function httpPost(rpcUrl, pathStr, body, timeoutMs = 15000) {
135
+ return new Promise((resolve, reject) => {
136
+ const url = new URL(pathStr, rpcUrl);
137
+ const lib = url.protocol === 'https:' ? require('https') : require('http');
138
+ const bodyStr = JSON.stringify(body);
139
+ const req = lib.request({
140
+ hostname: url.hostname,
141
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
142
+ path: url.pathname + url.search,
143
+ method: 'POST',
144
+ timeout: timeoutMs,
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ 'Content-Length': Buffer.byteLength(bodyStr),
148
+ },
149
+ }, (res) => {
150
+ let data = '';
151
+ res.on('data', (chunk) => data += chunk);
152
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ _raw: data }); } });
153
+ });
154
+ req.on('error', reject);
155
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
156
+ req.write(bodyStr);
157
+ req.end();
158
+ });
159
+ }
160
+
161
+ function formatAether(lamports) {
162
+ const aeth = lamports / 1e9;
163
+ if (aeth === 0) return '0 AETH';
164
+ return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Multisig address derivation
169
+ // Derived from threshold M and signer list — sorted lexicographically
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Derive a deterministic multisig address from signers + threshold.
174
+ * Uses SHA-512 of sorted(signers) + threshold as the seed for a keypair.
175
+ * This gives a deterministic address without requiring on-chain registration.
176
+ */
177
+ function deriveMultisigAddress(signers, threshold) {
178
+ // Sort signers lexicographically for deterministic derivation
179
+ const sortedSigners = [...signers].sort();
180
+ const data = JSON.stringify({ signers: sortedSigners, threshold, v: MULTISIG_VERSION });
181
+ const hash = crypto.createHash('sha512').update(data).digest();
182
+ // Use first 32 bytes as seed for nacl keypair
183
+ const seed32 = hash.slice(0, 32);
184
+ const keyPair = nacl.sign.keyPair.fromSeed(seed32);
185
+ return {
186
+ address: formatAddress(Buffer.from(keyPair.publicKey)),
187
+ publicKey: Buffer.from(keyPair.publicKey),
188
+ };
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Multisig storage
193
+ // ---------------------------------------------------------------------------
194
+
195
+ function saveMultisig(ms) {
196
+ ensureDir(getMultisigDir());
197
+ const fp = getMultisigFilePath(ms.address);
198
+ fs.writeFileSync(fp, JSON.stringify(ms, null, 2));
199
+ }
200
+
201
+ function loadMultisig(address) {
202
+ const fp = getMultisigFilePath(address);
203
+ if (!fs.existsSync(fp)) return null;
204
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
205
+ }
206
+
207
+ function listAllMultisig() {
208
+ ensureDir(getMultisigDir());
209
+ const files = fs.readdirSync(getMultisigDir()).filter(f => f.endsWith('.json'));
210
+ const result = [];
211
+ for (const f of files) {
212
+ try {
213
+ const ms = JSON.parse(fs.readFileSync(path.join(getMultisigDir(), f), 'utf8'));
214
+ result.push(ms);
215
+ } catch {}
216
+ }
217
+ return result.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Readline helpers
222
+ // ---------------------------------------------------------------------------
223
+
224
+ function createRl() {
225
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
226
+ }
227
+
228
+ function question(rl, q) {
229
+ return new Promise((res) => rl.question(q, res));
230
+ }
231
+
232
+ function askMnemonic(rl, label = 'wallet passphrase') {
233
+ return new Promise(async (res) => {
234
+ console.log(`\n${C.cyan}Enter your 12/24-word ${label}:${C.reset}`);
235
+ console.log(`${C.dim}One space-separated line:${C.reset}`);
236
+ const raw = await question(rl, ` > ${C.reset}`);
237
+ res(raw.trim().toLowerCase());
238
+ });
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // CREATE MULTISIG
243
+ // ---------------------------------------------------------------------------
244
+
245
+ async function createMultisig(rl, args) {
246
+ console.log(`\n${C.bright}${C.cyan}── Create Multi-Signature Wallet ─────────────────────────${C.reset}\n`);
247
+
248
+ // Parse --threshold and --signers from args
249
+ let threshold = null;
250
+ let signers = [];
251
+
252
+ for (let i = 0; i < args.length; i++) {
253
+ if ((args[i] === '--threshold' || args[i] === '-t') && args[i + 1]) {
254
+ threshold = parseInt(args[i + 1], 10);
255
+ }
256
+ if ((args[i] === '--signers' || args[i] === '-s') && args[i + 1]) {
257
+ signers = args[i + 1].split(',').map(s => s.trim()).filter(Boolean);
258
+ }
259
+ }
260
+
261
+ // Interactive prompts for missing values
262
+ if (signers.length === 0) {
263
+ console.log(` ${C.cyan}Enter signer addresses (ATH...), separated by commas.${C.reset}`);
264
+ console.log(` ${C.dim}Example: ATHabc...,ATHdef...,ATHghi...${C.reset}`);
265
+ const rawSigners = await question(rl, ` Signers: ${C.reset}`);
266
+ signers = rawSigners.split(',').map(s => s.trim()).filter(s => s.length > 0);
267
+ }
268
+
269
+ if (signers.length < 2) {
270
+ console.log(` ${C.red}✗ A multisig wallet requires at least 2 signers.${C.reset}\n`);
271
+ return;
272
+ }
273
+
274
+ // Validate all signer addresses
275
+ const invalidSigners = signers.filter(s => !isValidAddress(s));
276
+ if (invalidSigners.length > 0) {
277
+ console.log(` ${C.red}✗ Invalid signer addresses:${C.reset} ${invalidSigners.join(', ')}`);
278
+ console.log(` ${C.dim}All signers must start with 'ATH' and be at least 36 characters.${C.reset}\n`);
279
+ return;
280
+ }
281
+
282
+ // Deduplicate
283
+ const uniqueSigners = [...new Set(signers)];
284
+ if (uniqueSigners.length !== signers.length) {
285
+ console.log(` ${C.yellow}⚠ Duplicate signers removed.${C.reset}`);
286
+ signers = uniqueSigners;
287
+ }
288
+
289
+ if (threshold === null) {
290
+ const defaultThreshold = Math.max(2, Math.ceil(signers.length / 2));
291
+ const rawThresh = await question(rl, ` ${C.cyan}Threshold (M, required signatures)${C.reset} [${defaultThreshold}]: ${C.reset}`);
292
+ threshold = rawThresh.trim() ? parseInt(rawThresh.trim(), 10) : defaultThreshold;
293
+ }
294
+
295
+ if (isNaN(threshold) || threshold < 1 || threshold > signers.length) {
296
+ console.log(` ${C.red}✗ Invalid threshold:${C.reset} ${threshold}. Must be between 1 and ${signers.length}.\n`);
297
+ return;
298
+ }
299
+
300
+ console.log(`\n ${C.green}★${C.reset} Signers (${signers.length}):`);
301
+ for (const s of signers) {
302
+ console.log(` ${C.cyan}${shortAddress(s)}${C.reset}`);
303
+ }
304
+ console.log(` ${C.green}★${C.reset} Threshold: ${C.bright}${threshold} of ${signers.length}${C.reset}`);
305
+ console.log();
306
+
307
+ const confirm = await question(rl, ` ${C.yellow}Create multisig wallet? [y/N]${C.reset} > ${C.reset}`);
308
+ if (!confirm.trim().toLowerCase().startsWith('y')) {
309
+ console.log(` ${C.dim}Cancelled.${C.reset}\n`);
310
+ return;
311
+ }
312
+
313
+ const { address, publicKey } = deriveMultisigAddress(signers, threshold);
314
+
315
+ const ms = {
316
+ version: MULTISIG_VERSION,
317
+ address,
318
+ public_key: bs58.encode(publicKey),
319
+ threshold,
320
+ signers,
321
+ created_at: new Date().toISOString(),
322
+ derivation: 'off-chain deterministic',
323
+ description: '',
324
+ };
325
+
326
+ saveMultisig(ms);
327
+
328
+ console.log(`\n${C.green}✓ Multi-signature wallet created!${C.reset}`);
329
+ console.log(` ${C.green}★${C.reset} Address: ${C.bright}${address}${C.reset}`);
330
+ console.log(` ${C.green}★${C.reset} Threshold: ${threshold}/${signers.length}`);
331
+ console.log(` ${C.dim} Saved to: ${getMultisigFilePath(address)}${C.reset}`);
332
+ console.log(` ${C.dim} Use: aether multisig send --address ${address}${C.reset}\n`);
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // LIST MULTISIG
337
+ // ---------------------------------------------------------------------------
338
+
339
+ async function listMultisig(rl, args) {
340
+ console.log(`\n${C.bright}${C.cyan}── Multi-Signature Wallets ────────────────────────────${C.reset}\n`);
341
+
342
+ const all = listAllMultisig();
343
+
344
+ if (all.length === 0) {
345
+ console.log(` ${C.dim}No multisig wallets found.${C.reset}`);
346
+ console.log(` ${C.dim}Create one with:${C.reset} ${C.cyan}aether multisig create --threshold 2 --signers addr1,addr2,addr3${C.reset}\n`);
347
+ return;
348
+ }
349
+
350
+ const rpcUrl = getDefaultRpc();
351
+ console.log(` ${C.dim}Location: ${getMultisigDir()}${C.reset}\n`);
352
+
353
+ for (const ms of all) {
354
+ const shortAddr = shortAddress(ms.address);
355
+ console.log(` ${C.bright}${C.cyan}${ms.address}${C.reset}`);
356
+ console.log(` ${C.dim} Threshold: ${ms.threshold}/${ms.signers.length} Signers: ${ms.signers.length}${C.reset}`);
357
+ console.log(` ${C.dim} Created: ${new Date(ms.created_at).toLocaleString()}${C.reset}`);
358
+
359
+ // Fetch on-chain balance
360
+ try {
361
+ const rawAddr = ms.address.startsWith('ATH') ? ms.address.slice(3) : ms.address;
362
+ const account = await httpRequest(rpcUrl, `/v1/account/${rawAddr}`);
363
+ if (!account.error && account.lamports !== undefined) {
364
+ console.log(` ${C.green}✓ Balance:${C.reset} ${C.bright}${formatAether(account.lamports)}${C.reset}`);
365
+ }
366
+ } catch {}
367
+
368
+ console.log();
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // INFO
374
+ // ---------------------------------------------------------------------------
375
+
376
+ async function infoMultisig(rl, args) {
377
+ console.log(`\n${C.bright}${C.cyan}── Multi-Signature Wallet Info ─────────────────────────${C.reset}\n`);
378
+
379
+ let address = null;
380
+ for (let i = 0; i < args.length; i++) {
381
+ if ((args[i] === '--address' || args[i] === '-a') && args[i + 1]) {
382
+ address = args[i + 1];
383
+ }
384
+ }
385
+
386
+ if (!address) {
387
+ // Try default wallet
388
+ const cfg = loadConfig();
389
+ address = cfg.defaultWallet;
390
+ }
391
+
392
+ if (!address) {
393
+ console.log(` ${C.red}✗ No address specified.${C.reset}`);
394
+ console.log(` ${C.dim}Usage: aether multisig info --address <addr>${C.reset}\n`);
395
+ return;
396
+ }
397
+
398
+ const ms = loadMultisig(address);
399
+ if (!ms) {
400
+ console.log(` ${C.red}✗ Multisig wallet not found:${C.reset} ${address}`);
401
+ console.log(` ${C.dim}Check your wallets: aether multisig list${C.reset}\n`);
402
+ return;
403
+ }
404
+
405
+ const rpcUrl = getDefaultRpc();
406
+ console.log(` ${C.green}★${C.reset} Address: ${C.bright}${ms.address}${C.reset}`);
407
+ console.log(` ${C.green}★${C.reset} Threshold: ${C.bright}${ms.threshold} of ${ms.signers.length}${C.reset}`);
408
+ console.log(` ${C.dim} Public key: ${ms.public_key}${C.reset}`);
409
+ console.log(` ${C.dim} Created: ${new Date(ms.created_at).toLocaleString()}${C.reset}`);
410
+ console.log(` ${C.dim} Version: ${ms.version}${C.reset}`);
411
+ console.log();
412
+
413
+ console.log(` ${C.bright}Signers (${ms.signers.length}):${C.reset}`);
414
+ for (let i = 0; i < ms.signers.length; i++) {
415
+ const s = ms.signers[i];
416
+ const isYou = s === loadConfig().defaultWallet;
417
+ const marker = isYou ? ` ${C.green}★ you${C.reset}` : '';
418
+ console.log(` ${i + 1}. ${C.cyan}${s}${C.reset}${marker}`);
419
+ }
420
+ console.log();
421
+
422
+ // On-chain balance
423
+ try {
424
+ const rawAddr = ms.address.startsWith('ATH') ? ms.address.slice(3) : ms.address;
425
+ const account = await httpRequest(rpcUrl, `/v1/account/${rawAddr}`);
426
+ if (!account.error && account.lamports !== undefined) {
427
+ console.log(` ${C.green}✓ Balance:${C.reset} ${C.bright}${formatAether(account.lamports)}${C.reset}`);
428
+ if (account.owner) {
429
+ console.log(` ${C.dim} Owner: ${account.owner}${C.reset}`);
430
+ }
431
+ } else {
432
+ console.log(` ${C.yellow}⚠ Account not found on chain (may have 0 balance).${C.reset}`);
433
+ }
434
+ } catch (err) {
435
+ console.log(` ${C.yellow}⚠ Could not fetch balance: ${err.message}${C.reset}`);
436
+ }
437
+
438
+ console.log();
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // ADD SIGNER
443
+ // ---------------------------------------------------------------------------
444
+
445
+ async function addSignerMultisig(rl, args) {
446
+ console.log(`\n${C.bright}${C.cyan}── Add Signer to Multi-Signature Wallet ─────────────────${C.reset}\n`);
447
+
448
+ let address = null;
449
+ let newSigner = null;
450
+
451
+ for (let i = 0; i < args.length; i++) {
452
+ if ((args[i] === '--address' || args[i] === '-a') && args[i + 1]) address = args[i + 1];
453
+ if ((args[i] === '--signer' || args[i] === '-s') && args[i + 1]) newSigner = args[i + 1];
454
+ }
455
+
456
+ if (!address || !newSigner) {
457
+ console.log(` ${C.red}✗ Missing required arguments.${C.reset}`);
458
+ console.log(` ${C.dim}Usage: aether multisig add-signer --address <msAddr> --signer <newAddr>${C.reset}\n`);
459
+ return;
460
+ }
461
+
462
+ const ms = loadMultisig(address);
463
+ if (!ms) {
464
+ console.log(` ${C.red}✗ Multisig wallet not found:${C.reset} ${address}\n`);
465
+ return;
466
+ }
467
+
468
+ if (!isValidAddress(newSigner)) {
469
+ console.log(` ${C.red}✗ Invalid signer address:${C.reset} ${newSigner}\n`);
470
+ return;
471
+ }
472
+
473
+ if (ms.signers.includes(newSigner)) {
474
+ console.log(` ${C.yellow}⚠ Signer already in wallet:${C.reset} ${newSigner}\n`);
475
+ return;
476
+ }
477
+
478
+ console.log(` ${C.green}★${C.reset} Multisig: ${C.bright}${shortAddress(address)}${C.reset}`);
479
+ console.log(` ${C.green}★${C.reset} Adding: ${C.bright}${shortAddress(newSigner)}${C.reset}`);
480
+ console.log(` ${C.dim} Current threshold: ${ms.threshold}/${ms.signers.length}${C.reset}`);
481
+ console.log();
482
+
483
+ // Re-derive the address with the new signer appended
484
+ const newSigners = [...ms.signers, newSigner];
485
+ const { address: newAddress } = deriveMultisigAddress(newSigners, ms.threshold);
486
+
487
+ const newMs = {
488
+ ...ms,
489
+ address: newAddress, // new address due to signer change
490
+ signers: newSigners,
491
+ updated_at: new Date().toISOString(),
492
+ note: 'Address changed because signers list changed. Old address no longer valid.',
493
+ };
494
+
495
+ saveMultisig(newMs);
496
+
497
+ console.log(`${C.green}✓ Signer added.${C.reset}`);
498
+ console.log(` ${C.yellow}⚠ Important: Changing signers creates a NEW wallet address.${C.reset}`);
499
+ console.log(` ${C.dim} Old address: ${ms.address}${C.reset}`);
500
+ console.log(` ${C.dim} New address: ${newAddress}${C.reset}`);
501
+ console.log(` ${C.dim} Transfer all funds to the new address.${C.reset}`);
502
+ console.log(` ${C.dim} Saved to: ${getMultisigFilePath(newAddress)}${C.reset}\n`);
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // SEND (multi-sig transaction)
507
+ // ---------------------------------------------------------------------------
508
+
509
+ async function sendMultisig(rl, args) {
510
+ console.log(`\n${C.bright}${C.cyan}── Multi-Signature Send ──────────────────────────────────${C.reset}\n`);
511
+
512
+ let address = null;
513
+ let recipient = null;
514
+ let amountStr = null;
515
+ let asJson = false;
516
+
517
+ for (let i = 0; i < args.length; i++) {
518
+ if ((args[i] === '--address' || args[i] === '-a') && args[i + 1]) address = args[i + 1];
519
+ else if ((args[i] === '--to' || args[i] === '-t') && args[i + 1]) recipient = args[i + 1];
520
+ else if ((args[i] === '--amount' || args[i] === '-m') && args[i + 1]) amountStr = args[i + 1];
521
+ else if (args[i] === '--json' || args[i] === '-j') asJson = true;
522
+ }
523
+
524
+ if (!address || !recipient || !amountStr) {
525
+ console.log(` ${C.red}✗ Missing required arguments.${C.reset}`);
526
+ console.log(` ${C.dim}Usage: aether multisig send --address <msAddr> --to <recipient> --amount <aeth>${C.reset}\n`);
527
+ return;
528
+ }
529
+
530
+ const ms = loadMultisig(address);
531
+ if (!ms) {
532
+ console.log(` ${C.red}✗ Multisig wallet not found:${C.reset} ${address}\n`);
533
+ return;
534
+ }
535
+
536
+ const amount = parseFloat(amountStr);
537
+ if (isNaN(amount) || amount <= 0) {
538
+ console.log(` ${C.red}✗ Invalid amount:${C.reset} ${amountStr}\n`);
539
+ return;
540
+ }
541
+
542
+ const lamports = Math.round(amount * 1e9);
543
+ const rpcUrl = getDefaultRpc();
544
+
545
+ console.log(` ${C.green}★${C.reset} Multisig: ${C.bright}${shortAddress(address)}${C.reset} (${ms.threshold}/${ms.signers.length})`);
546
+ console.log(` ${C.green}★${C.reset} To: ${C.bright}${shortAddress(recipient)}${C.reset}`);
547
+ console.log(` ${C.green}★${C.reset} Amount: ${C.bright}${amount} AETH${C.reset} (${lamports.toLocaleString()} lamports)`);
548
+ console.log();
549
+
550
+ // Fetch balance check
551
+ try {
552
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
553
+ const account = await httpRequest(rpcUrl, `/v1/account/${rawAddr}`);
554
+ if (!account.error && account.lamports !== undefined) {
555
+ if (account.lamports < lamports) {
556
+ console.log(` ${C.red}✗ Insufficient balance.${C.reset}`);
557
+ console.log(` ${C.dim} Have: ${formatAether(account.lamports)} Need: ${formatAether(lamports)}${C.reset}\n`);
558
+ return;
559
+ }
560
+ console.log(` ${C.green}✓ Balance check passed:${C.reset} ${formatAether(account.lamports)}`);
561
+ }
562
+ } catch (err) {
563
+ console.log(` ${C.yellow}⚠ Could not verify balance: ${err.message}${C.reset}`);
564
+ }
565
+ console.log();
566
+
567
+ // Collect M signatures from signers
568
+ console.log(` ${C.yellow}⚠ This is a multi-signature transaction.${C.reset}`);
569
+ console.log(` ${C.dim} Require ${ms.threshold} signature(s) from ${ms.signers.length} signer(s).${C.reset}`);
570
+ console.log(` ${C.dim} Signers:${C.reset}`);
571
+ for (const s of ms.signers) {
572
+ console.log(` ${C.cyan}${shortAddress(s)}${C.reset}`);
573
+ }
574
+ console.log();
575
+
576
+ const signatures = [];
577
+ const neededSigs = ms.threshold;
578
+
579
+ for (let i = 0; i < ms.signers.length && signatures.length < neededSigs; i++) {
580
+ const signer = ms.signers[i];
581
+ console.log(` ${C.cyan}[${signatures.length + 1}/${neededSigs}] Requesting signature from:${C.reset} ${C.bright}${shortAddress(signer)}${C.reset}`);
582
+
583
+ const isYou = loadConfig().defaultWallet === signer;
584
+
585
+ if (isYou) {
586
+ // You are a signer — get your mnemonic to sign
587
+ const mnemonic = await askMnemonic(rl, `your passphrase to sign`);
588
+ let keyPair;
589
+ try {
590
+ keyPair = deriveKeypair(mnemonic);
591
+ } catch (e) {
592
+ console.log(` ${C.red}✗ Failed to derive keypair: ${e.message}${C.reset}`);
593
+ continue;
594
+ }
595
+ const derivedAddr = formatAddress(keyPair.publicKey);
596
+ if (derivedAddr !== signer) {
597
+ console.log(` ${C.red}✗ Passphrase mismatch for signer ${shortAddress(signer)}.${C.reset}`);
598
+ continue;
599
+ }
600
+ // Sign the transaction digest
601
+ const txDigest = crypto.createHash('sha512')
602
+ .update(JSON.stringify({ to: recipient, amount: lamports, from: address, nonce: Math.floor(Math.random() * 0xffffffff) }))
603
+ .digest();
604
+ const sig = nacl.sign.detached(txDigest, keyPair.secretKey);
605
+ signatures.push({ signer, signature: bs58.encode(sig) });
606
+ console.log(` ${C.green}✓ Signed.${C.reset}`);
607
+ } else {
608
+ // Not you — simulate a signature request (in real impl, this would prompt via file/network)
609
+ console.log(` ${C.yellow}⚠ Cannot automatically collect signature for ${shortAddress(signer)}.${C.reset}`);
610
+ console.log(` ${C.dim} For remote signers, use: aether multisig sign --signer ${signer} --tx <txId>${C.reset}`);
611
+ }
612
+ console.log();
613
+ }
614
+
615
+ if (signatures.length < neededSigs) {
616
+ console.log(` ${C.red}✗ Not enough signatures.${C.reset} Have ${signatures.length}, need ${neededSigs}.`);
617
+ console.log(` ${C.dim} Transaction NOT submitted.${C.reset}\n`);
618
+ return;
619
+ }
620
+
621
+ // Build multi-sig transaction
622
+ const tx = {
623
+ type: 'MultisigSend',
624
+ from: address,
625
+ to: recipient.startsWith('ATH') ? recipient.slice(3) : recipient,
626
+ amount_lamports: lamports,
627
+ threshold: ms.threshold,
628
+ signers: ms.signers,
629
+ signatures: signatures.map(s => s.signature),
630
+ timestamp: Math.floor(Date.now() / 1000),
631
+ };
632
+
633
+ console.log(` ${C.green}✓ Collected ${signatures.length} signature(s). Submitting...${C.reset}`);
634
+
635
+ try {
636
+ const result = await httpPost(rpcUrl, '/v1/tx', tx);
637
+
638
+ if (result.error) {
639
+ console.log(`\n ${C.red}✗ Transaction failed:${C.reset} ${result.error}\n`);
640
+ process.exit(1);
641
+ }
642
+
643
+ const sig = result.signature || result.tx_signature || result.id || JSON.stringify(result);
644
+ console.log(`\n${C.green}✓ Multi-sig transaction submitted!${C.reset}`);
645
+ console.log(` ${C.dim}Signature: ${sig}${C.reset}`);
646
+ console.log(` ${C.dim}From: ${address}${C.reset}`);
647
+ console.log(` ${C.dim}To: ${recipient}${C.reset}`);
648
+ console.log(` ${C.dim}Amount: ${formatAether(lamports)}${C.reset}`);
649
+ console.log(` ${C.dim}Signers used: ${signatures.length}/${ms.signers.length}${C.reset}\n`);
650
+ } catch (err) {
651
+ console.log(` ${C.red}✗ Failed to submit transaction:${C.reset} ${err.message}`);
652
+ console.log(` ${C.dim} Is your validator running? RPC: ${rpcUrl}${C.reset}\n`);
653
+ process.exit(1);
654
+ }
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Parse CLI args
659
+ // ---------------------------------------------------------------------------
660
+
661
+ function parseArgs() {
662
+ // argv = [node, index.js, multisig, <subcmd>, ...]
663
+ return process.argv.slice(3);
664
+ }
665
+
666
+ function showHelp() {
667
+ console.log(`
668
+ ${C.bright}${C.cyan}aether-cli multisig${C.reset} — Multi-Signature Wallet Management
669
+
670
+ ${C.bright}Usage:${C.reset}
671
+ aether multisig create --threshold <m> --signers <addr1,addr2,...>
672
+ aether multisig list
673
+ aether multisig info --address <addr>
674
+ aether multisig add-signer --address <msAddr> --signer <newAddr>
675
+ aether multisig send --address <msAddr> --to <recipient> --amount <aeth>
676
+
677
+ ${C.bright}Examples:${C.reset}
678
+ aether multisig create --threshold 2 --signers ATHabc,ATHdef,ATHghi
679
+ aether multisig list
680
+ aether multisig info --address ATHxxxxx
681
+ aether multisig add-signer --address ATHxxxxx --signer ATHnewww
682
+ aether multisig send --address ATHxxxxx --to ATHdest --amount 10
683
+
684
+ ${C.bright}Notes:${C.reset}
685
+ Multi-sig wallets use off-chain deterministic address derivation.
686
+ Changing signers always produces a new wallet address.
687
+ All M signers must approve a transaction before it can be broadcast.
688
+ `.trim());
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Main dispatcher
693
+ // ---------------------------------------------------------------------------
694
+
695
+ async function multisigCommand() {
696
+ const args = parseArgs();
697
+ const subcmd = args[0];
698
+
699
+ const rl = createRl();
700
+ try {
701
+ if (!subcmd || subcmd === 'help' || subcmd === '--help' || subcmd === '-h') {
702
+ showHelp();
703
+ } else if (subcmd === 'create') {
704
+ await createMultisig(rl, args.slice(1));
705
+ } else if (subcmd === 'list') {
706
+ await listMultisig(rl, args.slice(1));
707
+ } else if (subcmd === 'info') {
708
+ await infoMultisig(rl, args.slice(1));
709
+ } else if (subcmd === 'add-signer') {
710
+ await addSignerMultisig(rl, args.slice(1));
711
+ } else if (subcmd === 'send') {
712
+ await sendMultisig(rl, args.slice(1));
713
+ } else {
714
+ console.log(`\n ${C.red}Unknown multisig subcommand:${C.reset} ${subcmd}`);
715
+ showHelp();
716
+ process.exit(1);
717
+ }
718
+ } finally {
719
+ rl.close();
720
+ }
721
+ }
722
+
723
+ module.exports = { multisigCommand };
724
+
725
+ if (require.main === module) {
726
+ multisigCommand();
727
+ }
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli stats
4
+ *
5
+ * Comprehensive wallet stats dashboard:
6
+ * - Token balance (AETH + lamports)
7
+ * - Active stake positions (validator, amount, status)
8
+ * - Recent transactions (last 5)
9
+ * - Estimated rewards accrued
10
+ *
11
+ * Usage:
12
+ * aether stats --address <addr> Full stats dashboard
13
+ * aether stats --address <addr> --json JSON output for scripting
14
+ * aether stats --address <addr> --compact One-line summary
15
+ *
16
+ * Requires AETHER_RPC env var or local node (default: http://127.0.0.1:8899)
17
+ */
18
+
19
+ const http = require('http');
20
+ const https = require('https');
21
+ const os = require('os');
22
+ const path = require('path');
23
+ const fs = require('fs');
24
+ const bs58 = require('bs58').default;
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
+ bold: '\x1b[1m',
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Paths & config
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function getAetherDir() {
44
+ return path.join(os.homedir(), '.aether');
45
+ }
46
+
47
+ function getConfigPath() {
48
+ return path.join(getAetherDir(), 'config.json');
49
+ }
50
+
51
+ function getWalletsDir() {
52
+ return path.join(getAetherDir(), 'wallets');
53
+ }
54
+
55
+ function loadConfig() {
56
+ const p = getConfigPath();
57
+ if (!fs.existsSync(p)) return { defaultWallet: null };
58
+ try {
59
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
60
+ } catch {
61
+ return { defaultWallet: null };
62
+ }
63
+ }
64
+
65
+ function loadWallet(address) {
66
+ const fp = path.join(getWalletsDir(), `${address}.json`);
67
+ if (!fs.existsSync(fp)) return null;
68
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // RPC helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function getDefaultRpc() {
76
+ return process.env.AETHER_RPC || 'http://127.0.0.1:8899';
77
+ }
78
+
79
+ function httpRequest(rpcUrl, pathStr) {
80
+ return new Promise((resolve, reject) => {
81
+ const url = new URL(pathStr, rpcUrl);
82
+ const lib = url.protocol === 'https:' ? https : http;
83
+ const req = lib.request({
84
+ hostname: url.hostname,
85
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
86
+ path: url.pathname + url.search,
87
+ method: 'GET',
88
+ timeout: 8000,
89
+ headers: { 'Content-Type': 'application/json' },
90
+ }, (res) => {
91
+ let data = '';
92
+ res.on('data', (chunk) => data += chunk);
93
+ res.on('end', () => {
94
+ try { resolve(JSON.parse(data)); }
95
+ catch { resolve({ raw: data }); }
96
+ });
97
+ });
98
+ req.on('error', reject);
99
+ req.end();
100
+ });
101
+ }
102
+
103
+ function httpPost(rpcUrl, pathStr, body) {
104
+ return new Promise((resolve, reject) => {
105
+ const url = new URL(pathStr, rpcUrl);
106
+ const lib = url.protocol === 'https:' ? https : http;
107
+ const bodyStr = JSON.stringify(body);
108
+ const req = lib.request({
109
+ hostname: url.hostname,
110
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
111
+ path: url.pathname + url.search,
112
+ method: 'POST',
113
+ timeout: 8000,
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ 'Content-Length': Buffer.byteLength(bodyStr),
117
+ },
118
+ }, (res) => {
119
+ let data = '';
120
+ res.on('data', (chunk) => data += chunk);
121
+ res.on('end', () => {
122
+ try { resolve(JSON.parse(data)); }
123
+ catch { resolve(data); }
124
+ });
125
+ });
126
+ req.on('error', reject);
127
+ req.write(bodyStr);
128
+ req.end();
129
+ });
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Formatting helpers
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Format lamports as AETH string (1 AETH = 1e9 lamports)
138
+ */
139
+ function formatAether(lamports) {
140
+ if (lamports === undefined || lamports === null) return '0 AETH';
141
+ const aeth = lamports / 1e9;
142
+ if (aeth === 0) return '0 AETH';
143
+ return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
144
+ }
145
+
146
+ /**
147
+ * Format a timestamp as relative time ("2h ago", "3d ago")
148
+ */
149
+ function relativeTime(timestamp) {
150
+ if (!timestamp) return 'unknown';
151
+ const now = Math.floor(Date.now() / 1000);
152
+ const diff = now - timestamp;
153
+ if (diff < 60) return `${diff}s ago`;
154
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
155
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
156
+ return `${Math.floor(diff / 86400)}d ago`;
157
+ }
158
+
159
+ /**
160
+ * Truncate a signature or hash for display
161
+ */
162
+ function truncate(str, chars = 8) {
163
+ if (!str || typeof str !== 'string') return '—';
164
+ if (str.length <= chars * 2 + 3) return str;
165
+ return str.slice(0, chars) + '…' + str.slice(-chars);
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Fetch wallet stats from RPC
170
+ // ---------------------------------------------------------------------------
171
+
172
+ async function fetchWalletStats(address, rpcUrl) {
173
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
174
+
175
+ // Fetch account, transactions, and stake positions in parallel
176
+ const [account, txs, stakeAccounts] = await Promise.all([
177
+ httpRequest(rpcUrl, `/v1/account/${rawAddr}`).catch(() => null),
178
+ httpRequest(rpcUrl, `/v1/tx?address=${encodeURIComponent(rawAddr)}&limit=5`).catch(() => null),
179
+ httpRequest(rpcUrl, `/v1/stake?address=${encodeURIComponent(rawAddr)}`).catch(() => null),
180
+ ]);
181
+
182
+ return { account, txs, stakeAccounts };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Render dashboard
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function renderDashboard(address, stats, opts) {
190
+ const { account, txs, stakeAccounts } = stats;
191
+ const { compact, asJson } = opts;
192
+ const rpcUrl = opts.rpcUrl;
193
+
194
+ if (asJson) {
195
+ const stakeList = stakeAccounts && !stakeAccounts.error
196
+ ? (Array.isArray(stakeAccounts) ? stakeAccounts : stakeAccounts.accounts || stakeAccounts.stake_accounts || [])
197
+ : [];
198
+ const txList = txs && !txs.error
199
+ ? (Array.isArray(txs) ? txs : txs.transactions || [])
200
+ : [];
201
+
202
+ const out = {
203
+ address,
204
+ rpc: rpcUrl,
205
+ balance: account && !account.error ? {
206
+ lamports: account.lamports || 0,
207
+ aeth: formatAether(account.lamports || 0),
208
+ } : null,
209
+ stake_positions: stakeList.map((sa) => ({
210
+ stake_account: sa.stake_account || sa.address || 'unknown',
211
+ validator: sa.validator || 'unknown',
212
+ amount: sa.amount || 0,
213
+ aeth: formatAether(sa.amount || 0),
214
+ status: sa.status || 'active',
215
+ created_epoch: sa.created_epoch || null,
216
+ })),
217
+ recent_txs: txList.map((tx) => ({
218
+ type: tx.tx_type || tx.type || 'Unknown',
219
+ signature: tx.signature || tx.id || tx.tx_signature || null,
220
+ timestamp: tx.timestamp || null,
221
+ relative_time: relativeTime(tx.timestamp),
222
+ payload: tx.payload?.data || {},
223
+ fee: tx.fee || 0,
224
+ })),
225
+ fetched_at: new Date().toISOString(),
226
+ };
227
+ console.log(JSON.stringify(out, null, 2));
228
+ return;
229
+ }
230
+
231
+ const lamports = (account && !account.error) ? (account.lamports || 0) : null;
232
+ const stakeList = stakeAccounts && !stakeAccounts.error
233
+ ? (Array.isArray(stakeAccounts) ? stakeAccounts : stakeAccounts.accounts || stakeAccounts.stake_accounts || [])
234
+ : [];
235
+ const txList = txs && !txs.error
236
+ ? (Array.isArray(txs) ? txs : txs.transactions || [])
237
+ : [];
238
+
239
+ if (compact) {
240
+ // One-line summary
241
+ const bal = lamports !== null ? formatAether(lamports) : 'unknown';
242
+ const stakes = stakeList.length;
243
+ const recent = txList.length > 0 ? (txList[0].tx_type || txList[0].type || '?') : 'none';
244
+ console.log(`${C.bright}${address}${C.reset} bal:${C.green}${bal}${C.reset} stakes:${stakes} last:${recent} txs:${txList.length}`);
245
+ return;
246
+ }
247
+
248
+ // Full dashboard
249
+ console.log(`\n${C.bright}${C.cyan}── Wallet Stats ─────────────────────────────────────────${C.reset}`);
250
+ console.log(` ${C.green}★${C.reset} ${C.bright}${address}${C.reset}`);
251
+ console.log(` ${C.dim}RPC: ${rpcUrl}${C.reset}`);
252
+ console.log();
253
+
254
+ // Balance section
255
+ console.log(` ${C.bright}Balance${C.reset}`);
256
+ if (lamports !== null) {
257
+ console.log(` ${C.green}${formatAether(lamports)}${C.reset} ${C.dim}(${lamports} lamports)${C.reset}`);
258
+ if (account.owner) {
259
+ const ownerStr = Array.isArray(account.owner)
260
+ ? 'ATH' + bs58.encode(Buffer.from(account.owner.slice(0, 32)))
261
+ : account.owner;
262
+ console.log(` ${C.dim}Owner: ${ownerStr}${C.reset}`);
263
+ }
264
+ if (account.rent_epoch !== undefined) {
265
+ console.log(` ${C.dim}Rent epoch: ${account.rent_epoch}${C.reset}`);
266
+ }
267
+ } else {
268
+ console.log(` ${C.yellow}⚠ Could not fetch balance (account may not exist)${C.reset}`);
269
+ }
270
+ console.log();
271
+
272
+ // Stake positions section
273
+ console.log(` ${C.bright}Stake Positions (${stakeList.length})${C.reset}`);
274
+ if (stakeList.length === 0) {
275
+ console.log(` ${C.dim}No active stake positions.${C.reset}`);
276
+ } else {
277
+ const statusColors = {
278
+ active: C.green,
279
+ inactive: C.yellow,
280
+ activating: C.yellow,
281
+ deactivating: C.red,
282
+ unknown: C.dim,
283
+ };
284
+ for (const sa of stakeList) {
285
+ const status = sa.status || 'unknown';
286
+ const color = statusColors[status] || C.dim;
287
+ const amount = sa.amount ? formatAether(sa.amount) : '0 AETH';
288
+ const validator = sa.validator || 'unknown';
289
+ console.log(` ${C.dim}┌─${C.reset}`);
290
+ console.log(` │ ${C.bright}Validator:${C.reset} ${validator}`);
291
+ console.log(` │ ${C.bright}Amount:${C.reset} ${color}${amount}${C.reset}`);
292
+ console.log(` │ ${C.bright}Status:${C.reset} ${color}${status}${C.reset}`);
293
+ if (sa.stake_account) {
294
+ console.log(` │ ${C.bright}Stake acct:${C.reset} ${truncate(sa.stake_account)}`);
295
+ }
296
+ console.log(` ${C.dim}└${C.reset}`);
297
+ }
298
+ }
299
+ console.log();
300
+
301
+ // Recent transactions section
302
+ console.log(` ${C.bright}Recent Transactions (${txList.length})${C.reset}`);
303
+ if (txList.length === 0) {
304
+ console.log(` ${C.dim}No transactions yet.${C.reset}`);
305
+ } else {
306
+ const typeColors = {
307
+ Transfer: C.cyan,
308
+ Stake: C.green,
309
+ Unstake: C.yellow,
310
+ ClaimRewards: C.magenta,
311
+ CreateNFT: C.red,
312
+ MintNFT: C.red,
313
+ TransferNFT: C.cyan,
314
+ UpdateMetadata: C.yellow,
315
+ Unknown: C.dim,
316
+ };
317
+ for (const tx of txList) {
318
+ const txType = tx.tx_type || tx.type || 'Unknown';
319
+ const color = typeColors[txType] || C.dim;
320
+ const sig = tx.signature || tx.id || tx.tx_signature || '—';
321
+ const ts = tx.timestamp ? relativeTime(tx.timestamp) : 'unknown';
322
+
323
+ console.log(` ${C.dim}┌─ ${ts}${C.reset} ${C.bright}${color}${txType}${C.reset} sig:${truncate(sig)}`);
324
+ if (tx.payload && tx.payload.data) {
325
+ const d = tx.payload.data;
326
+ if (d.recipient) console.log(` │ ${C.dim}→ to: ${d.recipient}${C.reset}`);
327
+ if (d.amount) console.log(` │ ${C.dim}amount: ${formatAether(d.amount)}${C.reset}`);
328
+ if (d.validator) console.log(` │ ${C.dim}validator: ${d.validator}${C.reset}`);
329
+ }
330
+ if (tx.fee !== undefined && tx.fee > 0) {
331
+ console.log(` │ ${C.dim}fee: ${tx.fee} lamports${C.reset}`);
332
+ }
333
+ console.log(` ${C.dim}└${C.reset}`);
334
+ }
335
+ }
336
+ console.log();
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Main command
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function statsCommand() {
344
+ const args = process.argv.slice(3);
345
+
346
+ // Parse flags
347
+ let address = null;
348
+ let compact = false;
349
+ let asJson = false;
350
+
351
+ for (let i = 0; i < args.length; i++) {
352
+ if ((args[i] === '--address' || args[i] === '-a') && args[i + 1]) {
353
+ address = args[i + 1];
354
+ i++;
355
+ } else if (args[i] === '--compact' || args[i] === '-c') {
356
+ compact = true;
357
+ } else if (args[i] === '--json' || args[i] === '-j') {
358
+ asJson = true;
359
+ } else if (args[i] === '--help' || args[i] === '-h') {
360
+ console.log(`
361
+ ${C.bright}Aether Wallet Stats${C.reset}
362
+ ${C.dim}Comprehensive wallet overview: balance, stakes, recent transactions.${C.reset}
363
+
364
+ ${C.bright}Usage:${C.reset}
365
+ aether stats --address <addr> Full dashboard
366
+ aether stats --address <addr> --json JSON output
367
+ aether stats --address <addr> --compact One-line summary
368
+
369
+ ${C.bright}Options:${C.reset}
370
+ -a, --address <addr> Wallet address (or set default)
371
+ -j, --json JSON output
372
+ -c, --compact One-line summary
373
+ -h, --help Show this help
374
+ `);
375
+ return;
376
+ }
377
+ }
378
+
379
+ // Resolve address
380
+ if (!address) {
381
+ const cfg = loadConfig();
382
+ address = cfg.defaultWallet;
383
+ }
384
+
385
+ if (!address) {
386
+ console.log(` ${C.red}✗ No wallet address.${C.reset} Use ${C.cyan}--address <addr>${C.reset} or set a default.`);
387
+ console.log(` ${C.dim}Usage: aether stats --address <address> [--compact] [--json]${C.reset}\n`);
388
+ process.exit(1);
389
+ }
390
+
391
+ // Verify wallet file exists
392
+ const wallet = loadWallet(address);
393
+ if (!wallet) {
394
+ console.log(` ${C.red}✗ Wallet not found locally:${C.reset} ${address}`);
395
+ console.log(` ${C.dim}Check your wallets: ${C.cyan}aether wallet list${C.reset}\n`);
396
+ process.exit(1);
397
+ }
398
+
399
+ const rpcUrl = getDefaultRpc();
400
+
401
+ if (!asJson) {
402
+ console.log(` ${C.dim}Fetching stats from ${rpcUrl}...${C.reset}`);
403
+ }
404
+
405
+ // Fetch and render
406
+ (async () => {
407
+ try {
408
+ const stats = await fetchWalletStats(address, rpcUrl);
409
+ renderDashboard(address, stats, { compact, asJson, rpcUrl });
410
+ } catch (err) {
411
+ console.log(` ${C.red}✗ Failed to fetch wallet stats:${C.reset} ${err.message}`);
412
+ console.log(` ${C.dim} Is your validator running? RPC: ${rpcUrl}${C.reset}`);
413
+ console.log(` ${C.dim} Set custom RPC: AETHER_RPC=https://your-rpc-url${C.reset}\n`);
414
+ process.exit(1);
415
+ }
416
+ })();
417
+ }
418
+
419
+ module.exports = { statsCommand };
package/index.js CHANGED
@@ -28,6 +28,7 @@ const { supplyCommand } = require('./commands/supply');
28
28
  const { statusCommand } = require('./commands/status');
29
29
  const { broadcastCommand } = require('./commands/broadcast');
30
30
  const { apyCommand } = require('./commands/apy');
31
+ const { statsCommand } = require('./commands/stats');
31
32
  const readline = require('readline');
32
33
 
33
34
  // CLI version
@@ -353,6 +354,12 @@ const COMMANDS = {
353
354
  validatorsListCommand();
354
355
  },
355
356
  },
357
+ stats: {
358
+ description: 'Wallet stats dashboard — balance, stake positions, recent txs — aether stats --address <addr> [--compact] [--json]',
359
+ handler: () => {
360
+ statsCommand();
361
+ },
362
+ },
356
363
  price: {
357
364
  description: 'AETH/USD price — aether price [--pair AETH/USD] [--json] [--source coingecko]',
358
365
  handler: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-hub",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "AeTHer Validator CLI — tiered validators (Full/Lite/Observer), system checks, onboarding, and node management",
5
5
  "main": "index.js",
6
6
  "bin": {