hippius 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2773 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command Line Interface handlers for Hippius SDK.
4
+
5
+ This module provides handler functions for CLI commands, including
6
+ file operations, marketplace interactions, configuration management, etc.
7
+ """
8
+ import asyncio
9
+ import base64
10
+ import getpass
11
+ import json
12
+ import os
13
+ import tempfile
14
+ import time
15
+ from typing import Any, List, Optional
16
+
17
+ from hippius_sdk import (
18
+ HippiusClient,
19
+ decrypt_seed_phrase,
20
+ delete_account,
21
+ encrypt_seed_phrase,
22
+ format_size,
23
+ get_account_address,
24
+ get_active_account,
25
+ get_all_config,
26
+ get_config_value,
27
+ list_accounts,
28
+ load_config,
29
+ reset_config,
30
+ save_config,
31
+ set_active_account,
32
+ set_config_value,
33
+ set_seed_phrase,
34
+ )
35
+ from hippius_sdk.cli_parser import get_default_address
36
+ from hippius_sdk.cli_rich import (
37
+ console,
38
+ create_progress,
39
+ error,
40
+ info,
41
+ log,
42
+ print_panel,
43
+ print_table,
44
+ success,
45
+ warning,
46
+ )
47
+
48
+ try:
49
+ import nacl.secret
50
+ import nacl.utils
51
+ except ImportError:
52
+ ENCRYPTION_AVAILABLE = False
53
+ else:
54
+ ENCRYPTION_AVAILABLE = True
55
+
56
+
57
+ # Client creation helper function
58
+ def create_client(args: Any) -> HippiusClient:
59
+ """Create a HippiusClient instance from command line arguments."""
60
+ # Process encryption flags
61
+ encrypt = None
62
+ if hasattr(args, "encrypt") and args.encrypt:
63
+ encrypt = True
64
+ elif hasattr(args, "no_encrypt") and args.no_encrypt:
65
+ encrypt = False
66
+
67
+ # Process encryption key if provided
68
+ encryption_key = None
69
+ if hasattr(args, "encryption_key") and args.encryption_key:
70
+ try:
71
+ encryption_key = base64.b64decode(args.encryption_key)
72
+ if hasattr(args, "verbose") and args.verbose:
73
+ print("Using provided encryption key")
74
+ except Exception as e:
75
+ print(f"Warning: Could not decode encryption key: {e}")
76
+ print("Using default encryption key from configuration if available")
77
+
78
+ # Get API URL based on local_ipfs flag if the flag exists
79
+ api_url = None
80
+ if hasattr(args, "local_ipfs") and args.local_ipfs:
81
+ api_url = "http://localhost:5001"
82
+ elif hasattr(args, "api_url"):
83
+ api_url = args.api_url
84
+ elif hasattr(args, "ipfs_api"):
85
+ api_url = args.ipfs_api
86
+
87
+ # Get gateway URL
88
+ gateway = None
89
+ if hasattr(args, "gateway"):
90
+ gateway = args.gateway
91
+ elif hasattr(args, "ipfs_gateway"):
92
+ gateway = args.ipfs_gateway
93
+
94
+ # Get substrate URL
95
+ substrate_url = args.substrate_url if hasattr(args, "substrate_url") else None
96
+
97
+ # Skip password if we're doing erasure-code with --no-publish
98
+ # This avoids prompting for password when we don't need to interact with the blockchain
99
+ password = None
100
+ if (
101
+ hasattr(args, "command")
102
+ and args.command == "erasure-code"
103
+ and hasattr(args, "no_publish")
104
+ and args.no_publish
105
+ ):
106
+ # Don't need a password in this case
107
+ password = None
108
+ else:
109
+ # Use password from args if provided
110
+ password = args.password if hasattr(args, "password") else None
111
+
112
+ # Initialize client with provided parameters
113
+ client = HippiusClient(
114
+ ipfs_gateway=gateway,
115
+ ipfs_api_url=api_url,
116
+ substrate_url=substrate_url,
117
+ substrate_seed_phrase=(
118
+ args.seed_phrase if hasattr(args, "seed_phrase") else None
119
+ ),
120
+ seed_phrase_password=password,
121
+ account_name=args.account if hasattr(args, "account") else None,
122
+ encrypt_by_default=encrypt,
123
+ encryption_key=encryption_key,
124
+ )
125
+
126
+ return client
127
+
128
+
129
+ #
130
+ # IPFS File Operation Handlers
131
+ #
132
+
133
+
134
+ async def handle_download(
135
+ client: HippiusClient, cid: str, output_path: str, decrypt: Optional[bool] = None
136
+ ) -> int:
137
+ """Handle the download command"""
138
+ info(f"Downloading [bold cyan]{cid}[/bold cyan] to [bold]{output_path}[/bold]...")
139
+
140
+ # Use the enhanced download method which returns formatted information
141
+ result = await client.download_file(cid, output_path, decrypt=decrypt)
142
+
143
+ # Create a success panel with download information
144
+ details = [
145
+ f"Download successful in [bold green]{result['elapsed_seconds']}[/bold green] seconds!",
146
+ f"Saved to: [bold]{result['output_path']}[/bold]",
147
+ f"Size: [bold cyan]{result['size_bytes']:,}[/bold cyan] bytes ([bold cyan]{result['size_formatted']}[/bold cyan])",
148
+ ]
149
+
150
+ if result.get("decrypted"):
151
+ details.append("[bold yellow]File was decrypted during download[/bold yellow]")
152
+
153
+ print_panel("\n".join(details), title="Download Complete")
154
+
155
+ return 0
156
+
157
+
158
+ async def handle_exists(client: HippiusClient, cid: str) -> int:
159
+ """Handle the exists command"""
160
+ info(f"Checking if CID [bold cyan]{cid}[/bold cyan] exists on IPFS...")
161
+ result = await client.exists(cid)
162
+
163
+ # Use the formatted CID from the result
164
+ formatted_cid = result["formatted_cid"]
165
+ exists = result["exists"]
166
+
167
+ if exists:
168
+ success(f"CID [bold cyan]{formatted_cid}[/bold cyan] exists on IPFS")
169
+
170
+ if result.get("gateway_url"):
171
+ log(f"Gateway URL: [link]{result['gateway_url']}[/link]")
172
+
173
+ # Display download command in a panel
174
+ command = f"[bold green underline]hippius download {formatted_cid} <output_path>[/bold green underline]"
175
+ print_panel(command, title="Download Command")
176
+ else:
177
+ error(f"CID [bold cyan]{formatted_cid}[/bold cyan] does not exist on IPFS")
178
+
179
+ return 0
180
+
181
+
182
+ async def handle_cat(
183
+ client: HippiusClient, cid: str, max_size: int, decrypt: Optional[bool] = None
184
+ ) -> int:
185
+ """Handle the cat command"""
186
+ info(f"Displaying content of CID [bold cyan]{cid}[/bold cyan]...")
187
+ with tempfile.NamedTemporaryFile() as temp:
188
+ temp_path = temp.name
189
+ download_result = await client.download_file(cid, temp_path, decrypt=decrypt)
190
+ file_size = os.path.getsize(temp_path)
191
+
192
+ # Read content based on max size
193
+ with open(temp_path, "rb") as f:
194
+ content = f.read(max_size)
195
+
196
+ # Try to display as text, fall back to binary info
197
+ try:
198
+ decoded = content.decode("utf-8")
199
+ log(
200
+ f"\nContent (first [bold]{min(max_size, file_size):,}[/bold] bytes):",
201
+ style="blue",
202
+ )
203
+ console.print("--------------------------------------------", style="dim")
204
+ console.print(decoded)
205
+ console.print("--------------------------------------------", style="dim")
206
+ except UnicodeDecodeError:
207
+ log("\nBinary content (showing size information only):", style="yellow")
208
+ log(
209
+ f"Total size: [bold cyan]{file_size:,}[/bold cyan] bytes ([bold cyan]{download_result['size_formatted']}[/bold cyan])"
210
+ )
211
+ log("Content type appears to be binary", style="yellow")
212
+
213
+ notes = []
214
+ if file_size > max_size:
215
+ notes.append(
216
+ f"Content truncated. Total file size: [bold]{file_size:,}[/bold] bytes"
217
+ )
218
+ notes.append(
219
+ f"Use '[bold]hippius download {cid} <output_path>[/bold]' to download the entire file"
220
+ )
221
+
222
+ if download_result.get("decrypted"):
223
+ notes.append(
224
+ "[bold yellow]File was decrypted during download[/bold yellow]"
225
+ )
226
+
227
+ if notes:
228
+ print_panel("\n".join(notes), title="Notes")
229
+
230
+
231
+ async def handle_store(
232
+ client: HippiusClient,
233
+ file_path: str,
234
+ miner_ids: Optional[List[str]] = None,
235
+ encrypt: Optional[bool] = None,
236
+ ) -> int:
237
+ """Handle the store command (upload file to IPFS and store on Substrate)"""
238
+ if not os.path.exists(file_path):
239
+ error(f"File [bold]{file_path}[/bold] does not exist")
240
+ return 1
241
+
242
+ if not os.path.isfile(file_path):
243
+ error(f"[bold]{file_path}[/bold] is not a file")
244
+ return 1
245
+
246
+ # Get file size for display
247
+ file_size = os.path.getsize(file_path)
248
+ file_name = os.path.basename(file_path)
249
+
250
+ # Format size for display
251
+ if file_size >= 1024 * 1024:
252
+ size_formatted = f"{file_size / (1024 * 1024):.2f} MB"
253
+ else:
254
+ size_formatted = f"{file_size / 1024:.2f} KB"
255
+
256
+ # Upload information panel
257
+ upload_info = [
258
+ f"File: [bold]{file_name}[/bold]",
259
+ f"Size: [bold cyan]{size_formatted}[/bold cyan] ({file_size:,} bytes)",
260
+ ]
261
+
262
+ # Add encryption status
263
+ if encrypt is True:
264
+ upload_info.append("[bold green]Encryption: Enabled[/bold green]")
265
+ elif encrypt is False:
266
+ upload_info.append("[bold red]Encryption: Disabled[/bold red]")
267
+ else:
268
+ upload_info.append(
269
+ "[bold yellow]Encryption: Using default setting[/bold yellow]"
270
+ )
271
+
272
+ # Parse miner IDs if provided
273
+ miner_id_list = None
274
+ if miner_ids:
275
+ miner_id_list = [m.strip() for m in miner_ids if m.strip()]
276
+ upload_info.append(
277
+ f"Targeting [bold]{len(miner_id_list)}[/bold] miners for storage"
278
+ )
279
+
280
+ # Display upload information panel
281
+ print_panel("\n".join(upload_info), title="Upload Operation")
282
+
283
+ # Create progress for the upload process
284
+ with create_progress() as progress:
285
+ # Add a task for the upload
286
+ task = progress.add_task("[cyan]Uploading...", total=100)
287
+
288
+ # We can't track actual progress from the client.store_file method yet,
289
+ # so we'll update the progress periodically
290
+ start_time = time.time()
291
+
292
+ # Create a task to update the progress while waiting for the upload
293
+ async def update_progress():
294
+ while not progress.finished:
295
+ # Since we don't have actual progress data, we'll use time as a proxy
296
+ # The progress will move faster at first, then slow down
297
+ elapsed = time.time() - start_time
298
+ # Use a logarithmic function to simulate progress
299
+ # This is just an estimation and not actual progress
300
+ pct = min(95, 100 * (1 - 1 / (1 + elapsed / 10)))
301
+ progress.update(task, completed=pct)
302
+ await asyncio.sleep(0.1)
303
+
304
+ # Start the progress updater task
305
+ updater = asyncio.create_task(update_progress())
306
+
307
+ try:
308
+ # Use the store_file method
309
+ result = await client.upload_file(
310
+ file_path=file_path,
311
+ encrypt=encrypt,
312
+ # miner_ids=miner_id_list
313
+ )
314
+
315
+ progress.update(task, completed=100)
316
+ updater.cancel()
317
+
318
+ elapsed_time = time.time() - start_time
319
+
320
+ # Success panel with results
321
+ success_info = [
322
+ f"Upload completed in [bold green]{elapsed_time:.2f}[/bold green] seconds!",
323
+ f"IPFS CID: [bold cyan]{result['cid']}[/bold cyan]",
324
+ ]
325
+
326
+ if result.get("gateway_url"):
327
+ success_info.append(
328
+ f"Gateway URL: [link]{result['gateway_url']}[/link]"
329
+ )
330
+
331
+ if result.get("encrypted"):
332
+ success_info.append(
333
+ "[bold yellow]File was encrypted during upload[/bold yellow]"
334
+ )
335
+
336
+ print_panel("\n".join(success_info), title="Upload Successful")
337
+
338
+ # If we stored in the marketplace
339
+ if "transaction_hash" in result:
340
+ log(
341
+ f"\nStored in marketplace. Transaction hash: [bold]{result['transaction_hash']}[/bold]"
342
+ )
343
+
344
+ # Display download command in a panel
345
+ command = f"[bold green underline]hippius download {result['cid']} <output_path>[/bold green underline]"
346
+ print_panel(command, title="Download Command")
347
+
348
+ return 0
349
+
350
+ except Exception as e:
351
+ # Cancel the updater task in case of error
352
+ updater.cancel()
353
+ error(f"Upload failed: {str(e)}")
354
+
355
+
356
+ async def handle_store_dir(
357
+ client: HippiusClient,
358
+ dir_path: str,
359
+ miner_ids: Optional[List[str]] = None,
360
+ encrypt: Optional[bool] = None,
361
+ ) -> int:
362
+ """Handle the store directory command"""
363
+ if not os.path.exists(dir_path):
364
+ error(f"Directory [bold]{dir_path}[/bold] does not exist")
365
+ return 1
366
+
367
+ if not os.path.isdir(dir_path):
368
+ error(f"[bold]{dir_path}[/bold] is not a directory")
369
+ return 1
370
+
371
+ # Upload information panel
372
+ upload_info = [f"Directory: [bold]{dir_path}[/bold]"]
373
+
374
+ # Add encryption status
375
+ if encrypt is True:
376
+ upload_info.append("[bold green]Encryption: Enabled[/bold green]")
377
+ elif encrypt is False:
378
+ upload_info.append("[bold red]Encryption: Disabled[/bold red]")
379
+ else:
380
+ upload_info.append(
381
+ "[bold yellow]Encryption: Using default setting[/bold yellow]"
382
+ )
383
+
384
+ # Parse miner IDs if provided
385
+ miner_id_list = None
386
+ if miner_ids:
387
+ miner_id_list = [m.strip() for m in miner_ids if m.strip()]
388
+ upload_info.append(
389
+ f"Targeting [bold]{len(miner_id_list)}[/bold] miners for storage"
390
+ )
391
+
392
+ # Display upload information panel
393
+ print_panel("\n".join(upload_info), title="Directory Upload Operation")
394
+
395
+ # Create progress for the directory upload process
396
+ with create_progress() as progress:
397
+ # Add a task for the directory upload
398
+ task = progress.add_task("[cyan]Uploading directory...", total=100)
399
+
400
+ # We can't track actual progress from the client.store_directory method yet,
401
+ # so we'll update the progress periodically
402
+ start_time = time.time()
403
+
404
+ # Create a task to update the progress while waiting for the upload
405
+ async def update_progress():
406
+ while not progress.finished:
407
+ # Since we don't have actual progress data, we'll use time as a proxy
408
+ # The progress will move faster at first, then slow down
409
+ elapsed = time.time() - start_time
410
+ # Use a logarithmic function to simulate progress
411
+ # This is just an estimation and not actual progress
412
+ pct = min(95, 100 * (1 - 1 / (1 + elapsed / 10)))
413
+ progress.update(task, completed=pct)
414
+ await asyncio.sleep(0.1)
415
+
416
+ # Start the progress updater task
417
+ updater = asyncio.create_task(update_progress())
418
+
419
+ try:
420
+ # Use the store_directory method
421
+ result = await client.ipfs_client.upload_directory(
422
+ dir_path=dir_path,
423
+ encrypt=encrypt,
424
+ )
425
+
426
+ # Complete the progress
427
+ progress.update(task, completed=100)
428
+ # Cancel the updater task
429
+ updater.cancel()
430
+
431
+ elapsed_time = time.time() - start_time
432
+
433
+ # Success panel with results
434
+ success_info = [
435
+ f"Upload completed in [bold green]{elapsed_time:.2f}[/bold green] seconds!",
436
+ f"Directory CID: [bold cyan]{result['cid']}[/bold cyan]",
437
+ ]
438
+
439
+ if result.get("gateway_url"):
440
+ success_info.append(
441
+ f"Gateway URL: [link]{result['gateway_url']}[/link]"
442
+ )
443
+
444
+ print_panel("\n".join(success_info), title="Directory Upload Successful")
445
+
446
+ # Display uploaded files in a table
447
+ if "files" in result:
448
+ table_data = []
449
+ for i, file_info in enumerate(result["files"], 1):
450
+ table_data.append(
451
+ {
452
+ "Index": str(i),
453
+ "Filename": file_info["name"],
454
+ "CID": file_info["cid"],
455
+ }
456
+ )
457
+
458
+ print_table(
459
+ f"Uploaded {len(result['files'])} Files",
460
+ table_data,
461
+ ["Index", "Filename", "CID"],
462
+ )
463
+
464
+ # If we stored in the marketplace
465
+ if "transaction_hash" in result:
466
+ log(
467
+ f"\nStored in marketplace. Transaction hash: [bold]{result['transaction_hash']}[/bold]"
468
+ )
469
+
470
+ return 0
471
+
472
+ except Exception as e:
473
+ # Cancel the updater task in case of error
474
+ updater.cancel()
475
+ error(f"Directory upload failed: {str(e)}")
476
+ return 1
477
+
478
+
479
+ async def handle_credits(
480
+ client: HippiusClient, account_address: Optional[str] = None
481
+ ) -> int:
482
+ """Handle the credits command"""
483
+ info("Checking free credits for the account...")
484
+ try:
485
+ # Get the account address we're querying
486
+ if account_address is None:
487
+ # If no address provided, first try to get from keypair (if available)
488
+ if (
489
+ hasattr(client.substrate_client, "_keypair")
490
+ and client.substrate_client._keypair is not None
491
+ ):
492
+ account_address = client.substrate_client._keypair.ss58_address
493
+ else:
494
+ # Try to get the default address
495
+ default_address = get_default_address()
496
+ if default_address:
497
+ account_address = default_address
498
+ else:
499
+ has_default = get_default_address() is not None
500
+
501
+ error("No account address provided, and client has no keypair.")
502
+
503
+ if has_default:
504
+ warning(
505
+ "Please provide an account address with '--account_address' or the default address may be invalid."
506
+ )
507
+ else:
508
+ warning(
509
+ "Please provide an account address with '--account_address' or set a default with:"
510
+ )
511
+ log(
512
+ " [bold green underline]hippius address set-default <your_account_address>[/bold green underline]"
513
+ )
514
+
515
+ return 1
516
+
517
+ credits = await client.substrate_client.get_free_credits(account_address)
518
+
519
+ # Create a panel with credit information
520
+ credit_info = [
521
+ f"Free credits: [bold green]{credits:.6f}[/bold green]",
522
+ f"Raw value: [dim]{int(credits * 1_000_000_000_000_000_000):,}[/dim]",
523
+ f"Account address: [bold cyan]{account_address}[/bold cyan]",
524
+ ]
525
+
526
+ print_panel("\n".join(credit_info), title="Account Credits")
527
+
528
+ except Exception as e:
529
+ error(f"Error checking credits: {e}")
530
+ return 1
531
+
532
+ return 0
533
+
534
+
535
+ async def handle_files(
536
+ client: HippiusClient,
537
+ account_address: Optional[str] = None,
538
+ show_all_miners: bool = False,
539
+ file_cid: str = None,
540
+ ) -> int:
541
+ """Handle the files command"""
542
+ # Get the account address we're querying
543
+ if account_address is None:
544
+ # If no address provided, first try to get from keypair (if available)
545
+ if (
546
+ hasattr(client.substrate_client, "_keypair")
547
+ and client.substrate_client._keypair is not None
548
+ ):
549
+ account_address = client.substrate_client._keypair.ss58_address
550
+ else:
551
+ # Try to get the default address
552
+ default_address = get_default_address()
553
+ if default_address:
554
+ account_address = default_address
555
+ else:
556
+ has_default = get_default_address() is not None
557
+
558
+ error("No account address provided, and client has no keypair.")
559
+
560
+ if has_default:
561
+ warning(
562
+ "Please provide an account address with '--account_address' or the default address may be invalid."
563
+ )
564
+ else:
565
+ info(
566
+ "Please provide an account address with '--account_address' or set a default with:"
567
+ )
568
+ log(
569
+ " [bold green underline]hippius address set-default <your_account_address>[/bold green underline]"
570
+ )
571
+ return 1
572
+
573
+ # Get files from the marketplace
574
+ info(f"Getting files for account: [bold]{account_address}[/bold]")
575
+ files = await client.substrate_client.get_user_files(account_address)
576
+
577
+ if not files:
578
+ info("No files found for this account")
579
+ return 0
580
+
581
+ # Display summary
582
+ success(f"Found [bold]{len(files)}[/bold] files")
583
+
584
+ # Display file information
585
+ for i, file in enumerate(files, 1):
586
+ # Extract file details
587
+ cid = file["cid"]
588
+
589
+ if file_cid and file_cid != cid:
590
+ continue
591
+
592
+ size_formatted = file["size_formatted"]
593
+ size_raw = file["file_size"]
594
+ file_name = file["file_name"]
595
+ file_hash = file["file_hash"]
596
+ selected_validator = file["selected_validator"]
597
+
598
+ # Create a panel for each file
599
+ file_info = [
600
+ f"CID: [bold cyan]{cid}[/bold cyan]",
601
+ f"Size: [bold]{size_raw}[/bold] bytes ([bold cyan]{size_formatted}[/bold cyan])",
602
+ f"File name: [bold]{file_name}[/bold]",
603
+ f"File hash: {file_hash}",
604
+ f"Selected validator: {selected_validator}",
605
+ ]
606
+
607
+ # Show miners if requested
608
+ if show_all_miners and "miner_ids" in file:
609
+ miners = file.get("miner_ids", [])
610
+ if miners:
611
+ file_info.append(f"Stored on [bold]{len(miners)}[/bold] miners:")
612
+ miners_list = []
613
+ for j, miner in enumerate(miners, 1):
614
+ miners_list.append(f" {j}. {miner}")
615
+ file_info.append("\n".join(miners_list))
616
+ else:
617
+ file_info.append("No miners assigned yet")
618
+
619
+ print_panel("\n".join(file_info), title=f"File #{i}: {file_name}")
620
+
621
+
622
+ async def handle_pinning_status(
623
+ client: HippiusClient,
624
+ account_address: Optional[str] = None,
625
+ verbose: bool = False,
626
+ show_contents: bool = True,
627
+ ) -> int:
628
+ """Handle the pinning-status command"""
629
+ try:
630
+ info("Checking pinning status of files...")
631
+
632
+ # Use the get_pinning_status method from the substrate client
633
+ pins = client.substrate_client.get_pinning_status(account_address)
634
+
635
+ if not pins:
636
+ log("No active pins found")
637
+ return 0
638
+
639
+ log(f"\nFound {len(pins)} pinning requests:")
640
+
641
+ for i, pin in enumerate(pins, 1):
642
+ try:
643
+ # Get the CID from the pin data
644
+ cid = pin.get("cid")
645
+
646
+ # Display pin information
647
+ log(f"\n{i}. CID: [bold]{cid}[/bold]")
648
+ log(f" File Name: {pin['file_name']}")
649
+ status = "Assigned" if pin["is_assigned"] else "Pending"
650
+ log(f" Status: {status}")
651
+ log(f" Created At Block: {pin['created_at']}")
652
+ log(f" Last Charged At Block: {pin['last_charged_at']}")
653
+ log(f" Owner: {pin['owner']}")
654
+ log(f" Total Replicas: {pin['total_replicas']}")
655
+ log(f" Selected Validator: {pin['selected_validator']}")
656
+ miners = pin["miner_ids"]
657
+ if miners:
658
+ log(f" Miners: {', '.join(miners[:3])}")
659
+ if len(miners) > 3:
660
+ log(f" ... and {len(miners) - 3} more")
661
+ else:
662
+ log(" Miners: None assigned yet")
663
+
664
+ # Show content info if requested
665
+ if show_contents:
666
+ try:
667
+ content_info = await client.ipfs_client.get_content_info(cid)
668
+
669
+ if content_info:
670
+ if "size_formatted" in content_info:
671
+ log(f" Size: {content_info['size_formatted']}")
672
+ if "gateway_url" in content_info:
673
+ log(f" Gateway URL: {content_info['gateway_url']}")
674
+ except Exception as e:
675
+ if verbose:
676
+ warning(f" Error getting content info: {e}")
677
+ except Exception as e:
678
+ warning(f"Error processing pin {i}: {e}")
679
+ if verbose:
680
+ log(f"Raw pin data: {pin}")
681
+
682
+ return 0
683
+
684
+ except Exception as e:
685
+ error(f"Error checking pinning status: {str(e)}")
686
+ return 1
687
+
688
+
689
+ async def handle_ec_files(
690
+ client: HippiusClient,
691
+ account_address: Optional[str] = None,
692
+ show_all_miners: bool = False,
693
+ show_chunks: bool = False,
694
+ filter_metadata_cid: str = None,
695
+ ) -> int:
696
+ """Handle the ec-files command"""
697
+ if account_address is None:
698
+ # If no address provided, first try to get from keypair (if available)
699
+ if (
700
+ hasattr(client.substrate_client, "_keypair")
701
+ and client.substrate_client._keypair is not None
702
+ ):
703
+ account_address = client.substrate_client._keypair.ss58_address
704
+ else:
705
+ # Try to get the default address
706
+ default_address = get_default_address()
707
+ if default_address:
708
+ account_address = default_address
709
+ else:
710
+ has_default = get_default_address() is not None
711
+
712
+ error("No account address provided, and client has no keypair.")
713
+
714
+ if has_default:
715
+ warning(
716
+ "Please provide an account address with '--account_address' or the default address may be invalid."
717
+ )
718
+ else:
719
+ info(
720
+ "Please provide an account address with '--account_address' or set a default with:"
721
+ )
722
+ log(
723
+ " [bold green underline]hippius address set-default <your_account_address>[/bold green underline]"
724
+ )
725
+ return 1
726
+
727
+ info(f"Getting erasure-coded files for account: [bold]{account_address}[/bold]")
728
+
729
+ # Get all files from the marketplace
730
+ files = await client.substrate_client.get_user_files(account_address)
731
+
732
+ # Separate metadata files and chunks
733
+ ec_metadata_files = []
734
+ chunk_files = []
735
+
736
+ for file in files:
737
+ if file["file_name"].endswith(".ec_metadata"):
738
+ ec_metadata_files.append(file)
739
+ elif file["file_name"].endswith(".ec"):
740
+ chunk_files.append(file)
741
+
742
+ if not ec_metadata_files:
743
+ info("No erasure-coded files found for this account")
744
+ return 0
745
+
746
+ # Display summary
747
+ success(f"Found [bold]{len(ec_metadata_files)}[/bold] erasure-coded files")
748
+ if chunk_files:
749
+ log(f"Found [bold]{len(chunk_files)}[/bold] chunk files")
750
+
751
+ # Store metadata CIDs for reconstruction command at the end
752
+ metadata_cids = []
753
+
754
+ # Process each metadata file
755
+ for i, metadata_file in enumerate(ec_metadata_files, 1):
756
+ metadata_cid = metadata_file["cid"]
757
+
758
+ if filter_metadata_cid and metadata_cid != filter_metadata_cid:
759
+ continue
760
+
761
+ metadata_file_name = metadata_file["file_name"]
762
+ metadata_size = metadata_file["file_size"]
763
+ metadata_size_formatted = metadata_file["size_formatted"]
764
+
765
+ # Store metadata CID for reconstruction command
766
+ metadata_cids.append(metadata_cid)
767
+
768
+ # Basic file info panel (always show this)
769
+ file_info = [
770
+ f"Metadata filename: [bold]{metadata_file_name}[/bold]",
771
+ f"Metadata CID: [bold cyan]{metadata_cid}[/bold cyan]",
772
+ f"Metadata size: [bold]{metadata_size}[/bold] bytes ([bold cyan]{metadata_size_formatted}[/bold cyan])",
773
+ f"Selected validator: {metadata_file['selected_validator']}",
774
+ ]
775
+
776
+ # Add miners info if available and requested - with consistent formatting
777
+ if show_all_miners and metadata_file["miner_ids"]:
778
+ miners = metadata_file["miner_ids"]
779
+ file_info.append(f"Metadata stored on [bold]{len(miners)}[/bold] miners:")
780
+ miners_list = []
781
+ for j, miner in enumerate(miners, 1):
782
+ miners_list.append(f" {j}. {miner}")
783
+ file_info.append("\n".join(miners_list))
784
+
785
+ # If show_chunks is enabled, download the metadata file and get chunk information
786
+ if show_chunks:
787
+ with tempfile.NamedTemporaryFile() as temp:
788
+ temp_path = temp.name
789
+
790
+ # Download the metadata file without logging
791
+ await client.ipfs_client.download_file(metadata_cid, temp_path)
792
+
793
+ # Open and parse the metadata file
794
+ with open(temp_path, "r") as f:
795
+ metadata_content = json.load(f)
796
+
797
+ # Extract the original file information
798
+ original_file = metadata_content["original_file"]
799
+ original_file_name = original_file["name"]
800
+ original_file_size = original_file["size"]
801
+
802
+ # Extract the erasure coding parameters
803
+ erasure_coding = metadata_content["erasure_coding"]
804
+ file_id = erasure_coding["file_id"]
805
+ k = erasure_coding["k"]
806
+ m = erasure_coding["m"]
807
+ chunk_size = erasure_coding["chunk_size"]
808
+ encrypted = erasure_coding["encrypted"]
809
+
810
+ # Extract the chunks information
811
+ chunks_info = metadata_content["chunks"]
812
+
813
+ # Update file_info with detailed metadata information
814
+ file_info = [
815
+ f"Original file: [bold]{original_file_name}[/bold]",
816
+ f"Size: [bold]{original_file_size}[/bold] bytes ([bold cyan]{format_size(original_file_size)}[/bold cyan])",
817
+ f"File hash: {original_file['hash']}",
818
+ f"Metadata CID: [bold cyan]{metadata_cid}[/bold cyan]",
819
+ f"File ID: [bold yellow]{file_id}[/bold yellow]",
820
+ f"Erasure coding: k=[bold]{k}[/bold], m=[bold]{m}[/bold] (need {k} of {m} chunks to reconstruct)",
821
+ f"Chunk size: [bold]{format_size(chunk_size)}[/bold]",
822
+ f"Encrypted: [bold]{'Yes' if encrypted else 'No'}[/bold]",
823
+ f"Total chunks from metadata: [bold]{len(chunks_info)}[/bold]",
824
+ ]
825
+
826
+ # Match chunks from the blockchain with the metadata by CID
827
+ matching_chunks = []
828
+ chunk_cids_in_metadata = []
829
+
830
+ # Create a mapping of CIDs from metadata
831
+ for chunk in chunks_info:
832
+ # Extract CID (handle both string and dict formats)
833
+ chunk_cid = chunk["cid"]
834
+ if isinstance(chunk_cid, dict) and "cid" in chunk_cid:
835
+ chunk_cid = chunk_cid["cid"]
836
+ chunk_cids_in_metadata.append(chunk_cid)
837
+
838
+ # Find matching chunks
839
+ for chunk_file in chunk_files:
840
+ if (
841
+ chunk_file["cid"] in chunk_cids_in_metadata
842
+ or file_id in chunk_file["file_name"]
843
+ ):
844
+ matching_chunks.append(chunk_file)
845
+
846
+ # Add information about matched chunks
847
+ if matching_chunks:
848
+ file_info.append(
849
+ f"Found [bold]{len(matching_chunks)}/{len(chunks_info)}[/bold] chunks in blockchain"
850
+ )
851
+
852
+ # Calculate if we have enough chunks for reconstruction
853
+ chunks_needed = k
854
+ if len(matching_chunks) >= chunks_needed:
855
+ file_info.append(
856
+ "[bold green]✓ Enough chunks available for reconstruction[/bold green]"
857
+ )
858
+ else:
859
+ file_info.append(
860
+ "[bold red]✗ Not enough chunks available for reconstruction[/bold red]"
861
+ )
862
+ else:
863
+ file_info.append(
864
+ "[bold yellow]No associated chunks found in blockchain[/bold yellow]"
865
+ )
866
+
867
+ # Display the panel with all file information
868
+ print_panel(
869
+ "\n".join(file_info), title=f"File #{i}: {original_file_name}"
870
+ )
871
+
872
+ # Display the chunks in a table if we found any
873
+ if matching_chunks:
874
+ # Limit the number of chunks displayed
875
+ MAX_DISPLAYED_CHUNKS = 25
876
+ chunk_table = []
877
+
878
+ # Sort chunks by original_chunk_idx and share_idx for better display
879
+ def chunk_sort_key(chunk):
880
+ chunk_name = chunk["file_name"]
881
+ chunk_parts = []
882
+ if "_chunk_" in chunk_name:
883
+ chunk_parts = (
884
+ chunk_name.split("_chunk_")[1].split(".")[0].split("_")
885
+ )
886
+
887
+ original_chunk_idx = (
888
+ int(chunk_parts[0])
889
+ if len(chunk_parts) > 0 and chunk_parts[0].isdigit()
890
+ else 0
891
+ )
892
+ share_idx = (
893
+ int(chunk_parts[1])
894
+ if len(chunk_parts) > 1 and chunk_parts[1].isdigit()
895
+ else 0
896
+ )
897
+ return (original_chunk_idx, share_idx)
898
+
899
+ sorted_chunks = sorted(matching_chunks, key=chunk_sort_key)
900
+ displayed_chunks = sorted_chunks[:MAX_DISPLAYED_CHUNKS]
901
+
902
+ for j, chunk in enumerate(displayed_chunks, 1):
903
+ # Extract information from the chunk filename
904
+ chunk_name = chunk["file_name"]
905
+ chunk_parts = []
906
+
907
+ # Try to extract original_chunk_idx and share_idx
908
+ if "_chunk_" in chunk_name:
909
+ chunk_parts = (
910
+ chunk_name.split("_chunk_")[1].split(".")[0].split("_")
911
+ )
912
+
913
+ original_chunk_idx = (
914
+ chunk_parts[0] if len(chunk_parts) > 0 else "?"
915
+ )
916
+ share_idx = chunk_parts[1] if len(chunk_parts) > 1 else "?"
917
+
918
+ chunk_table.append(
919
+ {
920
+ "Index": str(j),
921
+ "Name": chunk_name,
922
+ "Original": original_chunk_idx,
923
+ "Share": share_idx,
924
+ "Size": chunk["size_formatted"],
925
+ "CID": chunk["cid"][:10] + "..." + chunk["cid"][-6:]
926
+ if len(chunk["cid"]) > 20
927
+ else chunk["cid"],
928
+ }
929
+ )
930
+
931
+ # Print chunk table without title, using dim (grey) styling
932
+ print_table(
933
+ "", # Empty title
934
+ chunk_table,
935
+ ["Index", "Name", "Original", "Share", "Size", "CID"],
936
+ style="dim", # Use dim (grey) styling for chunk tables
937
+ )
938
+
939
+ # If there are more chunks than the display limit, show a compact note in dim text
940
+ if len(matching_chunks) > MAX_DISPLAYED_CHUNKS:
941
+ log(
942
+ f"[dim](Showing {MAX_DISPLAYED_CHUNKS} of {len(matching_chunks)} chunks. Use 'hippius ipfs download {metadata_cid}' to view all.)[/dim]"
943
+ )
944
+
945
+ else:
946
+ # If show_chunks is disabled, just display the basic file information
947
+ print_panel("\n".join(file_info), title=f"File #{i}: {metadata_file_name}")
948
+
949
+ # Show a generic reconstruction command at the end
950
+ if metadata_cids:
951
+ # Include a real example with the first metadata CID
952
+ example_cid = metadata_cids[0] if metadata_cids else "<METADATA_CID>"
953
+ print_panel(
954
+ f"[bold green underline]hippius reconstruct <METADATA_CID> <OUTPUT_FILENAME>[/bold green underline]\n\nExample:\n[bold green underline]hippius reconstruct {example_cid} reconstructed_file.bin[/bold green underline]",
955
+ title="Reconstruction Command",
956
+ )
957
+
958
+ return 0
959
+
960
+
961
+ async def handle_erasure_code(
962
+ client: HippiusClient,
963
+ file_path: str,
964
+ k: int,
965
+ m: int,
966
+ chunk_size: int,
967
+ miner_ids: Optional[List[str]] = None,
968
+ encrypt: Optional[bool] = None,
969
+ publish: bool = True,
970
+ verbose: bool = False,
971
+ ) -> int:
972
+ """Handle the erasure-code command"""
973
+ if not os.path.exists(file_path):
974
+ error(f"File [bold]{file_path}[/bold] does not exist")
975
+ return 1
976
+
977
+ if not os.path.isfile(file_path):
978
+ error(f"[bold]{file_path}[/bold] is not a file")
979
+ return 1
980
+
981
+ # Check if zfec is installed
982
+ try:
983
+ import zfec
984
+ except ImportError:
985
+ error("zfec is required for erasure coding")
986
+ log(
987
+ "Install it with: [bold green underline]pip install zfec[/bold green underline]"
988
+ )
989
+ log(
990
+ "Then update your environment: [bold green underline]poetry add zfec[/bold green underline]"
991
+ )
992
+ return 1
993
+
994
+ # Get file size
995
+ file_size = os.path.getsize(file_path)
996
+ file_name = os.path.basename(file_path)
997
+
998
+ # Convert chunk size from MB to bytes if needed
999
+ if chunk_size < 1024: # Assume it's in MB if small
1000
+ chunk_size = chunk_size * 1024 * 1024
1001
+
1002
+ # Calculate potential chunks
1003
+ potential_chunks = file_size / chunk_size
1004
+ if potential_chunks < k:
1005
+ warning("File is too small for the requested parameters.")
1006
+
1007
+ # Calculate new chunk size to get exactly k chunks
1008
+ new_chunk_size = file_size / k
1009
+
1010
+ new_chunk_size = int(new_chunk_size)
1011
+ new_chunk_size = max(1, new_chunk_size)
1012
+
1013
+ # Create a panel with parameter adjustment information
1014
+ adjustment_info = [
1015
+ f"Original parameters: k=[bold]{k}[/bold], m=[bold]{m}[/bold], chunk size=[bold]{chunk_size / 1024 / 1024:.2f} MB[/bold]",
1016
+ f"Would create only [bold red]{potential_chunks:.2f}[/bold red] chunks, which is less than k=[bold]{k}[/bold]",
1017
+ f"Automatically adjusting chunk size to [bold green]{new_chunk_size / 1024 / 1024:.6f} MB[/bold green] to create at least {k} chunks",
1018
+ ]
1019
+ print_panel("\n".join(adjustment_info), title="Parameter Adjustment")
1020
+
1021
+ chunk_size = new_chunk_size
1022
+
1023
+ # Create parameter information panel
1024
+ param_info = [
1025
+ f"File: [bold]{file_name}[/bold] ([bold cyan]{file_size / 1024 / 1024:.2f} MB[/bold cyan])",
1026
+ f"Parameters: k=[bold]{k}[/bold], m=[bold]{m}[/bold] (need {k} of {m} chunks to reconstruct)",
1027
+ f"Chunk size: [bold cyan]{chunk_size / 1024 / 1024:.6f} MB[/bold cyan]",
1028
+ ]
1029
+
1030
+ # Add encryption status
1031
+ if encrypt:
1032
+ param_info.append("[bold green]Encryption: Enabled[/bold green]")
1033
+ else:
1034
+ param_info.append("[bold yellow]Encryption: Disabled[/bold yellow]")
1035
+
1036
+ # Add publish status
1037
+ if publish:
1038
+ param_info.append(
1039
+ "[bold blue]Publishing: Enabled[/bold blue] (will store on blockchain)"
1040
+ )
1041
+ else:
1042
+ param_info.append(
1043
+ "[bold cyan]Publishing: Disabled[/bold cyan] (local only, no password needed)"
1044
+ )
1045
+
1046
+ # Parse miner IDs if provided
1047
+ miner_id_list = None
1048
+ if miner_ids:
1049
+ miner_id_list = [m.strip() for m in miner_ids if m.strip()]
1050
+ param_info.append(
1051
+ f"Targeting [bold]{len(miner_id_list)}[/bold] miners for storage"
1052
+ )
1053
+
1054
+ # Display parameter information panel
1055
+ print_panel("\n".join(param_info), title="Erasure Coding Operation")
1056
+
1057
+ start_time = time.time()
1058
+
1059
+ # Create progress for the erasure coding operation
1060
+ with create_progress() as progress:
1061
+ # Add tasks for the different stages
1062
+ processing_task = progress.add_task(
1063
+ "[cyan]Processing file...", total=100, visible=False
1064
+ )
1065
+ encoding_task = progress.add_task(
1066
+ "[green]Encoding chunks...", total=100, visible=False
1067
+ )
1068
+ upload_task = progress.add_task(
1069
+ "[blue]Uploading chunks...", total=100, visible=False
1070
+ )
1071
+
1072
+ # Progress callback function to update the appropriate task
1073
+ def update_progress_bar(stage, current, total):
1074
+ pct = min(100, int(current / total * 100))
1075
+
1076
+ if stage == "processing":
1077
+ progress.update(processing_task, completed=pct)
1078
+ if pct >= 100 and not progress.tasks[encoding_task].visible:
1079
+ progress.update(encoding_task, visible=True)
1080
+
1081
+ elif stage == "encoding":
1082
+ progress.update(encoding_task, completed=pct)
1083
+ if pct >= 100 and not progress.tasks[upload_task].visible:
1084
+ progress.update(upload_task, visible=True)
1085
+
1086
+ elif stage == "upload":
1087
+ progress.update(upload_task, completed=pct)
1088
+
1089
+ # As a fallback, create a task to update the general progress if no callbacks are received
1090
+ async def update_general_progress():
1091
+ while not progress.finished:
1092
+ elapsed = time.time() - start_time
1093
+ # If we haven't shown the encoding task yet, update the processing task
1094
+ if not progress.tasks[encoding_task].visible:
1095
+ pct = min(95, 100 * (1 - 1 / (1 + elapsed / 5)))
1096
+ progress.update(processing_task, completed=pct)
1097
+ if pct > 90:
1098
+ progress.update(encoding_task, visible=True)
1099
+ # If we haven't shown the upload task yet, update the encoding task
1100
+ elif not progress.tasks[upload_task].visible and elapsed > 3:
1101
+ pct = min(95, 100 * (1 - 1 / (1 + (elapsed - 3) / 5)))
1102
+ progress.update(encoding_task, completed=pct)
1103
+ if pct > 90:
1104
+ progress.update(upload_task, visible=True)
1105
+
1106
+ await asyncio.sleep(0.1)
1107
+
1108
+ # Start the fallback progress updater task
1109
+ updater = asyncio.create_task(update_general_progress())
1110
+
1111
+ try:
1112
+ # Use the store_erasure_coded_file method directly from HippiusClient
1113
+ result = await client.store_erasure_coded_file(
1114
+ file_path=file_path,
1115
+ k=k,
1116
+ m=m,
1117
+ chunk_size=chunk_size,
1118
+ encrypt=encrypt,
1119
+ miner_ids=miner_id_list,
1120
+ max_retries=3,
1121
+ verbose=verbose,
1122
+ progress_callback=update_progress_bar,
1123
+ publish=publish,
1124
+ )
1125
+
1126
+ # Complete all progress tasks
1127
+ progress.update(processing_task, completed=100)
1128
+ progress.update(encoding_task, completed=100, visible=True)
1129
+ progress.update(upload_task, completed=100, visible=True)
1130
+ # Cancel the updater task
1131
+ updater.cancel()
1132
+
1133
+ # Store the original result before potentially overwriting it with publish result
1134
+ storage_result = result.copy()
1135
+ metadata_cid = storage_result.get("metadata_cid", "unknown")
1136
+
1137
+ # If publish flag is set, publish to the global IPFS network
1138
+ if publish:
1139
+ if metadata_cid != "unknown":
1140
+ info("Publishing to global IPFS network...")
1141
+ try:
1142
+ # Publish the metadata to the global IPFS network
1143
+ publish_result = await client.ipfs_client.publish_global(
1144
+ metadata_cid
1145
+ )
1146
+ if publish_result.get("published", False):
1147
+ success("Successfully published to global IPFS network")
1148
+ log(
1149
+ f"Access URL: [link]{client.ipfs_client.gateway}/ipfs/{metadata_cid}[/link]"
1150
+ )
1151
+ else:
1152
+ warning(
1153
+ f"{publish_result.get('message', 'Failed to publish to global network')}"
1154
+ )
1155
+ except Exception as e:
1156
+ warning(f"Failed to publish to global IPFS network: {str(e)}")
1157
+
1158
+ elapsed_time = time.time() - start_time
1159
+
1160
+ # Display metadata
1161
+ metadata = storage_result.get("metadata", {})
1162
+ total_files_stored = storage_result.get("total_files_stored", 0)
1163
+
1164
+ original_file = metadata.get("original_file", {})
1165
+ erasure_coding = metadata.get("erasure_coding", {})
1166
+
1167
+ # Create a summary panel with the erasure coding results
1168
+ summary_lines = [
1169
+ f"Completed in [bold green]{elapsed_time:.2f}[/bold green] seconds!"
1170
+ ]
1171
+
1172
+ # If metadata_cid is known but metadata is empty, try to get file info from result directly
1173
+ if metadata_cid != "unknown" and not original_file:
1174
+ file_name = os.path.basename(file_path)
1175
+ file_size = (
1176
+ os.path.getsize(file_path) if os.path.exists(file_path) else 0
1177
+ )
1178
+
1179
+ # Use direct values from input parameters when metadata is not available
1180
+ summary_lines.extend(
1181
+ [
1182
+ f"Original file: [bold]{file_name}[/bold] ([bold cyan]{file_size / 1024 / 1024:.2f} MB[/bold cyan])",
1183
+ f"Parameters: k=[bold]{k}[/bold], m=[bold]{m}[/bold]",
1184
+ f"Total files stored in marketplace: [bold]{total_files_stored}[/bold]",
1185
+ f"Metadata CID: [bold cyan]{metadata_cid}[/bold cyan]",
1186
+ ]
1187
+ )
1188
+
1189
+ # Add publish status if applicable
1190
+ if publish:
1191
+ summary_lines.extend(
1192
+ [
1193
+ "Published to global IPFS: [bold green]Yes[/bold green]",
1194
+ f"Global access URL: [link]{client.ipfs_client.gateway}/ipfs/{metadata_cid}[/link]",
1195
+ ]
1196
+ )
1197
+ else:
1198
+ summary_lines.extend(
1199
+ [
1200
+ f"Original file: [bold]{original_file.get('name')}[/bold] ([bold cyan]{original_file.get('size', 0) / 1024 / 1024:.2f} MB[/bold cyan])",
1201
+ f"File ID: [bold]{erasure_coding.get('file_id')}[/bold]",
1202
+ f"Parameters: k=[bold]{erasure_coding.get('k')}[/bold], m=[bold]{erasure_coding.get('m')}[/bold]",
1203
+ f"Total chunks: [bold]{len(metadata.get('chunks', []))}[/bold]",
1204
+ f"Total files stored in marketplace: [bold]{total_files_stored}[/bold]",
1205
+ f"Metadata CID: [bold cyan]{metadata_cid}[/bold cyan]",
1206
+ ]
1207
+ )
1208
+
1209
+ # Add publish status if applicable
1210
+ if publish:
1211
+ summary_lines.extend(
1212
+ [
1213
+ "Published to global IPFS: [bold green]Yes[/bold green]",
1214
+ f"Global access URL: [link]{client.ipfs_client.gateway}/ipfs/{metadata_cid}[/link]",
1215
+ ]
1216
+ )
1217
+
1218
+ # If we stored in the marketplace
1219
+ if "transaction_hash" in result:
1220
+ summary_lines.append(
1221
+ f"Transaction hash: [bold]{result['transaction_hash']}[/bold]"
1222
+ )
1223
+
1224
+ # Display the summary panel
1225
+ print_panel("\n".join(summary_lines), title="Erasure Coding Summary")
1226
+
1227
+ # Get file name, either from metadata or directly from file path
1228
+ output_filename = original_file.get("name")
1229
+ if not output_filename:
1230
+ output_filename = os.path.basename(file_path)
1231
+
1232
+ # Create reconstruction instructions panel
1233
+ reconstruction_lines = [
1234
+ "You will need:",
1235
+ f" 1. The metadata CID: [bold cyan]{metadata_cid}[/bold cyan]",
1236
+ f" 2. Access to at least [bold]{k}[/bold] chunks for each original chunk",
1237
+ "",
1238
+ "Reconstruction command:",
1239
+ f"[bold green underline]hippius reconstruct {metadata_cid} reconstructed_{output_filename}[/bold green underline]",
1240
+ ]
1241
+
1242
+ print_panel(
1243
+ "\n".join(reconstruction_lines), title="Reconstruction Instructions"
1244
+ )
1245
+
1246
+ return 0
1247
+
1248
+ except Exception as e:
1249
+ # Cancel the updater task in case of error
1250
+ updater.cancel()
1251
+ error(f"Erasure coding failed: {str(e)}")
1252
+
1253
+ # Provide helpful advice based on the error
1254
+ if "Wrong length" in str(e) and "input blocks" in str(e):
1255
+ # Create an advice panel for small file errors
1256
+ advice_lines = [
1257
+ "This error typically occurs with very small files.",
1258
+ "",
1259
+ "Suggestions:",
1260
+ " 1. Try using a smaller chunk size: [bold]--chunk-size 4096[/bold]",
1261
+ " 2. Try using a smaller k value: [bold]--k 2[/bold]",
1262
+ " 3. For very small files, consider using regular storage instead of erasure coding.",
1263
+ ]
1264
+ print_panel("\n".join(advice_lines), title="Troubleshooting")
1265
+
1266
+
1267
+ async def handle_reconstruct(
1268
+ client: HippiusClient, metadata_cid: str, output_file: str, verbose: bool = False
1269
+ ) -> int:
1270
+ """Handle the reconstruct command"""
1271
+ # Create initial parameters panel
1272
+ param_info = [
1273
+ f"Metadata CID: [bold cyan]{metadata_cid}[/bold cyan]",
1274
+ f"Output file: [bold]{output_file}[/bold]",
1275
+ ]
1276
+
1277
+ if verbose:
1278
+ param_info.append(
1279
+ "[bold yellow]Verbose mode enabled[/bold yellow] - will show detailed progress"
1280
+ )
1281
+
1282
+ print_panel("\n".join(param_info), title="Reconstruction Operation")
1283
+
1284
+ start_time = time.time()
1285
+
1286
+ # Create progress for the reconstruction operation
1287
+ with create_progress() as progress:
1288
+ # Add a task for the reconstruction process
1289
+ download_task = progress.add_task("[cyan]Downloading metadata...", total=100)
1290
+ reconstruct_task = progress.add_task(
1291
+ "[green]Reconstructing file...", total=100, visible=False
1292
+ )
1293
+
1294
+ # Create a task to update the progress while waiting for the operation
1295
+ async def update_progress():
1296
+ # Phase 1: Downloading metadata and chunks (first 40% of process)
1297
+ # Assume an approximate timing: phase1 = 5 seconds, phase2 = 10 seconds
1298
+ phase1_duration = 5
1299
+ total_duration = 15 # Estimation for both phases
1300
+
1301
+ while not progress.finished:
1302
+ elapsed = time.time() - start_time
1303
+
1304
+ # Phase 1: Downloading metadata and chunks (0-40%)
1305
+ if elapsed <= phase1_duration:
1306
+ # Calculate progress for phase 1 (0-100%)
1307
+ download_pct = min(100, elapsed / phase1_duration * 100)
1308
+ progress.update(download_task, completed=download_pct)
1309
+
1310
+ # Make reconstruction task visible when metadata download starts progressing
1311
+ if (
1312
+ download_pct > 30
1313
+ and not progress.tasks[reconstruct_task].visible
1314
+ ):
1315
+ progress.update(reconstruct_task, completed=0, visible=True)
1316
+ else:
1317
+ # Ensure download task shows complete
1318
+ progress.update(download_task, completed=100)
1319
+
1320
+ # Phase 2: Reconstructing (0-100%)
1321
+ remaining_time = total_duration - phase1_duration
1322
+ phase2_elapsed = elapsed - phase1_duration
1323
+ if phase2_elapsed >= 0:
1324
+ # Calculate progress for phase 2 (0-100%)
1325
+ reconstruct_pct = min(95, phase2_elapsed / remaining_time * 100)
1326
+ progress.update(
1327
+ reconstruct_task, completed=reconstruct_pct, visible=True
1328
+ )
1329
+
1330
+ await asyncio.sleep(0.1)
1331
+
1332
+ # Start the progress updater task
1333
+ updater = asyncio.create_task(update_progress())
1334
+
1335
+ try:
1336
+ # Use the reconstruct_erasure_coded_file method
1337
+ result = await client.reconstruct_from_erasure_code(
1338
+ metadata_cid=metadata_cid, output_file=output_file, verbose=verbose
1339
+ )
1340
+
1341
+ # Complete all progress tasks
1342
+ progress.update(download_task, completed=100)
1343
+ progress.update(reconstruct_task, completed=100)
1344
+ # Cancel the updater task
1345
+ updater.cancel()
1346
+
1347
+ elapsed_time = time.time() - start_time
1348
+
1349
+ # Display reconstruction results
1350
+ output_path = result.get("output_path", output_file)
1351
+ file_size = result.get("size_bytes", 0)
1352
+ size_formatted = format_size(file_size)
1353
+
1354
+ # Create success panel
1355
+ success_info = [
1356
+ f"Reconstruction completed in [bold green]{elapsed_time:.2f}[/bold green] seconds!",
1357
+ f"Saved to: [bold]{output_path}[/bold]",
1358
+ f"Size: [bold cyan]{file_size:,}[/bold cyan] bytes ([bold cyan]{size_formatted}[/bold cyan])",
1359
+ ]
1360
+
1361
+ if result.get("decrypted"):
1362
+ success_info.append(
1363
+ "[bold yellow]File was decrypted during reconstruction[/bold yellow]"
1364
+ )
1365
+
1366
+ print_panel("\n".join(success_info), title="Reconstruction Successful")
1367
+
1368
+ return 0
1369
+
1370
+ except Exception as e:
1371
+ # Cancel the updater task in case of error
1372
+ updater.cancel()
1373
+ error(f"Reconstruction failed: {str(e)}")
1374
+
1375
+ if "No metadata found for CID" in str(e):
1376
+ advice_lines = [
1377
+ "The metadata CID could not be found. Please check:",
1378
+ " 1. The CID is correct",
1379
+ " 2. The IPFS gateway is accessible",
1380
+ " 3. If the file was published to the global IPFS network",
1381
+ ]
1382
+ print_panel("\n".join(advice_lines), title="Troubleshooting")
1383
+
1384
+ elif "Failed to download chunk" in str(e):
1385
+ advice_lines = [
1386
+ "Failed to download enough chunks for reconstruction. Please check:",
1387
+ " 1. Your connection to the IPFS network",
1388
+ " 2. If enough chunks are available (need at least k chunks)",
1389
+ " 3. If the chunks are still pinned in the network",
1390
+ ]
1391
+ print_panel("\n".join(advice_lines), title="Troubleshooting")
1392
+
1393
+ return 1
1394
+
1395
+
1396
+ async def handle_delete(client: HippiusClient, cid: str, force: bool = False) -> int:
1397
+ """Handle the delete command"""
1398
+ info(f"Preparing to delete file with CID: [bold cyan]{cid}[/bold cyan]")
1399
+
1400
+ if not force:
1401
+ warning("This will cancel storage and remove the file from the marketplace.")
1402
+ confirm = input("Continue? (y/n): ").strip().lower()
1403
+ if confirm != "y":
1404
+ log("Deletion cancelled", style="yellow")
1405
+ return 0
1406
+
1407
+ info("Deleting file from marketplace...")
1408
+ result = await client.delete_file(cid)
1409
+
1410
+ if result.get("success"):
1411
+ success("File successfully deleted")
1412
+
1413
+ details = []
1414
+ if "transaction_hash" in result:
1415
+ details.append(
1416
+ f"Transaction hash: [bold]{result['transaction_hash']}[/bold]"
1417
+ )
1418
+
1419
+ # Create an informative panel with notes
1420
+ notes = [
1421
+ "1. The file is now unpinned from the marketplace",
1422
+ "2. The CID may still resolve temporarily until garbage collection occurs",
1423
+ "3. If the file was published to the global IPFS network, it may still be",
1424
+ " available through other nodes that pinned it",
1425
+ ]
1426
+
1427
+ if details:
1428
+ print_panel("\n".join(details), title="Transaction Details")
1429
+
1430
+ print_panel("\n".join(notes), title="Important Notes")
1431
+
1432
+ return 0
1433
+ else:
1434
+ error(f"Failed to delete file: {result}")
1435
+
1436
+
1437
+ async def handle_ec_delete(
1438
+ client: HippiusClient, metadata_cid: str, force: bool = False
1439
+ ) -> int:
1440
+ """Handle the ec-delete command"""
1441
+ info(
1442
+ f"Preparing to delete erasure-coded file with metadata CID: [bold cyan]{metadata_cid}[/bold cyan]"
1443
+ )
1444
+
1445
+ if not force:
1446
+ warning("This will delete the metadata and all chunks from the marketplace.")
1447
+ confirm = input("Continue? (y/n): ").strip().lower()
1448
+ if confirm != "y":
1449
+ log("Deletion cancelled", style="yellow")
1450
+ return 0
1451
+
1452
+ try:
1453
+ info("Deleting erasure-coded file from marketplace...")
1454
+ result = await client.delete_ec_file(metadata_cid)
1455
+
1456
+ if result.get("success"):
1457
+ success("Erasure-coded file successfully deleted")
1458
+
1459
+ # Show detailed results
1460
+ details = []
1461
+ chunks_deleted = result.get("chunks_deleted", 0)
1462
+ details.append(f"Deleted [bold]{chunks_deleted}[/bold] chunks")
1463
+
1464
+ if "transaction_hash" in result:
1465
+ details.append(
1466
+ f"Transaction hash: [bold]{result['transaction_hash']}[/bold]"
1467
+ )
1468
+
1469
+ print_panel("\n".join(details), title="Deletion Results")
1470
+
1471
+ return 0
1472
+ else:
1473
+ error(
1474
+ f"Failed to delete erasure-coded file: {result.get('message', 'Unknown error')}"
1475
+ )
1476
+ return 1
1477
+
1478
+ except Exception as e:
1479
+ error(f"Failed to delete erasure-coded file: {e}")
1480
+ return 1
1481
+
1482
+
1483
+ #
1484
+ # Configuration Handlers
1485
+ #
1486
+
1487
+
1488
+ def handle_config_get(section: str, key: str) -> int:
1489
+ """Handle the config get command"""
1490
+ try:
1491
+ value = get_config_value(section, key)
1492
+ log(
1493
+ f"[bold cyan]{section}[/bold cyan].[bold green]{key}[/bold green] = [bold]{value}[/bold]"
1494
+ )
1495
+ return 0
1496
+ except Exception as e:
1497
+ error(f"Error getting configuration value: {e}")
1498
+ return 1
1499
+
1500
+
1501
+ def handle_config_set(section: str, key: str, value: str) -> int:
1502
+ """Handle the config set command"""
1503
+ try:
1504
+ # Convert string "true"/"false" to boolean if applicable
1505
+ if value.lower() == "true":
1506
+ value = True
1507
+ elif value.lower() == "false":
1508
+ value = False
1509
+
1510
+ # Set the configuration value
1511
+ set_config_value(section, key, value)
1512
+ success(
1513
+ f"Set [bold cyan]{section}[/bold cyan].[bold green]{key}[/bold green] = [bold]{value}[/bold]"
1514
+ )
1515
+ return 0
1516
+ except Exception as e:
1517
+ error(f"Error setting configuration value: {e}")
1518
+ return 1
1519
+
1520
+
1521
+ def handle_config_list() -> int:
1522
+ """Handle the config list command"""
1523
+ try:
1524
+ config = get_all_config()
1525
+
1526
+ # Format the configuration as a multi-line string
1527
+ config_lines = ["Current configuration:"]
1528
+
1529
+ for section, values in config.items():
1530
+ config_lines.append(f"\n[bold cyan]{section}[/bold cyan]")
1531
+ for key, value in values.items():
1532
+ config_lines.append(
1533
+ f" [bold green]{key}[/bold green] = [bold]{value}[/bold]"
1534
+ )
1535
+
1536
+ # Print as a panel
1537
+ print_panel("\n".join(config_lines), title="Configuration")
1538
+
1539
+ return 0
1540
+ except Exception as e:
1541
+ error(f"Error listing configuration: {e}")
1542
+ return 1
1543
+
1544
+
1545
+ def handle_config_reset() -> int:
1546
+ """Handle the config reset command"""
1547
+ try:
1548
+ reset_config()
1549
+ success("Configuration reset to default values")
1550
+ return 0
1551
+ except Exception as e:
1552
+ error(f"Error resetting configuration: {e}")
1553
+ return 1
1554
+
1555
+
1556
+ #
1557
+ # Seed Phrase Handlers
1558
+ #
1559
+
1560
+
1561
+ def handle_seed_phrase_set(
1562
+ seed_phrase: str, encode: bool = False, account_name: Optional[str] = None
1563
+ ) -> int:
1564
+ """Handle the seed set command"""
1565
+ try:
1566
+ # Validate the seed phrase
1567
+ if not seed_phrase or len(seed_phrase.split()) not in [12, 24]:
1568
+ error("Seed phrase must be 12 or 24 words")
1569
+ return 1
1570
+
1571
+ # If account name is provided, create a new account
1572
+ if account_name:
1573
+ info(f"Setting seed phrase for account: [bold]{account_name}[/bold]")
1574
+ else:
1575
+ info("Setting default seed phrase")
1576
+
1577
+ # Encrypt if requested
1578
+ password = None
1579
+ if encode:
1580
+ log("\nYou've chosen to encrypt this seed phrase.", style="yellow")
1581
+ password = getpass.getpass("Enter a password for encryption: ")
1582
+ confirm = getpass.getpass("Confirm password: ")
1583
+
1584
+ if password != confirm:
1585
+ error("Passwords do not match")
1586
+ return 1
1587
+
1588
+ if not password:
1589
+ error("Password cannot be empty for encryption")
1590
+ return 1
1591
+
1592
+ # Set the seed phrase
1593
+ set_seed_phrase(seed_phrase, password, account_name)
1594
+
1595
+ # Gather information for the success panel
1596
+ status_info = []
1597
+
1598
+ # Display success message
1599
+ if encode:
1600
+ status_info.append(
1601
+ "[bold green]Seed phrase set and encrypted successfully[/bold green]"
1602
+ )
1603
+ else:
1604
+ status_info.append("[bold green]Seed phrase set successfully[/bold green]")
1605
+ status_info.append(
1606
+ "\n[bold yellow]Warning:[/bold yellow] Seed phrase is stored in plaintext. Consider encrypting it with:"
1607
+ )
1608
+ status_info.append(
1609
+ f" [bold]hippius seed encode{' --account ' + account_name if account_name else ''}[/bold]"
1610
+ )
1611
+
1612
+ # If this is a new account, show the address
1613
+ try:
1614
+ address = get_account_address(account_name)
1615
+ status_info.append(f"\nAccount address: [bold cyan]{address}[/bold cyan]")
1616
+ except:
1617
+ pass
1618
+
1619
+ print_panel("\n".join(status_info), title="Seed Phrase Status")
1620
+
1621
+ return 0
1622
+ except Exception as e:
1623
+ error(f"Error setting seed phrase: {e}")
1624
+ return 1
1625
+
1626
+
1627
+ def handle_seed_phrase_encode(account_name: Optional[str] = None) -> int:
1628
+ """Handle the seed encode command"""
1629
+ try:
1630
+ # Check if account exists and get its encryption status
1631
+ config = load_config()
1632
+ accounts = config.get("substrate", {}).get("accounts", {})
1633
+
1634
+ # If account name not specified, use active account
1635
+ if not account_name:
1636
+ account_name = config.get("substrate", {}).get("active_account")
1637
+ if not account_name:
1638
+ error("No account specified and no active account")
1639
+ return 1
1640
+
1641
+ # Check if the account exists
1642
+ if account_name not in accounts:
1643
+ error(f"Account '{account_name}' not found")
1644
+ return 1
1645
+
1646
+ # Get account details
1647
+ account = accounts.get(account_name, {})
1648
+ is_encrypted = account.get("seed_phrase_encoded", False)
1649
+ seed_phrase = account.get("seed_phrase")
1650
+
1651
+ # Check if we have a seed phrase
1652
+ if not seed_phrase:
1653
+ error(f"Account '{account_name}' doesn't have a seed phrase")
1654
+ info(
1655
+ f"Set a seed phrase first with: [bold green underline]hippius seed set <seed_phrase> --account {account_name}[/bold green underline]"
1656
+ )
1657
+ return 1
1658
+
1659
+ # Check if the seed phrase is already encrypted
1660
+ if is_encrypted:
1661
+ info("Seed phrase is already encrypted")
1662
+ confirm = (
1663
+ input("Do you want to re-encrypt it with a new password? (y/n): ")
1664
+ .strip()
1665
+ .lower()
1666
+ )
1667
+ if confirm != "y":
1668
+ info("Encryption cancelled")
1669
+ return 0
1670
+
1671
+ # Need to decrypt with old password first
1672
+ old_password = getpass.getpass("Enter your current password to decrypt: ")
1673
+ decrypted_seed_phrase = decrypt_seed_phrase(old_password, account_name)
1674
+
1675
+ if not decrypted_seed_phrase:
1676
+ error("Unable to decrypt the seed phrase. Incorrect password?")
1677
+ return 1
1678
+
1679
+ # Now we have the decrypted seed phrase
1680
+ seed_phrase = decrypted_seed_phrase
1681
+
1682
+ # Get new password for encryption
1683
+ info("\nYou are about to encrypt your seed phrase.")
1684
+ password = getpass.getpass("Enter a password for encryption: ")
1685
+ confirm = getpass.getpass("Confirm password: ")
1686
+
1687
+ if password != confirm:
1688
+ error("Passwords do not match")
1689
+ return 1
1690
+
1691
+ if not password:
1692
+ error("Password cannot be empty for encryption")
1693
+ return 1
1694
+
1695
+ # Now encrypt the seed phrase - key fix here passing correct parameters
1696
+ success = encrypt_seed_phrase(seed_phrase, password, account_name)
1697
+
1698
+ # Security: Clear the plaintext seed phrase from memory
1699
+ # This is a best-effort approach, as Python's garbage collection may still keep copies
1700
+ seed_phrase = None
1701
+
1702
+ if success:
1703
+ # Create success panel with encryption information
1704
+ encryption_info = [
1705
+ f"Account: [bold]{account_name}[/bold]",
1706
+ "[bold green]Seed phrase encrypted successfully[/bold green]",
1707
+ "",
1708
+ "You will need to provide this password when using the account for:",
1709
+ " - Pinning files to IPFS",
1710
+ " - Erasure coding with publishing",
1711
+ " - Any other blockchain operations",
1712
+ "",
1713
+ "[bold yellow underline]Security note:[/bold yellow underline] The original unencrypted seed phrase is NOT stored in the config.",
1714
+ ]
1715
+
1716
+ # Try to get the address for display
1717
+ try:
1718
+ address = get_account_address(account_name)
1719
+ if address:
1720
+ encryption_info.append("")
1721
+ encryption_info.append(
1722
+ f"Account address: [bold cyan]{address}[/bold cyan]"
1723
+ )
1724
+ except Exception:
1725
+ pass
1726
+
1727
+ print_panel("\n".join(encryption_info), title="Encryption Successful")
1728
+ return 0
1729
+ else:
1730
+ error("Failed to encrypt seed phrase")
1731
+ return 1
1732
+
1733
+ except Exception as e:
1734
+ error(f"Error encrypting seed phrase: {e}")
1735
+ return 1
1736
+
1737
+
1738
+ def handle_seed_phrase_decode(account_name: Optional[str] = None) -> int:
1739
+ """Handle the seed decode command - temporarily decrypts and displays the seed phrase"""
1740
+ try:
1741
+ # Check if seed phrase exists and is encrypted
1742
+ config = load_config()
1743
+ accounts = config.get("substrate", {}).get("accounts", {})
1744
+
1745
+ if account_name:
1746
+ account = accounts.get(account_name, {})
1747
+ is_encrypted = account.get("seed_phrase_encoded", False)
1748
+ else:
1749
+ # Get active account
1750
+ active_account = config.get("substrate", {}).get("active_account")
1751
+ if active_account and active_account in accounts:
1752
+ is_encrypted = accounts[active_account].get(
1753
+ "seed_phrase_encoded", False
1754
+ )
1755
+ else:
1756
+ # Legacy mode
1757
+ is_encrypted = config.get("substrate", {}).get(
1758
+ "seed_phrase_encoded", False
1759
+ )
1760
+
1761
+ if not is_encrypted:
1762
+ info("Seed phrase is not encrypted")
1763
+ return 0
1764
+
1765
+ # Get password for decryption
1766
+ password = getpass.getpass("Enter your password to decrypt the seed phrase: ")
1767
+
1768
+ if not password:
1769
+ error("Password cannot be empty")
1770
+ return 1
1771
+
1772
+ # Try to decrypt the seed phrase
1773
+ try:
1774
+ seed_phrase = decrypt_seed_phrase(password, account_name)
1775
+
1776
+ if seed_phrase:
1777
+ # Create info panel for the decrypted seed phrase
1778
+ seed_info = [
1779
+ f"Decrypted seed phrase: [bold yellow]{seed_phrase}[/bold yellow]",
1780
+ "",
1781
+ "[bold green underline]NOTE: This is a temporary decryption only. Your seed phrase remains encrypted in the config.[/bold green underline]",
1782
+ "",
1783
+ "[bold red underline]SECURITY WARNING:[/bold red underline]",
1784
+ "- Your seed phrase gives full access to your account funds",
1785
+ "- Never share it with anyone or store it in an insecure location",
1786
+ "- Be aware that displaying it on screen could expose it to screen capture",
1787
+ "- Consider clearing your terminal history after this operation",
1788
+ ]
1789
+
1790
+ print_panel("\n".join(seed_info), title="Seed Phrase Decoded")
1791
+
1792
+ # Security: Clear the plaintext seed phrase from memory
1793
+ # This is a best-effort approach, as Python's garbage collection may still keep copies
1794
+ seed_phrase = None
1795
+
1796
+ return 0
1797
+ else:
1798
+ error("Failed to decrypt seed phrase")
1799
+ return 1
1800
+
1801
+ except Exception as e:
1802
+ error(f"Error decrypting seed phrase: {e}")
1803
+
1804
+ if "decryption failed" in str(e).lower():
1805
+ warning("Incorrect password")
1806
+
1807
+ return 1
1808
+
1809
+ except Exception as e:
1810
+ error(f"{e}")
1811
+ return 1
1812
+
1813
+
1814
+ def handle_seed_phrase_status(account_name: Optional[str] = None) -> int:
1815
+ """Handle the seed status command"""
1816
+ try:
1817
+ # Load configuration
1818
+ config = load_config()
1819
+
1820
+ if account_name:
1821
+ print(f"Checking seed phrase status for account: {account_name}")
1822
+
1823
+ # Check if account exists
1824
+ accounts = config.get("substrate", {}).get("accounts", {})
1825
+ if account_name not in accounts:
1826
+ print(f"Account '{account_name}' not found")
1827
+ return 1
1828
+
1829
+ account = accounts[account_name]
1830
+ has_seed = "seed_phrase" in account
1831
+ is_encrypted = account.get("seed_phrase_encoded", False)
1832
+ is_active = account_name == get_active_account()
1833
+
1834
+ print("\nAccount Status:")
1835
+ print(f" Account Name: {account_name}")
1836
+ print(f" Has Seed Phrase: {'Yes' if has_seed else 'No'}")
1837
+ print(f" Encrypted: {'Yes' if is_encrypted else 'No'}")
1838
+ print(f" Active: {'Yes' if is_active else 'No'}")
1839
+
1840
+ if has_seed:
1841
+ try:
1842
+ # Try to get the address (will use cached if available)
1843
+ address = get_account_address(account_name)
1844
+ print(f" Address: {address}")
1845
+ except Exception as e:
1846
+ if is_encrypted:
1847
+ print(" Address: Encrypted (password required to view)")
1848
+ else:
1849
+ print(f" Address: Unable to derive (Error: {e})")
1850
+
1851
+ else:
1852
+ print("Checking active account seed phrase status")
1853
+
1854
+ # Get the active account
1855
+ active_account = get_active_account()
1856
+ if active_account:
1857
+ accounts = config.get("substrate", {}).get("accounts", {})
1858
+ if active_account in accounts:
1859
+ account = accounts[active_account]
1860
+ has_seed = "seed_phrase" in account
1861
+ is_encrypted = account.get("seed_phrase_encoded", False)
1862
+
1863
+ print(f"\nActive Account: {active_account}")
1864
+ print(f" Has Seed Phrase: {'Yes' if has_seed else 'No'}")
1865
+ print(f" Encrypted: {'Yes' if is_encrypted else 'No'}")
1866
+
1867
+ if has_seed:
1868
+ try:
1869
+ # Try to get the address (will use cached if available)
1870
+ address = get_account_address(active_account)
1871
+ print(f" Address: {address}")
1872
+ except Exception as e:
1873
+ if is_encrypted:
1874
+ print(
1875
+ " Address: Encrypted (password required to view)"
1876
+ )
1877
+ else:
1878
+ print(f" Address: Unable to derive (Error: {e})")
1879
+ else:
1880
+ print(
1881
+ f"\nActive account '{active_account}' not found in configuration"
1882
+ )
1883
+ else:
1884
+ print("\nNo active account set")
1885
+
1886
+ return 0
1887
+
1888
+ except Exception as e:
1889
+ print(f"Error checking seed phrase status: {e}")
1890
+ return 1
1891
+
1892
+
1893
+ #
1894
+ # Account Management Handlers
1895
+ #
1896
+
1897
+
1898
+ def handle_account_info(account_name: Optional[str] = None) -> int:
1899
+ """Handle the account info command - displays detailed information about an account"""
1900
+ try:
1901
+ # Load configuration
1902
+ config = load_config()
1903
+
1904
+ # If account name not specified, use active account
1905
+ if not account_name:
1906
+ account_name = config.get("substrate", {}).get("active_account")
1907
+ if not account_name:
1908
+ error("No account specified and no active account")
1909
+ return 1
1910
+
1911
+ # Check if account exists
1912
+ accounts = config.get("substrate", {}).get("accounts", {})
1913
+ if account_name not in accounts:
1914
+ error(f"Account '{account_name}' not found")
1915
+ return 1
1916
+
1917
+ # Get account details
1918
+ account = accounts[account_name]
1919
+ has_seed = "seed_phrase" in account
1920
+ is_encrypted = account.get("seed_phrase_encoded", False)
1921
+ is_active = account_name == get_active_account()
1922
+ ss58_address = account.get("ss58_address", "")
1923
+
1924
+ # Account information panel with rich formatting
1925
+ account_info = [
1926
+ f"Account Name: [bold]{account_name}[/bold]",
1927
+ f"Active: [bold cyan]{'Yes' if is_active else 'No'}[/bold cyan]",
1928
+ f"Has Seed Phrase: [bold]{'Yes' if has_seed else 'No'}[/bold]",
1929
+ f"Encryption: [bold {'green' if is_encrypted else 'yellow'}]{'Encrypted' if is_encrypted else 'Unencrypted'}[/bold {'green' if is_encrypted else 'yellow'}]",
1930
+ ]
1931
+
1932
+ if ss58_address:
1933
+ account_info.append(f"SS58 Address: [bold cyan]{ss58_address}[/bold cyan]")
1934
+ elif has_seed:
1935
+ if is_encrypted:
1936
+ account_info.append(
1937
+ "[dim]Address: Encrypted (password required to view)[/dim]"
1938
+ )
1939
+ else:
1940
+ try:
1941
+ # Try to get the address
1942
+ address = get_account_address(account_name)
1943
+ account_info.append(
1944
+ f"SS58 Address: [bold cyan]{address}[/bold cyan]"
1945
+ )
1946
+ except Exception as e:
1947
+ account_info.append(
1948
+ f"[yellow]Unable to derive address: {e}[/yellow]"
1949
+ )
1950
+
1951
+ # Add suggestions based on account status
1952
+ account_info.append("")
1953
+ if is_active:
1954
+ account_info.append("[bold green]This is your active account[/bold green]")
1955
+ else:
1956
+ account_info.append(
1957
+ f"[dim]To use this account: [bold green underline]hippius account switch {account_name}[/bold green underline][/dim]"
1958
+ )
1959
+
1960
+ if has_seed and not is_encrypted:
1961
+ account_info.append(
1962
+ f"[bold yellow underline]WARNING:[/bold yellow underline] Seed phrase is not encrypted"
1963
+ )
1964
+ account_info.append(
1965
+ f"[dim]To encrypt: [bold green underline]hippius account encode --name {account_name}[/bold green underline][/dim]"
1966
+ )
1967
+
1968
+ # Print the panel with rich formatting
1969
+ print_panel(
1970
+ "\n".join(account_info), title=f"Account Information: {account_name}"
1971
+ )
1972
+
1973
+ return 0
1974
+
1975
+ except Exception as e:
1976
+ error(f"Error getting account info: {e}")
1977
+ return 1
1978
+
1979
+
1980
+ def handle_account_create(
1981
+ client: HippiusClient, name: str, encrypt: bool = False
1982
+ ) -> int:
1983
+ """Handle the account create command"""
1984
+ try:
1985
+ # Check if account already exists
1986
+ accounts = list_accounts()
1987
+ if name in accounts:
1988
+ print(f"Error: Account '{name}' already exists")
1989
+ return 1
1990
+
1991
+ print(f"Creating new account: {name}")
1992
+
1993
+ # Import Keypair at the beginning to ensure it's available
1994
+ from substrateinterface import Keypair
1995
+
1996
+ # Generate a new keypair (seed phrase)
1997
+ seed_phrase = client.substrate_client.generate_seed_phrase()
1998
+
1999
+ if not seed_phrase:
2000
+ print("Error: Failed to generate seed phrase")
2001
+ return 1
2002
+
2003
+ # Process encryption
2004
+ password = None
2005
+ if encrypt:
2006
+ print("\nYou've chosen to encrypt this seed phrase.")
2007
+ password = getpass.getpass("Enter a password for encryption: ")
2008
+ confirm = getpass.getpass("Confirm password: ")
2009
+
2010
+ if password != confirm:
2011
+ print("Error: Passwords do not match")
2012
+ return 1
2013
+
2014
+ if not password:
2015
+ print("Error: Password cannot be empty for encryption")
2016
+ return 1
2017
+
2018
+ # Set the seed phrase for the new account
2019
+ # First load the config to directly edit it
2020
+ config = load_config()
2021
+
2022
+ # Ensure accounts structure exists
2023
+ if "accounts" not in config["substrate"]:
2024
+ config["substrate"]["accounts"] = {}
2025
+
2026
+ # Create keypair directly from seed phrase
2027
+ keypair = Keypair.create_from_mnemonic(seed_phrase)
2028
+ address = keypair.ss58_address
2029
+
2030
+ # Add the new account
2031
+ config["substrate"]["accounts"][name] = {
2032
+ "seed_phrase": seed_phrase,
2033
+ "seed_phrase_encoded": False,
2034
+ "seed_phrase_salt": None,
2035
+ "ss58_address": address,
2036
+ }
2037
+
2038
+ # Set as active account
2039
+ config["substrate"]["active_account"] = name
2040
+
2041
+ # Save the config
2042
+ save_config(config)
2043
+
2044
+ # Print account information using rich formatting
2045
+ account_info = [
2046
+ f"Account: [bold]{name}[/bold]",
2047
+ f"Address: [bold cyan]{address}[/bold cyan]",
2048
+ f"Seed phrase: [bold yellow]{seed_phrase}[/bold yellow]",
2049
+ "",
2050
+ "[bold red underline]IMPORTANT:[/bold red underline] Keep your seed phrase safe. It's the only way to recover your account!",
2051
+ ]
2052
+
2053
+ # Add encryption status
2054
+ if encrypt:
2055
+ account_info.append("")
2056
+ account_info.append(
2057
+ "[bold green]Your seed phrase is encrypted.[/bold green]"
2058
+ )
2059
+ account_info.append(
2060
+ "You'll need to provide the password whenever using this account."
2061
+ )
2062
+ else:
2063
+ account_info.append("")
2064
+ account_info.append(
2065
+ "[bold yellow underline]WARNING:[/bold yellow underline] Your seed phrase is stored unencrypted."
2066
+ )
2067
+ account_info.append(
2068
+ f"[bold green underline]Consider encrypting it with: hippius account encode --name {name}[/bold green underline]"
2069
+ )
2070
+
2071
+ account_info.append("")
2072
+ account_info.append(
2073
+ "This account is now active. Use it with: [bold]hippius <command>[/bold]"
2074
+ )
2075
+
2076
+ # Print the panel with rich formatting
2077
+ print_panel("\n".join(account_info), title="Account Created Successfully")
2078
+
2079
+ return 0
2080
+
2081
+ except Exception as e:
2082
+ error(f"Error creating account: {e}")
2083
+ return 1
2084
+
2085
+
2086
+ def handle_account_export(
2087
+ client: HippiusClient, name: Optional[str] = None, file_path: Optional[str] = None
2088
+ ) -> int:
2089
+ """Handle the account export command"""
2090
+ try:
2091
+ # Determine account to export
2092
+ account_name = name or get_active_account()
2093
+
2094
+ if not account_name:
2095
+ print("Error: No account specified and no active account found")
2096
+ print("Use --name to specify an account to export")
2097
+ return 1
2098
+
2099
+ print(f"Exporting account: {account_name}")
2100
+
2101
+ # Default file path if not provided
2102
+ if not file_path:
2103
+ file_path = f"{account_name}_hippius_account.json"
2104
+
2105
+ # Export the account
2106
+ config = load_config()
2107
+ accounts = config.get("substrate", {}).get("accounts", {})
2108
+
2109
+ if account_name not in accounts:
2110
+ print(f"Error: Account '{account_name}' not found")
2111
+ return 1
2112
+
2113
+ # Get the account data
2114
+ account_data = accounts[account_name]
2115
+
2116
+ # Create export data
2117
+ export_data = {
2118
+ "name": account_name,
2119
+ "encrypted": account_data.get("encrypted", False),
2120
+ "seed_phrase": account_data.get("seed_phrase", ""),
2121
+ "address": account_data.get("address", ""),
2122
+ }
2123
+
2124
+ # Save to file
2125
+ with open(file_path, "w") as f:
2126
+ json.dump(export_data, f, indent=2)
2127
+
2128
+ print(f"Account exported to: {file_path}")
2129
+
2130
+ # Security warning
2131
+ if not export_data.get("encrypted"):
2132
+ print("\nWARNING: This export file contains an unencrypted seed phrase.")
2133
+ print("Keep this file secure and never share it with anyone.")
2134
+
2135
+ return 0
2136
+
2137
+ except Exception as e:
2138
+ print(f"Error exporting account: {e}")
2139
+ return 1
2140
+
2141
+
2142
+ def handle_account_import(
2143
+ client: HippiusClient, file_path: str, encrypt: bool = False
2144
+ ) -> int:
2145
+ """Handle the account import command"""
2146
+ try:
2147
+ # Verify file exists
2148
+ if not os.path.exists(file_path):
2149
+ print(f"Error: File {file_path} not found")
2150
+ return 1
2151
+
2152
+ print(f"Importing account from: {file_path}")
2153
+
2154
+ # Read and parse the file
2155
+ try:
2156
+ with open(file_path, "r") as f:
2157
+ import_data = json.load(f)
2158
+
2159
+ # Validate data
2160
+ if not isinstance(import_data, dict):
2161
+ print("Error: Invalid account file format")
2162
+ return 1
2163
+
2164
+ account_name = import_data.get("name")
2165
+ seed_phrase = import_data.get("seed_phrase")
2166
+ is_encrypted = import_data.get("encrypted", False)
2167
+
2168
+ if not account_name:
2169
+ print("Error: Missing account name in import file")
2170
+ return 1
2171
+
2172
+ if not seed_phrase:
2173
+ print("Error: Missing seed phrase in import file")
2174
+ return 1
2175
+
2176
+ except Exception as e:
2177
+ print(f"Error reading account file: {e}")
2178
+ return 1
2179
+
2180
+ # Check if account already exists
2181
+ accounts = list_accounts()
2182
+ if account_name in accounts:
2183
+ print(f"Warning: Account '{account_name}' already exists")
2184
+ overwrite = input("Overwrite existing account? (y/n): ").strip().lower()
2185
+ if overwrite != "y":
2186
+ print("Import cancelled")
2187
+ return 0
2188
+
2189
+ # Handle encryption
2190
+ password = None
2191
+
2192
+ # If importing encrypted account
2193
+ if is_encrypted:
2194
+ print("\nThis account has an encrypted seed phrase.")
2195
+ if encrypt:
2196
+ # Re-encrypt with new password
2197
+ print("You've chosen to re-encrypt this account.")
2198
+ old_password = getpass.getpass("Enter the original password: ")
2199
+
2200
+ # Try to decrypt first
2201
+ try:
2202
+ # Create temporary decryption box
2203
+ if ENCRYPTION_AVAILABLE:
2204
+ # Derive key from password
2205
+ import hashlib
2206
+
2207
+ import nacl.secret
2208
+ import nacl.utils
2209
+ from nacl.exceptions import CryptoError
2210
+
2211
+ key = hashlib.sha256(old_password.encode()).digest()
2212
+ box = nacl.secret.SecretBox(key)
2213
+
2214
+ # Try decryption
2215
+ try:
2216
+ # Split the nonce and ciphertext
2217
+ data = base64.b64decode(seed_phrase)
2218
+ nonce = data[: box.NONCE_SIZE]
2219
+ ciphertext = data[box.NONCE_SIZE :]
2220
+
2221
+ # Decrypt
2222
+ decrypted = box.decrypt(ciphertext, nonce)
2223
+ seed_phrase = decrypted.decode("utf-8")
2224
+
2225
+ # Now get new password for re-encryption
2226
+ new_password = getpass.getpass(
2227
+ "Enter new password for encryption: "
2228
+ )
2229
+ confirm = getpass.getpass("Confirm new password: ")
2230
+
2231
+ if new_password != confirm:
2232
+ print("Error: Passwords do not match")
2233
+ return 1
2234
+
2235
+ password = new_password
2236
+
2237
+ except CryptoError:
2238
+ print("Error: Incorrect password for encrypted seed phrase")
2239
+ return 1
2240
+ else:
2241
+ print("Error: PyNaCl is required for encryption/decryption.")
2242
+ print("Install it with: pip install pynacl")
2243
+ return 1
2244
+ except Exception as e:
2245
+ print(f"Error decrypting seed phrase: {e}")
2246
+ return 1
2247
+ else:
2248
+ # Keep existing encryption
2249
+ print("Importing with existing encryption.")
2250
+ print("You'll need the original password to use this account.")
2251
+ elif encrypt:
2252
+ # Encrypt an unencrypted import
2253
+ print("\nYou've chosen to encrypt this account during import.")
2254
+ password = getpass.getpass("Enter a password for encryption: ")
2255
+ confirm = getpass.getpass("Confirm password: ")
2256
+
2257
+ if password != confirm:
2258
+ print("Error: Passwords do not match")
2259
+ return 1
2260
+
2261
+ if not password:
2262
+ print("Error: Password cannot be empty for encryption")
2263
+ return 1
2264
+
2265
+ # Import the account
2266
+ set_seed_phrase(seed_phrase, password, account_name)
2267
+
2268
+ # Set as active account
2269
+ set_active_account(account_name)
2270
+
2271
+ print(f"\nSuccessfully imported account: {account_name}")
2272
+ print("This account is now active.")
2273
+
2274
+ # If address is provided in import data, show it
2275
+ if "address" in import_data and import_data["address"]:
2276
+ print(f"Address: {import_data['address']}")
2277
+ else:
2278
+ # Try to get address
2279
+ try:
2280
+ address = get_account_address(account_name)
2281
+ print(f"Address: {address}")
2282
+ except:
2283
+ if is_encrypted or encrypt:
2284
+ print("Address: Encrypted (password required to view)")
2285
+ else:
2286
+ print("Address: Unable to derive")
2287
+
2288
+ return 0
2289
+
2290
+ except Exception as e:
2291
+ print(f"Error importing account: {e}")
2292
+ return 1
2293
+
2294
+
2295
+ def handle_account_list() -> int:
2296
+ """Handle the account list command"""
2297
+ try:
2298
+ accounts = list_accounts()
2299
+ active_account = get_active_account()
2300
+
2301
+ if not accounts:
2302
+ log("No accounts found", style="yellow")
2303
+ return 0
2304
+
2305
+ info(f"Found [bold]{len(accounts)}[/bold] accounts:")
2306
+
2307
+ # Load config to get more details
2308
+ config = load_config()
2309
+ account_config = config.get("substrate", {}).get("accounts", {})
2310
+
2311
+ # Create data for a table
2312
+ account_data_list = []
2313
+ for i, account_name in enumerate(accounts, 1):
2314
+ account_data = account_config.get(account_name, {})
2315
+
2316
+ is_active = account_name == active_account
2317
+ has_seed = "seed_phrase" in account_data
2318
+ is_encrypted = account_data.get("seed_phrase_encoded", False)
2319
+
2320
+ # Get address
2321
+ address = account_data.get("ss58_address", "")
2322
+
2323
+ # Add to table data
2324
+ row = {
2325
+ "Index": str(i),
2326
+ "Name": account_name,
2327
+ "Status": "[bold green]Active[/bold green]" if is_active else "",
2328
+ "Encrypted": "[yellow]Yes[/yellow]" if is_encrypted else "No",
2329
+ "Address": address if address else "",
2330
+ "Has seed": has_seed,
2331
+ }
2332
+ account_data_list.append(row)
2333
+
2334
+ # Display accounts in a table
2335
+ print_table(
2336
+ title="Accounts",
2337
+ data=account_data_list,
2338
+ columns=["Index", "Name", "Status", "Encrypted", "Address", "Has seed"],
2339
+ )
2340
+
2341
+ # Show active account status
2342
+ if active_account:
2343
+ success(f"Active account: [bold]{active_account}[/bold]")
2344
+ else:
2345
+ warning("No active account selected")
2346
+
2347
+ # Instructions
2348
+ help_text = [
2349
+ "To switch accounts: [bold green underline]hippius account switch <account_name>[/bold green underline]",
2350
+ "To create a new account: [bold green underline]hippius account create --name <account_name>[/bold green underline]",
2351
+ ]
2352
+ print_panel("\n".join(help_text), title="Account Management")
2353
+
2354
+ return 0
2355
+
2356
+ except Exception as e:
2357
+ error(f"Error listing accounts: {e}")
2358
+ return 1
2359
+
2360
+
2361
+ def handle_account_switch(account_name: str) -> int:
2362
+ """Handle the account switch command"""
2363
+ try:
2364
+ # Check if account exists
2365
+ accounts = list_accounts()
2366
+ if account_name not in accounts:
2367
+ print(f"Error: Account '{account_name}' not found")
2368
+ print("Available accounts:")
2369
+ for account in accounts:
2370
+ print(f" {account}")
2371
+ return 1
2372
+
2373
+ # Set as active account
2374
+ set_active_account(account_name)
2375
+
2376
+ print(f"Switched to account: {account_name}")
2377
+
2378
+ # Show account address if possible
2379
+ try:
2380
+ address = get_account_address(account_name)
2381
+ print(f"Address: {address}")
2382
+ except Exception as e:
2383
+ # Check if encrypted
2384
+ config = load_config()
2385
+ account_config = (
2386
+ config.get("substrate", {}).get("accounts", {}).get(account_name, {})
2387
+ )
2388
+
2389
+ if account_config.get("encrypted", False):
2390
+ print("Address: Encrypted (password required to view)")
2391
+ else:
2392
+ print(f"Note: Unable to display address ({str(e)})")
2393
+
2394
+ return 0
2395
+
2396
+ except Exception as e:
2397
+ print(f"Error switching account: {e}")
2398
+ return 1
2399
+
2400
+
2401
+ def handle_account_login() -> int:
2402
+ """Handle the account login command - prompts for account details and creates an account"""
2403
+ try:
2404
+ # Display the login banner
2405
+ from hippius_sdk.cli_assets import LOGIN_ASSET
2406
+
2407
+ console.print(LOGIN_ASSET, style="bold cyan")
2408
+ console.print(
2409
+ "\n[bold blue]Welcome to Hippius![/bold blue] Let's set up your account.\n"
2410
+ )
2411
+
2412
+ # Create a style for prompts
2413
+ prompt_style = "bold green"
2414
+ input_style = "bold cyan"
2415
+
2416
+ # Prompt for account name with nice formatting
2417
+ console.print(
2418
+ "[bold]Step 1:[/bold] Choose a name for your account", style=prompt_style
2419
+ )
2420
+ console.print(
2421
+ "This name will be used to identify your account in the Hippius system.",
2422
+ style="dim",
2423
+ )
2424
+ console.print("Account name:", style=input_style, end=" ")
2425
+ name = input().strip()
2426
+
2427
+ if not name:
2428
+ error("[bold red]Account name cannot be empty[/bold red]")
2429
+ return 1
2430
+
2431
+ # Check if account already exists
2432
+ accounts = list_accounts()
2433
+ if name in accounts:
2434
+ warning(f"Account '[bold]{name}[/bold]' already exists")
2435
+ console.print(
2436
+ "Do you want to overwrite it? (y/n):", style=input_style, end=" "
2437
+ )
2438
+ confirm = input().strip().lower()
2439
+ if confirm != "y":
2440
+ info("Login cancelled")
2441
+ return 0
2442
+
2443
+ # Prompt for seed phrase with detailed explanation
2444
+ console.print(
2445
+ "\n[bold]Step 2:[/bold] Enter your seed phrase", style=prompt_style
2446
+ )
2447
+ console.print(
2448
+ "Your seed phrase gives access to your blockchain account and funds.",
2449
+ style="dim",
2450
+ )
2451
+ console.print(
2452
+ "[yellow]Important:[/yellow] Must be 12 or 24 words separated by spaces.",
2453
+ style="dim",
2454
+ )
2455
+ console.print("Seed phrase:", style=input_style, end=" ")
2456
+ seed_phrase = input().strip()
2457
+
2458
+ # Validate the seed phrase
2459
+ if not seed_phrase or len(seed_phrase.split()) not in [12, 24]:
2460
+ error(
2461
+ "[bold red]Invalid seed phrase[/bold red] - must be 12 or 24 words separated by spaces"
2462
+ )
2463
+ return 1
2464
+
2465
+ # Prompt for encryption with security explanation
2466
+ console.print("\n[bold]Step 3:[/bold] Secure your account", style=prompt_style)
2467
+ console.print(
2468
+ "Encrypting your seed phrase adds an extra layer of security.", style="dim"
2469
+ )
2470
+ console.print(
2471
+ "[bold yellow]Strongly recommended[/bold yellow] to protect your account.",
2472
+ style="dim",
2473
+ )
2474
+ console.print(
2475
+ "Encrypt seed phrase? [bold green](Y/n)[/bold green]:",
2476
+ style=input_style,
2477
+ end=" ",
2478
+ )
2479
+ encrypt_input = input().strip().lower()
2480
+ encrypt = encrypt_input == "y" or encrypt_input == "" or encrypt_input == "yes"
2481
+
2482
+ # Set up encryption if requested
2483
+ password = None
2484
+ if encrypt:
2485
+ console.print(
2486
+ "\n[bold]Step 4:[/bold] Set encryption password", style=prompt_style
2487
+ )
2488
+ console.print(
2489
+ "This password will be required whenever you use your account for blockchain operations.",
2490
+ style="dim",
2491
+ )
2492
+
2493
+ password = getpass.getpass("Enter a password: ")
2494
+ confirm = getpass.getpass("Confirm password: ")
2495
+
2496
+ if password != confirm:
2497
+ error("[bold red]Passwords do not match[/bold red]")
2498
+ return 1
2499
+
2500
+ if not password:
2501
+ error("[bold red]Password cannot be empty for encryption[/bold red]")
2502
+ return 1
2503
+
2504
+ # Initialize address variable
2505
+ address = None
2506
+
2507
+ # Create and store the account
2508
+ with console.status("[cyan]Setting up your account...[/cyan]", spinner="dots"):
2509
+ # First, directly modify the config to ensure account is created
2510
+ config = load_config()
2511
+
2512
+ # Ensure accounts structure exists
2513
+ if "substrate" not in config:
2514
+ config["substrate"] = {}
2515
+ if "accounts" not in config["substrate"]:
2516
+ config["substrate"]["accounts"] = {}
2517
+
2518
+ # Create keypair and get address from seed phrase
2519
+ from substrateinterface import Keypair
2520
+
2521
+ keypair = Keypair.create_from_mnemonic(seed_phrase)
2522
+ address = keypair.ss58_address
2523
+
2524
+ # Add the new account
2525
+ config["substrate"]["accounts"][name] = {
2526
+ "seed_phrase": seed_phrase,
2527
+ "seed_phrase_encoded": False,
2528
+ "seed_phrase_salt": None,
2529
+ "ss58_address": address,
2530
+ }
2531
+
2532
+ # Set as active account
2533
+ config["substrate"]["active_account"] = name
2534
+
2535
+ # Save the config first
2536
+ save_config(config)
2537
+
2538
+ # Now encrypt if requested
2539
+ if encrypt:
2540
+ encrypt_seed_phrase(seed_phrase, password, name)
2541
+
2542
+ time.sleep(0.5) # Small delay for visual feedback
2543
+
2544
+ # Success panel with account information
2545
+ account_info = [
2546
+ f"[bold]Account Name:[/bold] [bold magenta]{name}[/bold magenta]",
2547
+ f"[bold]Blockchain Address:[/bold] [bold cyan]{address}[/bold cyan]",
2548
+ "",
2549
+ "[bold green]✓ Login successful![/bold green]",
2550
+ "[bold green]✓ Account set as active[/bold green]",
2551
+ ]
2552
+
2553
+ if encrypt:
2554
+ account_info.append("[bold green]✓ Seed phrase encrypted[/bold green]")
2555
+ account_info.append("")
2556
+ account_info.append(
2557
+ "[dim]You'll need your password when using this account for blockchain operations.[/dim]"
2558
+ )
2559
+ else:
2560
+ account_info.append(
2561
+ "[bold yellow]⚠ Seed phrase not encrypted[/bold yellow]"
2562
+ )
2563
+ account_info.append("")
2564
+ account_info.append(
2565
+ "[dim]For better security, consider encrypting your seed phrase:[/dim]"
2566
+ )
2567
+ account_info.append(
2568
+ f"[dim] [bold green underline]hippius account encode --name {name}[/bold green underline][/dim]"
2569
+ )
2570
+
2571
+ # Add next steps
2572
+ account_info.append("")
2573
+ account_info.append("[bold blue]Next steps:[/bold blue]")
2574
+ account_info.append(
2575
+ "• [bold green underline]hippius credits[/bold green underline] - Check your account balance"
2576
+ )
2577
+ account_info.append(
2578
+ "• [bold green underline]hippius files[/bold green underline] - View your stored files"
2579
+ )
2580
+ account_info.append(
2581
+ "• [bold green underline]hippius store <file>[/bold green underline] - Upload a file to IPFS"
2582
+ )
2583
+
2584
+ print_panel(
2585
+ "\n".join(account_info), title="[bold green]Account Ready[/bold green]"
2586
+ )
2587
+ return 0
2588
+
2589
+ except Exception as e:
2590
+ error(f"[bold red]Error logging in:[/bold red] {e}")
2591
+ return 1
2592
+
2593
+
2594
+ def handle_account_delete(account_name: str) -> int:
2595
+ """Handle the account delete command"""
2596
+ try:
2597
+ # Check if account exists
2598
+ accounts = list_accounts()
2599
+ if account_name not in accounts:
2600
+ print(f"Error: Account '{account_name}' not found")
2601
+ return 1
2602
+
2603
+ # Confirm deletion
2604
+ print(f"Warning: You are about to delete account '{account_name}'")
2605
+ print("This action cannot be undone unless you have exported the account.")
2606
+ confirm = input("Delete this account? (y/n): ").strip().lower()
2607
+
2608
+ if confirm != "y":
2609
+ print("Deletion cancelled")
2610
+ return 0
2611
+
2612
+ # Delete the account
2613
+ delete_account(account_name)
2614
+
2615
+ print(f"Account '{account_name}' deleted successfully")
2616
+
2617
+ # If this was the active account, notify user
2618
+ active_account = get_active_account()
2619
+ if active_account == account_name:
2620
+ print("This was the active account. No account is currently active.")
2621
+
2622
+ # If there are other accounts, suggest one
2623
+ remaining_accounts = list_accounts()
2624
+ if remaining_accounts:
2625
+ print(
2626
+ f"You can switch to another account with: hippius account switch {remaining_accounts[0]}"
2627
+ )
2628
+
2629
+ return 0
2630
+
2631
+ except Exception as e:
2632
+ print(f"Error deleting account: {e}")
2633
+ return 1
2634
+
2635
+
2636
+ async def handle_account_balance(
2637
+ client: HippiusClient, account_address: Optional[str] = None
2638
+ ) -> int:
2639
+ """Handle the account balance command"""
2640
+ info("Checking account balance...")
2641
+ # Get the account address we're querying
2642
+ if account_address is None:
2643
+ # If no address provided, first try to get from keypair (if available)
2644
+ if (
2645
+ hasattr(client.substrate_client, "_keypair")
2646
+ and client.substrate_client._keypair is not None
2647
+ ):
2648
+ account_address = client.substrate_client._keypair.ss58_address
2649
+ else:
2650
+ # Try to get the default address
2651
+ default_address = get_default_address()
2652
+ if default_address:
2653
+ account_address = default_address
2654
+ else:
2655
+ has_default = get_default_address() is not None
2656
+
2657
+ error("No account address provided, and client has no keypair.")
2658
+
2659
+ if has_default:
2660
+ warning(
2661
+ "Please provide an account address with '--account_address' or the default address may be invalid."
2662
+ )
2663
+ else:
2664
+ warning(
2665
+ "Please provide an account address with '--account_address' or set a default with:"
2666
+ )
2667
+ log(
2668
+ " hippius address set-default <your_account_address>",
2669
+ style="bold blue",
2670
+ )
2671
+
2672
+ return 1
2673
+
2674
+ # Get the account balance
2675
+ balance = await client.substrate_client.get_account_balance(account_address)
2676
+
2677
+ # Create a panel with balance information
2678
+ balance_info = [
2679
+ f"Account address: [bold cyan]{account_address}[/bold cyan]",
2680
+ f"Free balance: [bold green]{balance['free']:.6f}[/bold green]",
2681
+ f"Reserved balance: [bold yellow]{balance['reserved']:.6f}[/bold yellow]",
2682
+ f"Frozen balance: [bold blue]{balance['frozen']:.6f}[/bold blue]",
2683
+ f"Total balance: [bold]{balance['total']:.6f}[/bold]",
2684
+ ]
2685
+
2686
+ # Add the raw values in a more subtle format
2687
+ balance_info.append("\n[dim]Raw values:[/dim]")
2688
+ balance_info.append(f"[dim]Free: {balance['raw']['free']:,}[/dim]")
2689
+ balance_info.append(f"[dim]Reserved: {balance['raw']['reserved']:,}[/dim]")
2690
+ balance_info.append(f"[dim]Frozen: {balance['raw']['frozen']:,}[/dim]")
2691
+
2692
+ print_panel("\n".join(balance_info), title="Account Balance")
2693
+
2694
+
2695
+ #
2696
+ # Default Address Handlers
2697
+ #
2698
+
2699
+
2700
+ def handle_default_address_set(address: str) -> int:
2701
+ """Handle the address set-default command"""
2702
+ try:
2703
+ # Validate address format
2704
+ if not address.startswith("5"):
2705
+ warning("The address does not appear to be a valid Substrate address")
2706
+ log("Substrate addresses typically start with '5'", style="yellow")
2707
+ confirm = input("Continue anyway? (y/n): ").strip().lower()
2708
+ if confirm != "y":
2709
+ return 1
2710
+
2711
+ # Update config
2712
+ config = load_config()
2713
+
2714
+ if "substrate" not in config:
2715
+ config["substrate"] = {}
2716
+
2717
+ config["substrate"]["default_address"] = address
2718
+
2719
+ # Save config
2720
+ save_config(config)
2721
+
2722
+ # Create success information
2723
+ details = [
2724
+ f"Default address set to: [bold cyan]{address}[/bold cyan]",
2725
+ "\nThis address will be used for read-only operations when no account is specified.",
2726
+ ]
2727
+
2728
+ print_panel("\n".join(details), title="Default Address Updated")
2729
+
2730
+ return 0
2731
+
2732
+ except Exception as e:
2733
+ error(f"Error setting default address: {e}")
2734
+ return 1
2735
+
2736
+
2737
+ def handle_default_address_get() -> int:
2738
+ """Handle the address get-default command"""
2739
+ try:
2740
+ address = get_default_address()
2741
+
2742
+ if address:
2743
+ info(f"Default address: [bold cyan]{address}[/bold cyan]")
2744
+ else:
2745
+ warning("No default address set")
2746
+ log(
2747
+ "You can set one with: [bold]hippius address set-default <address>[/bold]"
2748
+ )
2749
+
2750
+ return 0
2751
+
2752
+ except Exception as e:
2753
+ error(f"Error getting default address: {e}")
2754
+ return 1
2755
+
2756
+
2757
+ def handle_default_address_clear() -> int:
2758
+ """Handle the address clear-default command"""
2759
+ try:
2760
+ config = load_config()
2761
+
2762
+ if "substrate" in config and "default_address" in config["substrate"]:
2763
+ del config["substrate"]["default_address"]
2764
+ save_config(config)
2765
+ success("Default address cleared")
2766
+ else:
2767
+ log("No default address was set", style="yellow")
2768
+
2769
+ return 0
2770
+
2771
+ except Exception as e:
2772
+ error(f"Error clearing default address: {e}")
2773
+ return 1