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.
- package/commands/multisig.js +727 -0
- package/commands/stats.js +419 -0
- package/index.js +7 -0
- package/package.json +1 -1
|
@@ -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: () => {
|