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