@jellylegsai/aether-cli 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/nft.js CHANGED
@@ -1,857 +1,1180 @@
1
- #!/usr/bin/env node
2
- /**
3
- * aether-cli nft
4
- *
5
- * Complete NFT management suite - fully wired to @jellylegsai/aether-sdk.
6
- * All blockchain calls use real HTTP RPC via AetherClient.
7
- *
8
- * Commands:
9
- * aether nft create --metadata <url> [--royalties <bps>] [--address <wallet>]
10
- * aether nft list --address <wallet> [--creator <addr>] [--json]
11
- * aether nft transfer --nft <id> --to <addr> [--from <wallet>]
12
- * aether nft info --nft <id> [--json]
13
- * aether nft update --nft <id> --metadata <url> [--address <wallet>]
14
- * aether nft burn --nft <id> [--address <wallet>]
15
- *
16
- * SDK Methods:
17
- * - client.createNFT() Creates new NFT with metadata
18
- * - client.transferNFT() Transfer NFT ownership
19
- * - client.updateMetadata()→ Update NFT metadata URL
20
- * - getTokenAccounts() List NFTs for address
21
- * - getAccountInfo() Get NFT details
22
- *
23
- * Requires AETHER_RPC env var (default: http://127.0.0.1:8899)
24
- */
25
-
26
- const fs = require('fs');
27
- const path = require('path');
28
- const os = require('os');
29
- const readline = require('readline');
30
- const nacl = require('tweetnacl');
31
- const bs58 = require('bs58').default;
32
- const bip39 = require('bip39');
33
-
34
- // Import SDK for real blockchain RPC calls
35
- const sdkPath = path.join(__dirname, '..', 'sdk', 'index.js');
36
- const aether = require(sdkPath);
37
-
38
- // ANSI colours
39
- const C = {
40
- reset: '\x1b[0m',
41
- bright: '\x1b[1m',
42
- dim: '\x1b[2m',
43
- red: '\x1b[31m',
44
- green: '\x1b[32m',
45
- yellow: '\x1b[33m',
46
- cyan: '\x1b[36m',
47
- magenta: '\x1b[35m',
48
- blue: '\x1b[34m',
49
- };
50
-
51
- const CLI_VERSION = '1.0.0';
52
- const DERIVATION_PATH = "m/44'/7777777'/0'/0'";
53
-
54
- // ============================================================================
55
- // SDK Client Setup
56
- // ============================================================================
57
-
58
- function getDefaultRpc() {
59
- return process.env.AETHER_RPC || aether.DEFAULT_RPC_URL || 'http://127.0.0.1:8899';
60
- }
61
-
62
- function createClient(rpcUrl) {
63
- return new aether.AetherClient({ rpcUrl });
64
- }
65
-
66
- // ============================================================================
67
- // Config & Wallet
68
- // ============================================================================
69
-
70
- function getAetherDir() {
71
- return path.join(os.homedir(), '.aether');
72
- }
73
-
74
- function getConfigPath() {
75
- return path.join(getAetherDir(), 'config.json');
76
- }
77
-
78
- function loadConfig() {
79
- if (!fs.existsSync(getConfigPath())) {
80
- return { defaultWallet: null };
81
- }
82
- try {
83
- return JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
84
- } catch {
85
- return { defaultWallet: null };
86
- }
87
- }
88
-
89
- function loadWallet(address) {
90
- const fp = path.join(getAetherDir(), 'wallets', `${address}.json`);
91
- if (!fs.existsSync(fp)) return null;
92
- try {
93
- return JSON.parse(fs.readFileSync(fp, 'utf8'));
94
- } catch {
95
- return null;
96
- }
97
- }
98
-
99
- // ============================================================================
100
- // Crypto Helpers
101
- // ============================================================================
102
-
103
- function deriveKeypair(mnemonic) {
104
- if (!bip39.validateMnemonic(mnemonic)) {
105
- throw new Error('Invalid mnemonic phrase');
106
- }
107
- const seedBuffer = bip39.mnemonicToSeedSync(mnemonic, '');
108
- const seed32 = seedBuffer.slice(0, 32);
109
- const keyPair = nacl.sign.keyPair.fromSeed(seed32);
110
- return {
111
- publicKey: Buffer.from(keyPair.publicKey),
112
- secretKey: Buffer.from(keyPair.secretKey),
113
- };
114
- }
115
-
116
- function formatAddress(publicKey) {
117
- return 'ATH' + bs58.encode(publicKey);
118
- }
119
-
120
- function signTransaction(tx, secretKey) {
121
- const txBytes = Buffer.from(JSON.stringify(tx));
122
- const sig = nacl.sign.detached(txBytes, secretKey);
123
- return bs58.encode(sig);
124
- }
125
-
126
- // ============================================================================
127
- // Format Helpers
128
- // ============================================================================
129
-
130
- function formatAether(lamports) {
131
- if (!lamports || lamports === '0') return '0 AETH';
132
- const aeth = Number(lamports) / 1e9;
133
- return aeth.toFixed(4).replace(/\.?0+$/, '') + ' AETH';
134
- }
135
-
136
- function shortAddress(addr) {
137
- if (!addr || addr.length < 16) return addr || 'unknown';
138
- return addr.slice(0, 8) + '...' + addr.slice(-8);
139
- }
140
-
141
- function truncate(str, len) {
142
- if (!str) return '';
143
- if (str.length <= len) return str;
144
- return str.slice(0, len - 3) + '...';
145
- }
146
-
147
- // ============================================================================
148
- // Readline Helpers
149
- // ============================================================================
150
-
151
- function createRl() {
152
- return readline.createInterface({ input: process.stdin, output: process.stdout });
153
- }
154
-
155
- function question(rl, q) {
156
- return new Promise((res) => rl.question(q, res));
157
- }
158
-
159
- async function askMnemonic(rl, promptText) {
160
- console.log(`\n${C.cyan}${promptText}${C.reset}`);
161
- console.log(`${C.dim}Enter your 12 or 24-word passphrase:${C.reset}`);
162
- const raw = await question(rl, ` > ${C.reset}`);
163
- return raw.trim().toLowerCase();
164
- }
165
-
166
- // ============================================================================
167
- // NFT Create Command - Uses SDK createNFT()
168
- // ============================================================================
169
-
170
- async function nftCreate(args) {
171
- const rl = createRl();
172
- const rpc = args.rpc || getDefaultRpc();
173
-
174
- // Resolve wallet address
175
- let address = args.address;
176
- if (!address) {
177
- const config = loadConfig();
178
- address = config.defaultWallet;
179
- }
180
-
181
- if (!address) {
182
- console.log(`\n ${C.red}✗ No wallet address specified.${C.reset}`);
183
- console.log(` ${C.dim}Usage: aether nft create --metadata <url> --address <wallet>${C.reset}\n`);
184
- rl.close();
185
- return;
186
- }
187
-
188
- const wallet = loadWallet(address);
189
- if (!wallet) {
190
- console.log(`\n ${C.red}✗ Wallet not found locally: ${address}${C.reset}`);
191
- console.log(` ${C.dim}Import it: aether wallet import${C.reset}\n`);
192
- rl.close();
193
- return;
194
- }
195
-
196
- // Get metadata URL
197
- let metadataUrl = args.metadata;
198
- if (!metadataUrl) {
199
- console.log(`\n${C.bright}${C.cyan}── NFT Creation ─────────────────────────────────────────${C.reset}\n`);
200
- console.log(` ${C.dim}Enter the metadata URL for your NFT:${C.reset}`);
201
- metadataUrl = await question(rl, ` Metadata URL > ${C.reset}`);
202
- if (!metadataUrl.trim()) {
203
- console.log(`\n ${C.red}✗ Metadata URL is required.${C.reset}\n`);
204
- rl.close();
205
- return;
206
- }
207
- }
208
-
209
- // Get royalties (default 5% = 500 bps)
210
- let royalties = args.royalties;
211
- if (!royalties) {
212
- const royaltyAns = await question(rl, ` ${C.dim}Royalties (basis points, default 500 = 5%):${C.reset} `);
213
- royalties = parseInt(royaltyAns.trim() || '500', 10);
214
- } else {
215
- royalties = parseInt(royalties, 10);
216
- }
217
-
218
- if (isNaN(royalties) || royalties < 0 || royalties > 10000) {
219
- console.log(`\n ${C.red}✗ Invalid royalties. Must be 0-10000 basis points.${C.reset}\n`);
220
- rl.close();
221
- return;
222
- }
223
-
224
- console.log(`\n ${C.green}★${C.reset} Creator: ${C.bright}${address}${C.reset}`);
225
- console.log(` ${C.green}★${C.reset} Metadata: ${C.bright}${truncate(metadataUrl, 50)}${C.reset}`);
226
- console.log(` ${C.green}★${C.reset} Royalties: ${C.bright}${(royalties / 100).toFixed(2)}%${C.reset}`);
227
- console.log();
228
-
229
- // Ask for mnemonic for signing
230
- console.log(`${C.yellow} Creating NFT requires your wallet passphrase.${C.reset}`);
231
- const mnemonic = await askMnemonic(rl, 'Enter passphrase to sign the NFT creation');
232
- console.log();
233
-
234
- let keyPair;
235
- try {
236
- keyPair = deriveKeypair(mnemonic);
237
- } catch (e) {
238
- console.log(` ${C.red} Failed to derive keypair: ${e.message}${C.reset}\n`);
239
- rl.close();
240
- return;
241
- }
242
-
243
- // Verify derived address matches
244
- const derivedAddress = formatAddress(keyPair.publicKey);
245
- if (derivedAddress !== address) {
246
- console.log(` ${C.red} Passphrase mismatch.${C.reset}`);
247
- console.log(` ${C.dim} Derived: ${derivedAddress}${C.reset}`);
248
- console.log(` ${C.dim} Expected: ${address}${C.reset}\n`);
249
- rl.close();
250
- return;
251
- }
252
-
253
- // Confirm
254
- const confirm = await question(rl, ` ${C.yellow}Create NFT? [y/N]${C.reset} > `);
255
- if (!confirm.trim().toLowerCase().startsWith('y')) {
256
- console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
257
- rl.close();
258
- return;
259
- }
260
- rl.close();
261
-
262
- // Create NFT via SDK
263
- const client = createClient(rpc);
264
-
265
- console.log(`\n ${C.dim}Submitting NFT creation via SDK...${C.reset}`);
266
-
267
- try {
268
- // SDK call: createNFT with signing function
269
- const result = await client.createNFT({
270
- creator: address.startsWith('ATH') ? address.slice(3) : address,
271
- metadataUrl: metadataUrl.trim(),
272
- royalties: royalties,
273
- signFn: async (tx) => signTransaction(tx, keyPair.secretKey),
274
- });
275
-
276
- if (result.error) {
277
- throw new Error(result.error.message || JSON.stringify(result.error));
278
- }
279
-
280
- const nftId = result.nft_id || result.id || result.signature;
281
-
282
- console.log(`\n${C.green}✓ NFT created successfully!${C.reset}`);
283
- console.log(` ${C.dim}NFT ID:${C.reset} ${C.cyan}${C.bright}${nftId}${C.reset}`);
284
- console.log(` ${C.dim}Creator:${C.reset} ${address}`);
285
- console.log(` ${C.dim}Metadata:${C.reset} ${truncate(metadataUrl, 45)}`);
286
- console.log(` ${C.dim}Royalties:${C.reset} ${(royalties / 100).toFixed(2)}%`);
287
- if (result.signature) {
288
- console.log(` ${C.dim}Signature:${C.reset} ${shortAddress(result.signature)}`);
289
- }
290
- if (result.slot) {
291
- console.log(` ${C.dim}Slot:${C.reset} ${result.slot}`);
292
- }
293
- console.log(` ${C.dim}SDK:${C.reset} createNFT() → POST /v1/transaction`);
294
- console.log();
295
- console.log(` ${C.dim}View your NFT:${C.reset}`);
296
- console.log(` ${C.cyan}aether nft info --nft ${nftId}${C.reset}\n`);
297
-
298
- return result;
299
- } catch (err) {
300
- console.log(`\n ${C.red}✗ NFT creation failed:${C.reset} ${err.message}`);
301
- console.log(` ${C.dim}Common causes:${C.reset}`);
302
- console.log(` • Insufficient balance for NFT creation fee (typically 0.01 AETH)`);
303
- console.log(` • RPC endpoint not accepting transactions`);
304
- console.log(` • Invalid metadata URL format\n`);
305
- process.exit(1);
306
- }
307
- }
308
-
309
- // ============================================================================
310
- // NFT List Command - Uses SDK getTokenAccounts()
311
- // ============================================================================
312
-
313
- async function nftList(args) {
314
- const rpc = args.rpc || getDefaultRpc();
315
- const isJson = args.json || false;
316
-
317
- let address = args.address;
318
- if (!address) {
319
- const config = loadConfig();
320
- address = config.defaultWallet;
321
- }
322
-
323
- if (!address) {
324
- if (isJson) {
325
- console.log(JSON.stringify({ error: 'No address provided' }, null, 2));
326
- } else {
327
- console.log(`\n ${C.red}✗ No wallet address specified.${C.reset}`);
328
- console.log(` ${C.dim}Usage: aether nft list --address <wallet>${C.reset}\n`);
329
- }
330
- return;
331
- }
332
-
333
- const client = createClient(rpc);
334
- const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
335
-
336
- if (!isJson) {
337
- console.log(`\n${C.bright}${C.cyan}── NFT Collection ───────────────────────────────────────${C.reset}\n`);
338
- console.log(` ${C.dim}Wallet:${C.reset} ${address}`);
339
- console.log(` ${C.dim}RPC:${C.reset} ${rpc}\n`);
340
- console.log(` ${C.dim}Fetching NFTs via SDK...${C.reset}\n`);
341
- }
342
-
343
- try {
344
- // SDK call: getTokenAccounts → GET /v1/tokens/<address>
345
- const tokenAccounts = await client.getTokenAccounts(rawAddr);
346
-
347
- // Filter for NFTs (amount = 1, has metadata)
348
- const nfts = (tokenAccounts || []).filter(t =>
349
- t.amount === '1' || t.amount === 1 || t.is_nft || t.nft_id
350
- );
351
-
352
- if (isJson) {
353
- console.log(JSON.stringify({
354
- address,
355
- rpc,
356
- nft_count: nfts.length,
357
- nfts: nfts.map(n => ({
358
- id: n.mint || n.nft_id || n.id,
359
- amount: n.amount,
360
- metadata: n.metadata_url || n.metadata,
361
- creator: n.creator,
362
- royalties: n.royalties,
363
- })),
364
- cli_version: CLI_VERSION,
365
- fetched_at: new Date().toISOString(),
366
- }, null, 2));
367
- return;
368
- }
369
-
370
- if (nfts.length === 0) {
371
- console.log(` ${C.yellow}⚠ No NFTs found for this wallet.${C.reset}`);
372
- console.log(` ${C.dim}Create your first NFT:${C.reset}`);
373
- console.log(` ${C.cyan}aether nft create --metadata <url>${C.reset}\n`);
374
- return;
375
- }
376
-
377
- console.log(` ${C.bright}Found ${nfts.length} NFT${nfts.length === 1 ? '' : 's'}${C.reset}\n`);
378
-
379
- // Display table
380
- console.log(` ${C.dim}┌─────────────────────────────────────────────────────────────────┐${C.reset}`);
381
- console.log(` ${C.dim}│${C.reset} ${C.bright}# NFT ID${C.reset} ${C.bright}Metadata${C.reset} ${C.bright}Royalties${C.reset} ${C.dim}│${C.reset}`);
382
- console.log(` ${C.dim}├─────────────────────────────────────────────────────────────────┤${C.reset}`);
383
-
384
- nfts.forEach((nft, i) => {
385
- const id = shortAddress(nft.mint || nft.nft_id || nft.id, 10).padEnd(24);
386
- const meta = truncate(nft.metadata_url || nft.metadata || 'N/A', 22).padEnd(22);
387
- const royalty = nft.royalties !== undefined
388
- ? `${(nft.royalties / 100).toFixed(0)}%`.padEnd(9)
389
- : 'N/A ';
390
- console.log(` ${C.dim}│${C.reset} ${(i + 1).toString().padStart(2)} ${C.cyan}${id}${C.reset} ${meta} ${royalty} ${C.dim}│${C.reset}`);
391
- });
392
-
393
- console.log(` ${C.dim}└─────────────────────────────────────────────────────────────────┘${C.reset}`);
394
- console.log();
395
- console.log(` ${C.dim}SDK: getTokenAccounts() → GET /v1/tokens/${shortAddress(rawAddr)}${C.reset}\n`);
396
-
397
- } catch (err) {
398
- if (isJson) {
399
- console.log(JSON.stringify({ error: err.message, address }, null, 2));
400
- } else {
401
- console.log(` ${C.red}✗ Failed to fetch NFTs:${C.reset} ${err.message}`);
402
- console.log(` ${C.dim}Is your validator running? RPC: ${rpc}${C.reset}\n`);
403
- }
404
- }
405
- }
406
-
407
- // ============================================================================
408
- // NFT Transfer Command - Uses SDK transferNFT()
409
- // ============================================================================
410
-
411
- async function nftTransfer(args) {
412
- const rl = createRl();
413
- const rpc = args.rpc || getDefaultRpc();
414
-
415
- let fromAddress = args.from;
416
- if (!fromAddress) {
417
- const config = loadConfig();
418
- fromAddress = config.defaultWallet;
419
- }
420
-
421
- if (!fromAddress) {
422
- console.log(`\n ${C.red}✗ No wallet address specified.${C.reset}`);
423
- console.log(` ${C.dim}Usage: aether nft transfer --nft <id> --to <addr> --from <wallet>${C.reset}\n`);
424
- rl.close();
425
- return;
426
- }
427
-
428
- // Get NFT ID
429
- let nftId = args.nft;
430
- if (!nftId) {
431
- console.log(`\n ${C.dim}Enter the NFT ID to transfer:${C.reset}`);
432
- nftId = await question(rl, ` NFT ID > ${C.reset}`);
433
- if (!nftId.trim()) {
434
- console.log(`\n ${C.red} NFT ID is required.${C.reset}\n`);
435
- rl.close();
436
- return;
437
- }
438
- }
439
-
440
- // Get recipient
441
- let toAddress = args.to;
442
- if (!toAddress) {
443
- console.log(` ${C.dim}Enter recipient address:${C.reset}`);
444
- toAddress = await question(rl, ` Recipient > ${C.reset}`);
445
- if (!toAddress.trim()) {
446
- console.log(`\n ${C.red}✗ Recipient address is required.${C.reset}\n`);
447
- rl.close();
448
- return;
449
- }
450
- }
451
-
452
- console.log(`\n ${C.green}★${C.reset} From: ${C.bright}${fromAddress}${C.reset}`);
453
- console.log(` ${C.green}★${C.reset} To: ${C.bright}${toAddress}${C.reset}`);
454
- console.log(` ${C.green}★${C.reset} NFT: ${C.bright}${shortAddress(nftId)}${C.reset}`);
455
- console.log();
456
-
457
- // Ask for mnemonic
458
- console.log(`${C.yellow} Transferring NFT requires your wallet passphrase.${C.reset}`);
459
- const mnemonic = await askMnemonic(rl, 'Enter passphrase to sign the transfer');
460
- console.log();
461
-
462
- let keyPair;
463
- try {
464
- keyPair = deriveKeypair(mnemonic);
465
- } catch (e) {
466
- console.log(` ${C.red}✗ Failed to derive keypair: ${e.message}${C.reset}\n`);
467
- rl.close();
468
- return;
469
- }
470
-
471
- // Verify derived address
472
- const derivedAddress = formatAddress(keyPair.publicKey);
473
- if (derivedAddress !== fromAddress) {
474
- console.log(` ${C.red}✗ Passphrase mismatch.${C.reset}`);
475
- console.log(` ${C.dim} Derived: ${derivedAddress}${C.reset}`);
476
- console.log(` ${C.dim} Expected: ${fromAddress}${C.reset}\n`);
477
- rl.close();
478
- return;
479
- }
480
-
481
- // Confirm
482
- const confirm = await question(rl, ` ${C.yellow}Confirm NFT transfer? [y/N]${C.reset} > `);
483
- if (!confirm.trim().toLowerCase().startsWith('y')) {
484
- console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
485
- rl.close();
486
- return;
487
- }
488
- rl.close();
489
-
490
- // Transfer via SDK
491
- const client = createClient(rpc);
492
-
493
- console.log(`\n ${C.dim}Submitting NFT transfer via SDK...${C.reset}`);
494
-
495
- try {
496
- const result = await client.transferNFT({
497
- from: fromAddress.startsWith('ATH') ? fromAddress.slice(3) : fromAddress,
498
- nftId: nftId.trim(),
499
- to: toAddress.startsWith('ATH') ? toAddress.slice(3) : toAddress,
500
- signFn: async (tx) => signTransaction(tx, keyPair.secretKey),
501
- });
502
-
503
- if (result.error) {
504
- throw new Error(result.error.message || JSON.stringify(result.error));
505
- }
506
-
507
- console.log(`\n${C.green}✓ NFT transferred successfully!${C.reset}`);
508
- console.log(` ${C.dim}NFT ID:${C.reset} ${C.cyan}${C.bright}${nftId}${C.reset}`);
509
- console.log(` ${C.dim}From:${C.reset} ${fromAddress}`);
510
- console.log(` ${C.dim}To:${C.reset} ${C.green}${toAddress}${C.reset}`);
511
- if (result.signature) {
512
- console.log(` ${C.dim}Signature:${C.reset} ${shortAddress(result.signature)}`);
513
- }
514
- if (result.slot) {
515
- console.log(` ${C.dim}Slot:${C.reset} ${result.slot}`);
516
- }
517
- console.log(` ${C.dim}SDK:${C.reset} transferNFT() POST /v1/transaction`);
518
- console.log();
519
- console.log(` ${C.dim}Verify transfer:${C.reset}`);
520
- console.log(` ${C.cyan}aether nft list --address ${toAddress}${C.reset}\n`);
521
-
522
- } catch (err) {
523
- console.log(`\n ${C.red}✗ NFT transfer failed:${C.reset} ${err.message}`);
524
- console.log(` ${C.dim}Common causes:${C.reset}`);
525
- console.log(` • You don't own this NFT`);
526
- console.log(` • Invalid NFT ID`);
527
- console.log(` • Recipient address is invalid`);
528
- console.log(` • Insufficient balance for transaction fee\n`);
529
- process.exit(1);
530
- }
531
- }
532
-
533
- // ============================================================================
534
- // NFT Info Command - Uses SDK getAccountInfo()
535
- // ============================================================================
536
-
537
- async function nftInfo(args) {
538
- const rpc = args.rpc || getDefaultRpc();
539
- const isJson = args.json || false;
540
-
541
- let nftId = args.nft;
542
- if (!nftId) {
543
- console.log(`\n ${C.red} NFT ID is required.${C.reset}`);
544
- console.log(` ${C.dim}Usage: aether nft info --nft <id>${C.reset}\n`);
545
- return;
546
- }
547
-
548
- const client = createClient(rpc);
549
-
550
- if (!isJson) {
551
- console.log(`\n${C.bright}${C.cyan}── NFT Details ──────────────────────────────────────────${C.reset}\n`);
552
- console.log(` ${C.dim}Fetching NFT info via SDK...${C.reset}\n`);
553
- }
554
-
555
- try {
556
- // SDK call: getAccountInfo → GET /v1/account/<nft_id>
557
- const accountInfo = await client.getAccountInfo(nftId);
558
-
559
- if (!accountInfo || accountInfo.error) {
560
- throw new Error(accountInfo?.error || 'NFT not found');
561
- }
562
-
563
- // Extract NFT-specific data
564
- const nftData = {
565
- id: nftId,
566
- owner: accountInfo.owner,
567
- metadata: accountInfo.data?.metadata || accountInfo.metadata_url,
568
- creator: accountInfo.data?.creator,
569
- royalties: accountInfo.data?.royalties,
570
- lamports: accountInfo.lamports,
571
- rentEpoch: accountInfo.rent_epoch,
572
- };
573
-
574
- if (isJson) {
575
- console.log(JSON.stringify({
576
- nft: nftData,
577
- rpc,
578
- cli_version: CLI_VERSION,
579
- fetched_at: new Date().toISOString(),
580
- }, null, 2));
581
- return;
582
- }
583
-
584
- console.log(` ${C.green}★${C.reset} NFT ID: ${C.bright}${C.cyan}${nftId}${C.reset}`);
585
- console.log(` ${C.green}★${C.reset} Owner: ${nftData.owner || 'Unknown'}`);
586
- console.log(` ${C.green}★${C.reset} Creator: ${nftData.creator || 'Unknown'}`);
587
- console.log(` ${C.green}★${C.reset} Metadata: ${truncate(nftData.metadata, 50) || 'N/A'}`);
588
- if (nftData.royalties !== undefined) {
589
- console.log(` ${C.green}★${C.reset} Royalties: ${(nftData.royalties / 100).toFixed(2)}%`);
590
- }
591
- if (nftData.lamports !== undefined) {
592
- console.log(` ${C.green}★${C.reset} Lamports: ${nftData.lamports.toLocaleString()}`);
593
- }
594
- console.log();
595
- console.log(` ${C.dim}SDK: getAccountInfo() → GET /v1/account/${shortAddress(nftId)}${C.reset}\n`);
596
-
597
- } catch (err) {
598
- if (isJson) {
599
- console.log(JSON.stringify({ error: err.message, nft_id: nftId }, null, 2));
600
- } else {
601
- console.log(` ${C.red}✗ Failed to fetch NFT info:${C.reset} ${err.message}`);
602
- console.log(` ${C.dim}The NFT may not exist or the RPC endpoint is unavailable.${C.reset}\n`);
603
- }
604
- }
605
- }
606
-
607
- // ============================================================================
608
- // NFT Update Command - Uses SDK updateMetadata()
609
- // ============================================================================
610
-
611
- async function nftUpdate(args) {
612
- const rl = createRl();
613
- const rpc = args.rpc || getDefaultRpc();
614
-
615
- let address = args.address;
616
- if (!address) {
617
- const config = loadConfig();
618
- address = config.defaultWallet;
619
- }
620
-
621
- if (!address) {
622
- console.log(`\n ${C.red}✗ No wallet address specified.${C.reset}`);
623
- console.log(` ${C.dim}Usage: aether nft update --nft <id> --metadata <url> --address <wallet>${C.reset}\n`);
624
- rl.close();
625
- return;
626
- }
627
-
628
- let nftId = args.nft;
629
- if (!nftId) {
630
- console.log(`\n ${C.dim}Enter the NFT ID to update:${C.reset}`);
631
- nftId = await question(rl, ` NFT ID > ${C.reset}`);
632
- if (!nftId.trim()) {
633
- console.log(`\n ${C.red}✗ NFT ID is required.${C.reset}\n`);
634
- rl.close();
635
- return;
636
- }
637
- }
638
-
639
- let metadataUrl = args.metadata;
640
- if (!metadataUrl) {
641
- console.log(` ${C.dim}Enter new metadata URL:${C.reset}`);
642
- metadataUrl = await question(rl, ` New Metadata URL > ${C.reset}`);
643
- if (!metadataUrl.trim()) {
644
- console.log(`\n ${C.red}✗ Metadata URL is required.${C.reset}\n`);
645
- rl.close();
646
- return;
647
- }
648
- }
649
-
650
- console.log(`\n ${C.green}★${C.reset} Updater: ${C.bright}${address}${C.reset}`);
651
- console.log(` ${C.green}★${C.reset} NFT: ${C.bright}${shortAddress(nftId)}${C.reset}`);
652
- console.log(` ${C.green}★${C.reset} New Meta: ${C.bright}${truncate(metadataUrl, 40)}${C.reset}`);
653
- console.log();
654
-
655
- // Ask for mnemonic
656
- console.log(`${C.yellow} ⚠ Updating metadata requires your wallet passphrase.${C.reset}`);
657
- const mnemonic = await askMnemonic(rl, 'Enter passphrase to sign the update');
658
- console.log();
659
-
660
- let keyPair;
661
- try {
662
- keyPair = deriveKeypair(mnemonic);
663
- } catch (e) {
664
- console.log(` ${C.red}✗ Failed to derive keypair: ${e.message}${C.reset}\n`);
665
- rl.close();
666
- return;
667
- }
668
-
669
- // Verify derived address
670
- const derivedAddress = formatAddress(keyPair.publicKey);
671
- if (derivedAddress !== address) {
672
- console.log(` ${C.red}✗ Passphrase mismatch.${C.reset}`);
673
- console.log(` ${C.dim} Derived: ${derivedAddress}${C.reset}`);
674
- console.log(` ${C.dim} Expected: ${address}${C.reset}\n`);
675
- rl.close();
676
- return;
677
- }
678
-
679
- // Confirm
680
- const confirm = await question(rl, ` ${C.yellow}Confirm metadata update? [y/N]${C.reset} > `);
681
- if (!confirm.trim().toLowerCase().startsWith('y')) {
682
- console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
683
- rl.close();
684
- return;
685
- }
686
- rl.close();
687
-
688
- // Update via SDK
689
- const client = createClient(rpc);
690
-
691
- console.log(`\n ${C.dim}Submitting metadata update via SDK...${C.reset}`);
692
-
693
- try {
694
- const result = await client.updateMetadata({
695
- creator: address.startsWith('ATH') ? address.slice(3) : address,
696
- nftId: nftId.trim(),
697
- metadataUrl: metadataUrl.trim(),
698
- signFn: async (tx) => signTransaction(tx, keyPair.secretKey),
699
- });
700
-
701
- if (result.error) {
702
- throw new Error(result.error.message || JSON.stringify(result.error));
703
- }
704
-
705
- console.log(`\n${C.green}✓ Metadata updated successfully!${C.reset}`);
706
- console.log(` ${C.dim}NFT ID:${C.reset} ${C.cyan}${C.bright}${nftId}${C.reset}`);
707
- console.log(` ${C.dim}New Meta:${C.reset} ${truncate(metadataUrl, 45)}`);
708
- if (result.signature) {
709
- console.log(` ${C.dim}Signature:${C.reset} ${shortAddress(result.signature)}`);
710
- }
711
- if (result.slot) {
712
- console.log(` ${C.dim}Slot:${C.reset} ${result.slot}`);
713
- }
714
- console.log(` ${C.dim}SDK:${C.reset} updateMetadata() → POST /v1/transaction`);
715
- console.log();
716
- console.log(` ${C.dim}Verify update:${C.reset}`);
717
- console.log(` ${C.cyan}aether nft info --nft ${nftId}${C.reset}\n`);
718
-
719
- } catch (err) {
720
- console.log(`\n ${C.red} Metadata update failed:${C.reset} ${err.message}`);
721
- console.log(` ${C.dim}Common causes:${C.reset}`);
722
- console.log(` • You are not the NFT creator`);
723
- console.log(` • Invalid NFT ID`);
724
- console.log(` • Insufficient balance for transaction fee\n`);
725
- process.exit(1);
726
- }
727
- }
728
-
729
- // ============================================================================
730
- // Help
731
- // ============================================================================
732
-
733
- function showHelp() {
734
- console.log(`
735
- ${C.bright}${C.cyan}aether-cli nft${C.reset} NFT management suite
736
-
737
- ${C.bright}COMMANDS${C.reset}
738
- create Create a new NFT with metadata and royalties
739
- list List all NFTs owned by a wallet
740
- transfer Transfer an NFT to another address
741
- info Show detailed info about an NFT
742
- update Update NFT metadata (creator only)
743
-
744
- ${C.bright}USAGE${C.reset}
745
- aether nft create --metadata <url> [--royalties <bps>] [--address <wallet>]
746
- aether nft list --address <wallet> [--json]
747
- aether nft transfer --nft <id> --to <addr> [--from <wallet>]
748
- aether nft info --nft <id> [--json]
749
- aether nft update --nft <id> --metadata <url> [--address <wallet>]
750
-
751
- ${C.bright}SDK METHODS USED${C.reset}
752
- client.createNFT() → POST /v1/transaction (CreateNFT)
753
- client.transferNFT() → POST /v1/transaction (TransferNFT)
754
- client.updateMetadata() → POST /v1/transaction (UpdateMetadata)
755
- client.getTokenAccounts() → GET /v1/tokens/<addr>
756
- client.getAccountInfo() GET /v1/account/<addr>
757
-
758
- ${C.bright}EXAMPLES${C.reset}
759
- aether nft create --metadata https://example.com/nft.json --royalties 500
760
- aether nft list --address ATHxxx... --json
761
- aether nft transfer --nft NFTxxx... --to ATHyyy...
762
- aether nft info --nft NFTxxx... --json
763
-
764
- ${C.bright}NOTES${C.reset}
765
- Only the creator can update NFT metadata
766
- • Royalties are specified in basis points (100 = 1%, 500 = 5%)
767
- NFT creation fee is typically 0.01 AETH
768
- Metadata must be a valid JSON file hosted at the provided URL
769
-
770
- ${C.green} Fully wired to @jellylegsai/aether-sdk${C.reset}
771
- `);
772
- }
773
-
774
- // ============================================================================
775
- // Argument Parser
776
- // ============================================================================
777
-
778
- function parseArgs() {
779
- const rawArgs = process.argv.slice(3);
780
- const subcmd = rawArgs[0] || 'help';
781
- const allArgs = rawArgs.slice(1);
782
-
783
- const rpcIndex = allArgs.findIndex(a => a === '--rpc' || a === '-r');
784
- const rpc = rpcIndex !== -1 && allArgs[rpcIndex + 1] ? allArgs[rpcIndex + 1] : getDefaultRpc();
785
-
786
- const parsed = {
787
- subcmd,
788
- rpc,
789
- json: allArgs.includes('--json') || allArgs.includes('-j'),
790
- address: null,
791
- from: null,
792
- to: null,
793
- nft: null,
794
- metadata: null,
795
- royalties: null,
796
- };
797
-
798
- const addrIdx = allArgs.findIndex(a => a === '--address' || a === '-a');
799
- if (addrIdx !== -1 && allArgs[addrIdx + 1]) parsed.address = allArgs[addrIdx + 1];
800
-
801
- const fromIdx = allArgs.findIndex(a => a === '--from' || a === '-f');
802
- if (fromIdx !== -1 && allArgs[fromIdx + 1]) parsed.from = allArgs[fromIdx + 1];
803
-
804
- const toIdx = allArgs.findIndex(a => a === '--to' || a === '-t');
805
- if (toIdx !== -1 && allArgs[toIdx + 1]) parsed.to = allArgs[toIdx + 1];
806
-
807
- const nftIdx = allArgs.findIndex(a => a === '--nft' || a === '-n');
808
- if (nftIdx !== -1 && allArgs[nftIdx + 1]) parsed.nft = allArgs[nftIdx + 1];
809
-
810
- const metaIdx = allArgs.findIndex(a => a === '--metadata' || a === '-m');
811
- if (metaIdx !== -1 && allArgs[metaIdx + 1]) parsed.metadata = allArgs[metaIdx + 1];
812
-
813
- const royaltyIdx = allArgs.findIndex(a => a === '--royalties' || a === '-r');
814
- if (royaltyIdx !== -1 && allArgs[royaltyIdx + 1]) parsed.royalties = allArgs[royaltyIdx + 1];
815
-
816
- return parsed;
817
- }
818
-
819
- // ============================================================================
820
- // Main Entry Point
821
- // ============================================================================
822
-
823
- async function nftCommand() {
824
- const args = parseArgs();
825
-
826
- switch (args.subcmd) {
827
- case 'create':
828
- await nftCreate(args);
829
- break;
830
- case 'list':
831
- await nftList(args);
832
- break;
833
- case 'transfer':
834
- await nftTransfer(args);
835
- break;
836
- case 'info':
837
- await nftInfo(args);
838
- break;
839
- case 'update':
840
- await nftUpdate(args);
841
- break;
842
- case 'help':
843
- case '--help':
844
- case '-h':
845
- default:
846
- showHelp();
847
- }
848
- }
849
-
850
- module.exports = { nftCommand };
851
-
852
- if (require.main === module) {
853
- nftCommand().catch(err => {
854
- console.error(`\n${C.red}✗ NFT command failed:${C.reset}`, err.message, '\n');
855
- process.exit(1);
856
- });
857
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli nft
4
+ *
5
+ * NFT management commands for the Aether blockchain.
6
+ * Create, mint, transfer, and manage NFTs with real RPC calls.
7
+ *
8
+ * Usage:
9
+ * aether nft create --metadata <url> [--royalties <bps>] [--json]
10
+ * aether nft mint --nft <id> --amount <n> [--to <addr>] [--json]
11
+ * aether nft transfer --nft <id> --to <addr> [--json]
12
+ * aether nft update --nft <id> --metadata <url> [--json]
13
+ * aether nft list --address <addr> [--json]
14
+ * aether nft info --nft <id> [--json]
15
+ *
16
+ * SDK wired to:
17
+ * - client.sendTransaction(tx) POST /v1/transaction
18
+ * - client.getAccountInfo(addr) GET /v1/account/<addr>
19
+ * - client.getNFT(nftId) GET /v1/nft/<id>
20
+ * - client.getNFTHoldings(address) GET /v1/nft-holdings/<addr>
21
+ * - client.getSlot() GET /v1/slot
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const os = require('os');
27
+ const readline = require('readline');
28
+ const nacl = require('tweetnacl');
29
+ const bs58 = require('bs58').default;
30
+ const bip39 = require('bip39');
31
+
32
+ // Import SDK for ALL blockchain RPC calls
33
+ const sdkPath = path.join(__dirname, '..', 'sdk', 'index.js');
34
+ const aether = require(sdkPath);
35
+
36
+ // ANSI colours
37
+ const C = {
38
+ reset: '\x1b[0m',
39
+ bright: '\x1b[1m',
40
+ dim: '\x1b[2m',
41
+ red: '\x1b[31m',
42
+ green: '\x1b[32m',
43
+ yellow: '\x1b[33m',
44
+ cyan: '\x1b[36m',
45
+ magenta: '\x1b[35m',
46
+ blue: '\x1b[34m',
47
+ };
48
+
49
+ const CLI_VERSION = '1.0.0';
50
+ const DERIVATION_PATH = "m/44'/7777777'/0'/0'";
51
+
52
+ // ============================================================================
53
+ // SDK Setup
54
+ // ============================================================================
55
+
56
+ function getDefaultRpc() {
57
+ return process.env.AETHER_RPC || aether.DEFAULT_RPC_URL || 'http://127.0.0.1:8899';
58
+ }
59
+
60
+ function createClient(rpcUrl) {
61
+ return new aether.AetherClient({ rpcUrl });
62
+ }
63
+
64
+ // ============================================================================
65
+ // Config & Wallet
66
+ // ============================================================================
67
+
68
+ function getAetherDir() {
69
+ return path.join(os.homedir(), '.aether');
70
+ }
71
+
72
+ function getConfigPath() {
73
+ return path.join(getAetherDir(), 'config.json');
74
+ }
75
+
76
+ function loadConfig() {
77
+ if (!fs.existsSync(getConfigPath())) {
78
+ return { defaultWallet: null };
79
+ }
80
+ try {
81
+ return JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
82
+ } catch {
83
+ return { defaultWallet: null };
84
+ }
85
+ }
86
+
87
+ function loadWallet(address) {
88
+ const fp = path.join(getAetherDir(), 'wallets', `${address}.json`);
89
+ if (!fs.existsSync(fp)) return null;
90
+ try {
91
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // ============================================================================
98
+ // Crypto Helpers
99
+ // ============================================================================
100
+
101
+ function deriveKeypair(mnemonic) {
102
+ if (!bip39.validateMnemonic(mnemonic)) {
103
+ throw new Error('Invalid mnemonic phrase');
104
+ }
105
+ const seedBuffer = bip39.mnemonicToSeedSync(mnemonic, '');
106
+ const seed32 = seedBuffer.slice(0, 32);
107
+ const keyPair = nacl.sign.keyPair.fromSeed(seed32);
108
+ return {
109
+ publicKey: Buffer.from(keyPair.publicKey),
110
+ secretKey: Buffer.from(keyPair.secretKey),
111
+ };
112
+ }
113
+
114
+ function formatAddress(publicKey) {
115
+ return 'ATH' + bs58.encode(publicKey);
116
+ }
117
+
118
+ function signTransaction(tx, secretKey) {
119
+ const txBytes = Buffer.from(JSON.stringify(tx));
120
+ const sig = nacl.sign.detached(txBytes, secretKey);
121
+ return bs58.encode(sig);
122
+ }
123
+
124
+ // ============================================================================
125
+ // Format Helpers
126
+ // ============================================================================
127
+
128
+ function shortAddress(addr) {
129
+ if (!addr || addr.length < 20) return addr || 'unknown';
130
+ return addr.slice(0, 8) + '...' + addr.slice(-8);
131
+ }
132
+
133
+ function formatTimestamp(ts) {
134
+ if (!ts) return 'unknown';
135
+ const date = new Date(ts * 1000);
136
+ return date.toLocaleString();
137
+ }
138
+
139
+ // ============================================================================
140
+ // Readline Helpers
141
+ // ============================================================================
142
+
143
+ function createRl() {
144
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
145
+ }
146
+
147
+ function question(rl, q) {
148
+ return new Promise((res) => rl.question(q, res));
149
+ }
150
+
151
+ async function askMnemonic(rl, promptText) {
152
+ console.log(`\n${C.cyan}${promptText}${C.reset}`);
153
+ console.log(`${C.dim}Enter your 12 or 24-word passphrase, one space-separated line:${C.reset}`);
154
+ const raw = await question(rl, ` > ${C.reset}`);
155
+ return raw.trim().toLowerCase();
156
+ }
157
+
158
+ async function askConfirm(rl, text) {
159
+ const ans = await question(rl, `\n${C.yellow}${text} [y/N]${C.reset} > `);
160
+ return ans.trim().toLowerCase().startsWith('y');
161
+ }
162
+
163
+ // ============================================================================
164
+ // NFT SDK Fetchers (REAL RPC CALLS)
165
+ // ============================================================================
166
+
167
+ /**
168
+ * Fetch NFT details via SDK
169
+ * REAL RPC: GET /v1/nft/<id>
170
+ */
171
+ async function fetchNFT(rpcUrl, nftId) {
172
+ const client = createClient(rpcUrl);
173
+ try {
174
+ const nft = await client.getNFT(nftId);
175
+ if (!nft || nft.error) return null;
176
+ return {
177
+ id: nft.id || nftId,
178
+ creator: nft.creator || nft.mint_authority,
179
+ metadata: nft.metadata_url || nft.metadata,
180
+ royalties: nft.royalties || nft.royalty_bps || 0,
181
+ supply: nft.supply || nft.current_supply || 0,
182
+ maxSupply: nft.max_supply,
183
+ createdAt: nft.created_at,
184
+ updateAuthority: nft.update_authority,
185
+ };
186
+ } catch (err) {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Fetch NFT holdings for an address via SDK
193
+ * REAL RPC: GET /v1/nft-holdings/<addr>
194
+ */
195
+ async function fetchNFTHoldings(rpcUrl, address) {
196
+ const client = createClient(rpcUrl);
197
+ try {
198
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
199
+ const holdings = await client.getNFTHoldings(rawAddr);
200
+ if (!Array.isArray(holdings)) return [];
201
+ return holdings.map(h => ({
202
+ nftId: h.nft_id || h.id || h.mint,
203
+ amount: h.amount || h.balance || 1,
204
+ acquiredAt: h.acquired_at,
205
+ metadata: h.metadata_url,
206
+ }));
207
+ } catch (err) {
208
+ return [];
209
+ }
210
+ }
211
+
212
+ // ============================================================================
213
+ // NFT Create Command
214
+ // ============================================================================
215
+
216
+ async function nftCreate(args) {
217
+ const rpc = args.rpc || getDefaultRpc();
218
+ const isJson = args.json || false;
219
+ const rl = createRl();
220
+
221
+ // Resolve wallet address
222
+ let address = args.address;
223
+ if (!address) {
224
+ const cfg = loadConfig();
225
+ address = cfg.defaultWallet;
226
+ }
227
+
228
+ if (!address) {
229
+ console.log(`\n ${C.red}✗ No wallet address provided.${C.reset}`);
230
+ console.log(` ${C.dim}Set default: aether wallet default --set <addr>${C.reset}\n`);
231
+ rl.close();
232
+ return;
233
+ }
234
+
235
+ const wallet = loadWallet(address);
236
+ if (!wallet) {
237
+ console.log(`\n ${C.red} Wallet not found locally: ${address}${C.reset}`);
238
+ console.log(` ${C.dim}Import it: aether wallet import${C.reset}\n`);
239
+ rl.close();
240
+ return;
241
+ }
242
+
243
+ // Get metadata URL
244
+ let metadataUrl = args.metadata;
245
+ if (!metadataUrl) {
246
+ console.log(`\n${C.bright}${C.cyan}── Create NFT ────────────────────────────────────────────${C.reset}\n`);
247
+ console.log(` ${C.dim}Enter metadata URL (IPFS/Arweave/HTTPS):${C.reset}`);
248
+ metadataUrl = await question(rl, ` Metadata URL > ${C.reset}`);
249
+ if (!metadataUrl) {
250
+ console.log(`\n ${C.red}✗ Metadata URL is required.${C.reset}\n`);
251
+ rl.close();
252
+ return;
253
+ }
254
+ }
255
+
256
+ // Get royalties
257
+ let royalties = args.royalties || 0;
258
+ if (!args.royalties && !isJson) {
259
+ const roy = await question(rl, ` ${C.dim}Royalties (basis points, 0-10000) [0]:${C.reset} `);
260
+ royalties = parseInt(roy, 10) || 0;
261
+ }
262
+
263
+ if (royalties < 0 || royalties > 10000) {
264
+ console.log(`\n ${C.red}✗ Royalties must be between 0 and 10000 basis points.${C.reset}\n`);
265
+ rl.close();
266
+ return;
267
+ }
268
+
269
+ // Summary
270
+ console.log(`\n ${C.green}★${C.reset} Creator: ${C.bright}${address}${C.reset}`);
271
+ console.log(` ${C.green}★${C.reset} Metadata: ${C.bright}${metadataUrl}${C.reset}`);
272
+ console.log(` ${C.green}★${C.reset} Royalties: ${C.bright}${royalties} bps (${(royalties / 100).toFixed(2)}%)${C.reset}`);
273
+ console.log();
274
+
275
+ if (args.dryRun) {
276
+ console.log(` ${C.yellow}⚠ Dry run - no transaction submitted${C.reset}\n`);
277
+ rl.close();
278
+ return;
279
+ }
280
+
281
+ // Get mnemonic for signing
282
+ let keyPair;
283
+ try {
284
+ const mnemonic = await askMnemonic(rl, 'Enter your wallet passphrase to sign the NFT creation');
285
+ keyPair = deriveKeypair(mnemonic);
286
+ const derivedAddress = formatAddress(keyPair.publicKey);
287
+ if (derivedAddress !== address) {
288
+ console.log(`\n ${C.red}✗ Passphrase mismatch!${C.reset}`);
289
+ console.log(` ${C.dim} Derived: ${derivedAddress}${C.reset}`);
290
+ console.log(` ${C.dim} Expected: ${address}${C.reset}\n`);
291
+ rl.close();
292
+ return;
293
+ }
294
+ } catch (e) {
295
+ console.log(`\n ${C.red} Failed to derive keypair: ${e.message}${C.reset}\n`);
296
+ rl.close();
297
+ return;
298
+ }
299
+
300
+ const confirm = await askConfirm(rl, 'Create this NFT?');
301
+ if (!confirm) {
302
+ console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
303
+ rl.close();
304
+ return;
305
+ }
306
+ rl.close();
307
+
308
+ // Build and submit transaction via SDK
309
+ const client = createClient(rpc);
310
+ const slot = await client.getSlot().catch(() => 0);
311
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
312
+
313
+ const tx = {
314
+ signer: rawAddr,
315
+ tx_type: 'CreateNFT',
316
+ payload: {
317
+ type: 'CreateNFT',
318
+ data: {
319
+ metadata_url: metadataUrl,
320
+ royalties: royalties,
321
+ },
322
+ },
323
+ fee: 10000, // Higher fee for NFT creation
324
+ slot: slot,
325
+ timestamp: Math.floor(Date.now() / 1000),
326
+ };
327
+
328
+ tx.signature = signTransaction(tx, keyPair.secretKey);
329
+
330
+ try {
331
+ if (!isJson) {
332
+ console.log(`\n ${C.dim}Submitting via SDK to ${rpc}...${C.reset}`);
333
+ }
334
+ const result = await client.sendTransaction(tx);
335
+
336
+ if (result.error) {
337
+ throw new Error(result.error.message || JSON.stringify(result.error));
338
+ }
339
+
340
+ // Generate NFT ID from result
341
+ const nftId = result.nft_id || result.mint || `NFT-${result.signature?.slice(0, 16) || 'UNKNOWN'}`;
342
+
343
+ if (isJson) {
344
+ console.log(JSON.stringify({
345
+ success: true,
346
+ nft_id: nftId,
347
+ creator: address,
348
+ metadata_url: metadataUrl,
349
+ royalties_bps: royalties,
350
+ tx_signature: result.signature || result.txid,
351
+ slot: result.slot || slot,
352
+ rpc,
353
+ cli_version: CLI_VERSION,
354
+ timestamp: new Date().toISOString(),
355
+ }, null, 2));
356
+ } else {
357
+ console.log(`\n${C.green}✓ NFT created successfully!${C.reset}\n`);
358
+ console.log(` ${C.green}★${C.reset} NFT ID: ${C.bright}${C.cyan}${nftId}${C.reset}`);
359
+ console.log(` ${C.green}★${C.reset} Creator: ${address}`);
360
+ console.log(` ${C.green}★${C.reset} Metadata: ${metadataUrl}`);
361
+ console.log(` ${C.green}★${C.reset} Royalties: ${(royalties / 100).toFixed(2)}%`);
362
+ if (result.signature || result.txid) {
363
+ console.log(` ${C.dim} Tx Signature: ${(result.signature || result.txid).slice(0, 40)}...${C.reset}`);
364
+ }
365
+ console.log(` ${C.dim} Slot: ${result.slot || slot}${C.reset}`);
366
+ console.log();
367
+ console.log(` ${C.dim}Next steps:${C.reset}`);
368
+ console.log(` ${C.cyan}aether nft mint --nft ${nftId} --amount 1${C.reset}`);
369
+ console.log(` ${C.cyan}aether nft info --nft ${nftId}${C.reset}`);
370
+ console.log();
371
+ }
372
+ } catch (err) {
373
+ if (isJson) {
374
+ console.log(JSON.stringify({
375
+ success: false,
376
+ error: err.message,
377
+ creator: address,
378
+ metadata_url: metadataUrl,
379
+ }, null, 2));
380
+ } else {
381
+ console.log(`\n ${C.red}NFT creation failed: ${err.message}${C.reset}\n`);
382
+ }
383
+ process.exit(1);
384
+ }
385
+ }
386
+
387
+ // ============================================================================
388
+ // NFT Mint Command
389
+ // ============================================================================
390
+
391
+ async function nftMint(args) {
392
+ const rpc = args.rpc || getDefaultRpc();
393
+ const isJson = args.json || false;
394
+ const rl = createRl();
395
+
396
+ // Resolve wallet address
397
+ let address = args.address;
398
+ if (!address) {
399
+ const cfg = loadConfig();
400
+ address = cfg.defaultWallet;
401
+ }
402
+
403
+ if (!address) {
404
+ console.log(`\n ${C.red}✗ No wallet address provided.${C.reset}\n`);
405
+ rl.close();
406
+ return;
407
+ }
408
+
409
+ const wallet = loadWallet(address);
410
+ if (!wallet) {
411
+ console.log(`\n ${C.red}✗ Wallet not found locally: ${address}${C.reset}\n`);
412
+ rl.close();
413
+ return;
414
+ }
415
+
416
+ // Get NFT ID
417
+ let nftId = args.nft;
418
+ if (!nftId) {
419
+ console.log(`\n${C.bright}${C.cyan}── Mint NFT ─────────────────────────────────────────────${C.reset}\n`);
420
+ console.log(` ${C.dim}Enter NFT ID to mint:${C.reset}`);
421
+ nftId = await question(rl, ` NFT ID > ${C.reset}`);
422
+ if (!nftId) {
423
+ console.log(`\n ${C.red} NFT ID is required.${C.reset}\n`);
424
+ rl.close();
425
+ return;
426
+ }
427
+ }
428
+
429
+ // Get amount
430
+ let amount = args.amount;
431
+ if (!amount) {
432
+ const amt = await question(rl, ` ${C.dim}Amount to mint [1]:${C.reset} `);
433
+ amount = parseInt(amt, 10) || 1;
434
+ } else {
435
+ amount = parseInt(amount, 10);
436
+ }
437
+
438
+ if (isNaN(amount) || amount < 1) {
439
+ console.log(`\n ${C.red}✗ Invalid amount.${C.reset}\n`);
440
+ rl.close();
441
+ return;
442
+ }
443
+
444
+ // Get recipient (optional, defaults to self)
445
+ let recipient = args.to || address;
446
+
447
+ // Verify NFT exists
448
+ const nftInfo = await fetchNFT(rpc, nftId);
449
+ if (!nftInfo && !isJson) {
450
+ console.log(`\n ${C.yellow}⚠ Warning: Could not verify NFT exists.${C.reset}`);
451
+ console.log(` ${C.dim} Continuing anyway...${C.reset}\n`);
452
+ }
453
+
454
+ // Summary
455
+ console.log(`\n ${C.green}★${C.reset} NFT: ${C.bright}${nftId}${C.reset}`);
456
+ if (nftInfo) {
457
+ console.log(` ${C.green}★${C.reset} Creator: ${shortAddress(nftInfo.creator)}`);
458
+ console.log(` ${C.green}★${C.reset} Current Supply: ${nftInfo.supply}`);
459
+ }
460
+ console.log(` ${C.green}★${C.reset} Amount: ${C.bright}${amount}${C.reset}`);
461
+ console.log(` ${C.green}★${C.reset} Recipient: ${C.bright}${recipient}${C.reset}`);
462
+ console.log();
463
+
464
+ if (args.dryRun) {
465
+ console.log(` ${C.yellow} Dry run - no transaction submitted${C.reset}\n`);
466
+ rl.close();
467
+ return;
468
+ }
469
+
470
+ // Get mnemonic for signing
471
+ let keyPair;
472
+ try {
473
+ const mnemonic = await askMnemonic(rl, 'Enter your wallet passphrase to sign the mint');
474
+ keyPair = deriveKeypair(mnemonic);
475
+ const derivedAddress = formatAddress(keyPair.publicKey);
476
+ if (derivedAddress !== address) {
477
+ console.log(`\n ${C.red}✗ Passphrase mismatch!${C.reset}\n`);
478
+ rl.close();
479
+ return;
480
+ }
481
+ } catch (e) {
482
+ console.log(`\n ${C.red} Failed to derive keypair: ${e.message}${C.reset}\n`);
483
+ rl.close();
484
+ return;
485
+ }
486
+
487
+ const confirm = await askConfirm(rl, 'Mint these NFTs?');
488
+ if (!confirm) {
489
+ console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
490
+ rl.close();
491
+ return;
492
+ }
493
+ rl.close();
494
+
495
+ // Build and submit transaction via SDK
496
+ const client = createClient(rpc);
497
+ const slot = await client.getSlot().catch(() => 0);
498
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
499
+ const rawRecipient = recipient.startsWith('ATH') ? recipient.slice(3) : recipient;
500
+
501
+ const tx = {
502
+ signer: rawAddr,
503
+ tx_type: 'MintNFT',
504
+ payload: {
505
+ type: 'MintNFT',
506
+ data: {
507
+ nft_id: nftId,
508
+ amount: amount,
509
+ recipient: rawRecipient,
510
+ },
511
+ },
512
+ fee: 5000,
513
+ slot: slot,
514
+ timestamp: Math.floor(Date.now() / 1000),
515
+ };
516
+
517
+ tx.signature = signTransaction(tx, keyPair.secretKey);
518
+
519
+ try {
520
+ if (!isJson) {
521
+ console.log(`\n ${C.dim}Submitting via SDK...${C.reset}`);
522
+ }
523
+ const result = await client.sendTransaction(tx);
524
+
525
+ if (result.error) {
526
+ throw new Error(result.error.message || JSON.stringify(result.error));
527
+ }
528
+
529
+ if (isJson) {
530
+ console.log(JSON.stringify({
531
+ success: true,
532
+ nft_id: nftId,
533
+ amount: amount,
534
+ recipient: recipient,
535
+ minter: address,
536
+ tx_signature: result.signature || result.txid,
537
+ slot: result.slot || slot,
538
+ rpc,
539
+ cli_version: CLI_VERSION,
540
+ timestamp: new Date().toISOString(),
541
+ }, null, 2));
542
+ } else {
543
+ console.log(`\n${C.green} NFT minted successfully!${C.reset}\n`);
544
+ console.log(` ${C.green}★${C.reset} NFT ID: ${C.cyan}${nftId}${C.reset}`);
545
+ console.log(` ${C.green}★${C.reset} Amount: ${amount}`);
546
+ console.log(` ${C.green}★${C.reset} Recipient: ${recipient}`);
547
+ if (result.signature || result.txid) {
548
+ console.log(` ${C.dim} Tx: ${(result.signature || result.txid).slice(0, 40)}...${C.reset}`);
549
+ }
550
+ console.log(` ${C.dim} Slot: ${result.slot || slot}${C.reset}`);
551
+ console.log();
552
+ }
553
+ } catch (err) {
554
+ if (isJson) {
555
+ console.log(JSON.stringify({
556
+ success: false,
557
+ error: err.message,
558
+ nft_id: nftId,
559
+ }, null, 2));
560
+ } else {
561
+ console.log(`\n ${C.red}✗ NFT mint failed: ${err.message}${C.reset}\n`);
562
+ }
563
+ process.exit(1);
564
+ }
565
+ }
566
+
567
+ // ============================================================================
568
+ // NFT Transfer Command
569
+ // ============================================================================
570
+
571
+ async function nftTransfer(args) {
572
+ const rpc = args.rpc || getDefaultRpc();
573
+ const isJson = args.json || false;
574
+ const rl = createRl();
575
+
576
+ // Resolve wallet address
577
+ let address = args.address;
578
+ if (!address) {
579
+ const cfg = loadConfig();
580
+ address = cfg.defaultWallet;
581
+ }
582
+
583
+ if (!address) {
584
+ console.log(`\n ${C.red} No wallet address provided.${C.reset}\n`);
585
+ rl.close();
586
+ return;
587
+ }
588
+
589
+ const wallet = loadWallet(address);
590
+ if (!wallet) {
591
+ console.log(`\n ${C.red}✗ Wallet not found: ${address}${C.reset}\n`);
592
+ rl.close();
593
+ return;
594
+ }
595
+
596
+ // Get NFT ID
597
+ let nftId = args.nft;
598
+ if (!nftId) {
599
+ console.log(`\n${C.bright}${C.cyan}── Transfer NFT ─────────────────────────────────────────${C.reset}\n`);
600
+ console.log(` ${C.dim}Enter NFT ID to transfer:${C.reset}`);
601
+ nftId = await question(rl, ` NFT ID > ${C.reset}`);
602
+ if (!nftId) {
603
+ console.log(`\n ${C.red}✗ NFT ID is required.${C.reset}\n`);
604
+ rl.close();
605
+ return;
606
+ }
607
+ }
608
+
609
+ // Get recipient
610
+ let recipient = args.to;
611
+ if (!recipient) {
612
+ console.log(` ${C.dim}Enter recipient address:${C.reset}`);
613
+ recipient = await question(rl, ` Recipient > ${C.reset}`);
614
+ if (!recipient) {
615
+ console.log(`\n ${C.red}✗ Recipient address is required.${C.reset}\n`);
616
+ rl.close();
617
+ return;
618
+ }
619
+ }
620
+
621
+ // Get amount (for semi-fungible NFTs)
622
+ let amount = args.amount || 1;
623
+
624
+ // Summary
625
+ console.log(`\n ${C.green}★${C.reset} NFT: ${C.bright}${nftId}${C.reset}`);
626
+ console.log(` ${C.green}★${C.reset} From: ${address}`);
627
+ console.log(` ${C.green}★${C.reset} To: ${C.bright}${C.cyan}${recipient}${C.reset}`);
628
+ console.log(` ${C.green}★${C.reset} Amount: ${amount}`);
629
+ console.log();
630
+
631
+ if (args.dryRun) {
632
+ console.log(` ${C.yellow}⚠ Dry run - no transaction submitted${C.reset}\n`);
633
+ rl.close();
634
+ return;
635
+ }
636
+
637
+ // Get mnemonic for signing
638
+ let keyPair;
639
+ try {
640
+ const mnemonic = await askMnemonic(rl, 'Enter your wallet passphrase to sign the transfer');
641
+ keyPair = deriveKeypair(mnemonic);
642
+ const derivedAddress = formatAddress(keyPair.publicKey);
643
+ if (derivedAddress !== address) {
644
+ console.log(`\n ${C.red}✗ Passphrase mismatch!${C.reset}\n`);
645
+ rl.close();
646
+ return;
647
+ }
648
+ } catch (e) {
649
+ console.log(`\n ${C.red}✗ Failed to derive keypair: ${e.message}${C.reset}\n`);
650
+ rl.close();
651
+ return;
652
+ }
653
+
654
+ const confirm = await askConfirm(rl, `Transfer ${amount} of ${nftId} to ${shortAddress(recipient)}?`);
655
+ if (!confirm) {
656
+ console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
657
+ rl.close();
658
+ return;
659
+ }
660
+ rl.close();
661
+
662
+ // Build and submit transaction via SDK
663
+ const client = createClient(rpc);
664
+ const slot = await client.getSlot().catch(() => 0);
665
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
666
+ const rawRecipient = recipient.startsWith('ATH') ? recipient.slice(3) : recipient;
667
+
668
+ const tx = {
669
+ signer: rawAddr,
670
+ tx_type: 'TransferNFT',
671
+ payload: {
672
+ type: 'TransferNFT',
673
+ data: {
674
+ nft_id: nftId,
675
+ recipient: rawRecipient,
676
+ amount: amount,
677
+ },
678
+ },
679
+ fee: 5000,
680
+ slot: slot,
681
+ timestamp: Math.floor(Date.now() / 1000),
682
+ };
683
+
684
+ tx.signature = signTransaction(tx, keyPair.secretKey);
685
+
686
+ try {
687
+ if (!isJson) {
688
+ console.log(`\n ${C.dim}Submitting via SDK...${C.reset}`);
689
+ }
690
+ const result = await client.sendTransaction(tx);
691
+
692
+ if (result.error) {
693
+ throw new Error(result.error.message || JSON.stringify(result.error));
694
+ }
695
+
696
+ if (isJson) {
697
+ console.log(JSON.stringify({
698
+ success: true,
699
+ nft_id: nftId,
700
+ amount: amount,
701
+ from: address,
702
+ to: recipient,
703
+ tx_signature: result.signature || result.txid,
704
+ slot: result.slot || slot,
705
+ rpc,
706
+ cli_version: CLI_VERSION,
707
+ timestamp: new Date().toISOString(),
708
+ }, null, 2));
709
+ } else {
710
+ console.log(`\n${C.green}✓ NFT transferred successfully!${C.reset}\n`);
711
+ console.log(` ${C.green}★${C.reset} NFT ID: ${C.cyan}${nftId}${C.reset}`);
712
+ console.log(` ${C.green}★${C.reset} From: ${shortAddress(address)}`);
713
+ console.log(` ${C.green}★${C.reset} To: ${C.cyan}${shortAddress(recipient)}${C.reset}`);
714
+ console.log(` ${C.green}★${C.reset} Amount: ${amount}`);
715
+ if (result.signature || result.txid) {
716
+ console.log(` ${C.dim} Tx: ${(result.signature || result.txid).slice(0, 40)}...${C.reset}`);
717
+ }
718
+ console.log();
719
+ }
720
+ } catch (err) {
721
+ if (isJson) {
722
+ console.log(JSON.stringify({
723
+ success: false,
724
+ error: err.message,
725
+ nft_id: nftId,
726
+ }, null, 2));
727
+ } else {
728
+ console.log(`\n ${C.red}✗ NFT transfer failed: ${err.message}${C.reset}\n`);
729
+ }
730
+ process.exit(1);
731
+ }
732
+ }
733
+
734
+ // ============================================================================
735
+ // NFT Update Metadata Command
736
+ // ============================================================================
737
+
738
+ async function nftUpdate(args) {
739
+ const rpc = args.rpc || getDefaultRpc();
740
+ const isJson = args.json || false;
741
+ const rl = createRl();
742
+
743
+ // Resolve wallet address
744
+ let address = args.address;
745
+ if (!address) {
746
+ const cfg = loadConfig();
747
+ address = cfg.defaultWallet;
748
+ }
749
+
750
+ if (!address) {
751
+ console.log(`\n ${C.red} No wallet address provided.${C.reset}\n`);
752
+ rl.close();
753
+ return;
754
+ }
755
+
756
+ const wallet = loadWallet(address);
757
+ if (!wallet) {
758
+ console.log(`\n ${C.red}✗ Wallet not found: ${address}${C.reset}\n`);
759
+ rl.close();
760
+ return;
761
+ }
762
+
763
+ // Get NFT ID
764
+ let nftId = args.nft;
765
+ if (!nftId) {
766
+ console.log(`\n${C.bright}${C.cyan}── Update NFT Metadata ──────────────────────────────────${C.reset}\n`);
767
+ console.log(` ${C.dim}Enter NFT ID to update:${C.reset}`);
768
+ nftId = await question(rl, ` NFT ID > ${C.reset}`);
769
+ if (!nftId) {
770
+ console.log(`\n ${C.red} NFT ID is required.${C.reset}\n`);
771
+ rl.close();
772
+ return;
773
+ }
774
+ }
775
+
776
+ // Get new metadata URL
777
+ let metadataUrl = args.metadata;
778
+ if (!metadataUrl) {
779
+ console.log(` ${C.dim}Enter new metadata URL:${C.reset}`);
780
+ metadataUrl = await question(rl, ` New Metadata URL > ${C.reset}`);
781
+ if (!metadataUrl) {
782
+ console.log(`\n ${C.red}✗ Metadata URL is required.${C.reset}\n`);
783
+ rl.close();
784
+ return;
785
+ }
786
+ }
787
+
788
+ // Verify NFT exists
789
+ const nftInfo = await fetchNFT(rpc, nftId);
790
+ if (!nftInfo) {
791
+ console.log(`\n ${C.red}✗ NFT not found: ${nftId}${C.reset}\n`);
792
+ rl.close();
793
+ return;
794
+ }
795
+
796
+ // Check if user is update authority
797
+ if (nftInfo.updateAuthority && nftInfo.updateAuthority !== address) {
798
+ console.log(`\n ${C.yellow}⚠ Warning: You may not be the update authority.${C.reset}`);
799
+ console.log(` ${C.dim} Update Authority: ${nftInfo.updateAuthority}${C.reset}`);
800
+ console.log(` ${C.dim} Your Address: ${address}${C.reset}\n`);
801
+ }
802
+
803
+ // Summary
804
+ console.log(`\n ${C.green}★${C.reset} NFT: ${C.bright}${nftId}${C.reset}`);
805
+ console.log(` ${C.green}★${C.reset} Current: ${C.dim}${nftInfo.metadata}${C.reset}`);
806
+ console.log(` ${C.green}★${C.reset} New Metadata: ${C.bright}${metadataUrl}${C.reset}`);
807
+ console.log();
808
+
809
+ if (args.dryRun) {
810
+ console.log(` ${C.yellow}⚠ Dry run - no transaction submitted${C.reset}\n`);
811
+ rl.close();
812
+ return;
813
+ }
814
+
815
+ // Get mnemonic for signing
816
+ let keyPair;
817
+ try {
818
+ const mnemonic = await askMnemonic(rl, 'Enter your wallet passphrase to sign the update');
819
+ keyPair = deriveKeypair(mnemonic);
820
+ const derivedAddress = formatAddress(keyPair.publicKey);
821
+ if (derivedAddress !== address) {
822
+ console.log(`\n ${C.red}✗ Passphrase mismatch!${C.reset}\n`);
823
+ rl.close();
824
+ return;
825
+ }
826
+ } catch (e) {
827
+ console.log(`\n ${C.red}✗ Failed to derive keypair: ${e.message}${C.reset}\n`);
828
+ rl.close();
829
+ return;
830
+ }
831
+
832
+ const confirm = await askConfirm(rl, 'Update metadata?');
833
+ if (!confirm) {
834
+ console.log(`\n ${C.dim}Cancelled.${C.reset}\n`);
835
+ rl.close();
836
+ return;
837
+ }
838
+ rl.close();
839
+
840
+ // Build and submit transaction via SDK
841
+ const client = createClient(rpc);
842
+ const slot = await client.getSlot().catch(() => 0);
843
+ const rawAddr = address.startsWith('ATH') ? address.slice(3) : address;
844
+
845
+ const tx = {
846
+ signer: rawAddr,
847
+ tx_type: 'UpdateMetadata',
848
+ payload: {
849
+ type: 'UpdateMetadata',
850
+ data: {
851
+ nft_id: nftId,
852
+ metadata_url: metadataUrl,
853
+ },
854
+ },
855
+ fee: 5000,
856
+ slot: slot,
857
+ timestamp: Math.floor(Date.now() / 1000),
858
+ };
859
+
860
+ tx.signature = signTransaction(tx, keyPair.secretKey);
861
+
862
+ try {
863
+ if (!isJson) {
864
+ console.log(`\n ${C.dim}Submitting via SDK...${C.reset}`);
865
+ }
866
+ const result = await client.sendTransaction(tx);
867
+
868
+ if (result.error) {
869
+ throw new Error(result.error.message || JSON.stringify(result.error));
870
+ }
871
+
872
+ if (isJson) {
873
+ console.log(JSON.stringify({
874
+ success: true,
875
+ nft_id: nftId,
876
+ new_metadata: metadataUrl,
877
+ updater: address,
878
+ tx_signature: result.signature || result.txid,
879
+ slot: result.slot || slot,
880
+ rpc,
881
+ cli_version: CLI_VERSION,
882
+ timestamp: new Date().toISOString(),
883
+ }, null, 2));
884
+ } else {
885
+ console.log(`\n${C.green}✓ NFT metadata updated!${C.reset}\n`);
886
+ console.log(` ${C.green}★${C.reset} NFT ID: ${C.cyan}${nftId}${C.reset}`);
887
+ console.log(` ${C.green}★${C.reset} New Metadata: ${metadataUrl}`);
888
+ if (result.signature || result.txid) {
889
+ console.log(` ${C.dim} Tx: ${(result.signature || result.txid).slice(0, 40)}...${C.reset}`);
890
+ }
891
+ console.log();
892
+ }
893
+ } catch (err) {
894
+ if (isJson) {
895
+ console.log(JSON.stringify({
896
+ success: false,
897
+ error: err.message,
898
+ nft_id: nftId,
899
+ }, null, 2));
900
+ } else {
901
+ console.log(`\n ${C.red}✗ Metadata update failed: ${err.message}${C.reset}\n`);
902
+ }
903
+ process.exit(1);
904
+ }
905
+ }
906
+
907
+ // ============================================================================
908
+ // NFT List Command
909
+ // ============================================================================
910
+
911
+ async function nftList(args) {
912
+ const rpc = args.rpc || getDefaultRpc();
913
+ const isJson = args.json || false;
914
+ let address = args.address;
915
+
916
+ // Resolve address
917
+ if (!address) {
918
+ const cfg = loadConfig();
919
+ address = cfg.defaultWallet;
920
+ }
921
+
922
+ if (!address) {
923
+ if (isJson) {
924
+ console.log(JSON.stringify({ error: 'No address provided and no default wallet' }, null, 2));
925
+ } else {
926
+ console.log(`\n ${C.red}✗ No wallet address specified.${C.reset}\n`);
927
+ }
928
+ return;
929
+ }
930
+
931
+ // Fetch NFT holdings via SDK (REAL RPC)
932
+ const holdings = await fetchNFTHoldings(rpc, address);
933
+
934
+ if (isJson) {
935
+ console.log(JSON.stringify({
936
+ address,
937
+ rpc,
938
+ holdings: holdings.map(h => ({
939
+ nft_id: h.nftId,
940
+ amount: h.amount,
941
+ metadata: h.metadata,
942
+ acquired_at: h.acquiredAt,
943
+ })),
944
+ total_nfts: holdings.length,
945
+ cli_version: CLI_VERSION,
946
+ timestamp: new Date().toISOString(),
947
+ }, null, 2));
948
+ return;
949
+ }
950
+
951
+ console.log(`\n${C.bright}${C.cyan}╔═══════════════════════════════════════════════════════════════════╗${C.reset}`);
952
+ console.log(`${C.bright}${C.cyan}║ NFT HOLDINGS — ${shortAddress(address).padEnd(30)}║${C.reset}`);
953
+ console.log(`${C.bright}${C.cyan}╚═══════════════════════════════════════════════════════════════════╝${C.reset}\n`);
954
+ console.log(` ${C.dim}RPC: ${rpc}${C.reset}\n`);
955
+
956
+ if (holdings.length === 0) {
957
+ console.log(` ${C.yellow}⚠ No NFTs found for this wallet.${C.reset}`);
958
+ console.log(` ${C.dim}Create an NFT:${C.reset}`);
959
+ console.log(` ${C.cyan}aether nft create --metadata <url>${C.reset}\n`);
960
+ return;
961
+ }
962
+
963
+ console.log(` ${C.dim}┌─────────────────────────────────────────────────────────────────────┐${C.reset}`);
964
+ console.log(` ${C.dim}│${C.reset} ${C.bright}# NFT ID Amount Metadata${C.reset} ${C.dim}│${C.reset}`);
965
+ console.log(` ${C.dim}├─────────────────────────────────────────────────────────────────────┤${C.reset}`);
966
+
967
+ holdings.forEach((h, i) => {
968
+ const num = (i + 1).toString().padStart(2);
969
+ const shortId = shortAddress(h.nftId).padEnd(30);
970
+ const amt = h.amount.toString().padStart(6);
971
+ const meta = h.metadata ? h.metadata.substring(0, 20) + '...' : 'N/A';
972
+ console.log(` ${C.dim}│${C.reset} ${num} ${shortId} ${amt} ${C.dim}${meta}${C.reset} ${C.dim}│${C.reset}`);
973
+ });
974
+
975
+ console.log(` ${C.dim}└─────────────────────────────────────────────────────────────────────┘${C.reset}`);
976
+ console.log(`\n ${C.bright}Total NFTs:${C.reset} ${holdings.length}`);
977
+ console.log(` ${C.dim}SDK: getNFTHoldings()${C.reset}\n`);
978
+ }
979
+
980
+ // ============================================================================
981
+ // NFT Info Command
982
+ // ============================================================================
983
+
984
+ async function nftInfo(args) {
985
+ const rpc = args.rpc || getDefaultRpc();
986
+ const isJson = args.json || false;
987
+ const nftId = args.nft;
988
+
989
+ if (!nftId) {
990
+ if (isJson) {
991
+ console.log(JSON.stringify({ error: 'NFT ID required (--nft <id>)' }, null, 2));
992
+ } else {
993
+ console.log(`\n ${C.red}✗ NFT ID required.${C.reset}`);
994
+ console.log(` ${C.dim}Usage: aether nft info --nft <id>${C.reset}\n`);
995
+ }
996
+ return;
997
+ }
998
+
999
+ // Fetch NFT info via SDK (REAL RPC)
1000
+ const nft = await fetchNFT(rpc, nftId);
1001
+
1002
+ if (!nft) {
1003
+ if (isJson) {
1004
+ console.log(JSON.stringify({ error: 'NFT not found', nft_id: nftId }, null, 2));
1005
+ } else {
1006
+ console.log(`\n ${C.red}✗ NFT not found: ${nftId}${C.reset}\n`);
1007
+ }
1008
+ return;
1009
+ }
1010
+
1011
+ if (isJson) {
1012
+ console.log(JSON.stringify({
1013
+ nft_id: nft.id,
1014
+ creator: nft.creator,
1015
+ metadata_url: nft.metadata,
1016
+ royalties_bps: nft.royalties,
1017
+ supply: nft.supply,
1018
+ max_supply: nft.maxSupply,
1019
+ update_authority: nft.updateAuthority,
1020
+ created_at: nft.createdAt,
1021
+ rpc,
1022
+ cli_version: CLI_VERSION,
1023
+ timestamp: new Date().toISOString(),
1024
+ }, null, 2));
1025
+ return;
1026
+ }
1027
+
1028
+ console.log(`\n${C.bright}${C.cyan}── NFT Details ────────────────────────────────────────────${C.reset}\n`);
1029
+ console.log(` ${C.green}★${C.reset} NFT ID: ${C.bright}${C.cyan}${nft.id}${C.reset}`);
1030
+ console.log(` ${C.green}★${C.reset} Creator: ${shortAddress(nft.creator)}`);
1031
+ console.log(` ${C.green}★${C.reset} Metadata: ${C.blue}${nft.metadata}${C.reset}`);
1032
+ console.log(` ${C.green}★${C.reset} Royalties: ${(nft.royalties / 100).toFixed(2)}%`);
1033
+ console.log(` ${C.green}★${C.reset} Supply: ${nft.supply}${nft.maxSupply ? ' / ' + nft.maxSupply : ''}`);
1034
+ if (nft.updateAuthority) {
1035
+ console.log(` ${C.green}★${C.reset} Update Authority: ${shortAddress(nft.updateAuthority)}`);
1036
+ }
1037
+ if (nft.createdAt) {
1038
+ console.log(` ${C.dim} Created: ${formatTimestamp(nft.createdAt)}${C.reset}`);
1039
+ }
1040
+ console.log();
1041
+ console.log(` ${C.dim}SDK: getNFT()${C.reset}\n`);
1042
+ }
1043
+
1044
+ // ============================================================================
1045
+ // CLI Args Parser
1046
+ // ============================================================================
1047
+
1048
+ function parseArgs() {
1049
+ const rawArgs = process.argv.slice(3);
1050
+ const subcmd = rawArgs[0] || 'list';
1051
+ const allArgs = rawArgs.slice(1);
1052
+
1053
+ const rpcIndex = allArgs.findIndex(a => a === '--rpc' || a === '-r');
1054
+ const rpc = rpcIndex !== -1 && allArgs[rpcIndex + 1] ? allArgs[rpcIndex + 1] : getDefaultRpc();
1055
+
1056
+ const parsed = {
1057
+ subcmd,
1058
+ rpc,
1059
+ json: allArgs.includes('--json') || allArgs.includes('-j'),
1060
+ dryRun: allArgs.includes('--dry-run'),
1061
+ address: null,
1062
+ nft: null,
1063
+ metadata: null,
1064
+ to: null,
1065
+ amount: null,
1066
+ royalties: null,
1067
+ };
1068
+
1069
+ const addrIdx = allArgs.findIndex(a => a === '--address' || a === '-a');
1070
+ if (addrIdx !== -1 && allArgs[addrIdx + 1]) parsed.address = allArgs[addrIdx + 1];
1071
+
1072
+ const nftIdx = allArgs.findIndex(a => a === '--nft' || a === '-n');
1073
+ if (nftIdx !== -1 && allArgs[nftIdx + 1]) parsed.nft = allArgs[nftIdx + 1];
1074
+
1075
+ const metaIdx = allArgs.findIndex(a => a === '--metadata' || a === '-m');
1076
+ if (metaIdx !== -1 && allArgs[metaIdx + 1]) parsed.metadata = allArgs[metaIdx + 1];
1077
+
1078
+ const toIdx = allArgs.findIndex(a => a === '--to' || a === '-t');
1079
+ if (toIdx !== -1 && allArgs[toIdx + 1]) parsed.to = allArgs[toIdx + 1];
1080
+
1081
+ const amtIdx = allArgs.findIndex(a => a === '--amount' || a === '-x');
1082
+ if (amtIdx !== -1 && allArgs[amtIdx + 1]) parsed.amount = allArgs[amtIdx + 1];
1083
+
1084
+ const royIdx = allArgs.findIndex(a => a === '--royalties' || a === '-r');
1085
+ if (royIdx !== -1 && allArgs[royIdx + 1]) parsed.royalties = parseInt(allArgs[royIdx + 1], 10);
1086
+
1087
+ return parsed;
1088
+ }
1089
+
1090
+ function showHelp() {
1091
+ console.log(`
1092
+ ${C.bright}${C.cyan}aether-cli nft${C.reset} — NFT Management
1093
+
1094
+ ${C.bright}USAGE${C.reset}
1095
+ aether nft create --metadata <url> [--royalties <bps>] [--json]
1096
+ aether nft mint --nft <id> --amount <n> [--to <addr>] [--json]
1097
+ aether nft transfer --nft <id> --to <addr> [--amount <n>] [--json]
1098
+ aether nft update --nft <id> --metadata <url> [--json]
1099
+ aether nft list [--address <addr>] [--json]
1100
+ aether nft info --nft <id> [--json]
1101
+
1102
+ ${C.bright}COMMANDS${C.reset}
1103
+ create Create a new NFT
1104
+ mint Mint additional supply of an existing NFT
1105
+ transfer Transfer NFT to another address
1106
+ update Update NFT metadata URL
1107
+ list Show all NFTs held by a wallet
1108
+ info Show detailed info about a specific NFT
1109
+
1110
+ ${C.bright}OPTIONS${C.reset}
1111
+ --metadata <url> Metadata URL (IPFS/Arweave/HTTPS)
1112
+ --royalties <bps> Royalties in basis points (0-10000)
1113
+ --nft <id> NFT identifier
1114
+ --amount <n> Amount to mint/transfer
1115
+ --to <addr> Recipient address
1116
+ --address <addr> Wallet address (default: configured default)
1117
+ --rpc <url> RPC endpoint
1118
+ --json Output JSON
1119
+ --dry-run Preview without submitting
1120
+
1121
+ ${C.bright}EXAMPLES${C.reset}
1122
+ aether nft create --metadata ipfs://Qm... --royalties 500
1123
+ aether nft mint --nft NFTabc... --amount 10 --to ATH...
1124
+ aether nft transfer --nft NFTabc... --to ATH... --amount 1
1125
+ aether nft update --nft NFTabc... --metadata ipfs://QmNew...
1126
+ aether nft list --address ATH...
1127
+ aether nft info --nft NFTabc...
1128
+
1129
+ ${C.green}✓ Fully wired to @jellylegsai/aether-sdk${C.reset}
1130
+ `);
1131
+ }
1132
+
1133
+ // ============================================================================
1134
+ // Main Entry Point
1135
+ // ============================================================================
1136
+
1137
+ async function nftCommand() {
1138
+ const args = parseArgs();
1139
+
1140
+ if (args.subcmd === '--help' || args.subcmd === '-h' || args.subcmd === 'help') {
1141
+ showHelp();
1142
+ return;
1143
+ }
1144
+
1145
+ switch (args.subcmd) {
1146
+ case 'create':
1147
+ await nftCreate(args);
1148
+ break;
1149
+ case 'mint':
1150
+ await nftMint(args);
1151
+ break;
1152
+ case 'transfer':
1153
+ await nftTransfer(args);
1154
+ break;
1155
+ case 'update':
1156
+ await nftUpdate(args);
1157
+ break;
1158
+ case 'list':
1159
+ await nftList(args);
1160
+ break;
1161
+ case 'info':
1162
+ await nftInfo(args);
1163
+ break;
1164
+ default:
1165
+ console.log(`\n ${C.red}✗ Unknown subcommand: ${args.subcmd}${C.reset}`);
1166
+ showHelp();
1167
+ process.exit(1);
1168
+ }
1169
+ }
1170
+
1171
+ // Export for module use
1172
+ module.exports = { nftCommand };
1173
+
1174
+ // Run if called directly
1175
+ if (require.main === module) {
1176
+ nftCommand().catch(err => {
1177
+ console.error(`\n${C.red}✗ NFT command failed:${C.reset}`, err.message, '\n');
1178
+ process.exit(1);
1179
+ });
1180
+ }