htcli 1.1.0__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.
Files changed (140) hide show
  1. htcli-1.1.0.dist-info/METADATA +509 -0
  2. htcli-1.1.0.dist-info/RECORD +140 -0
  3. htcli-1.1.0.dist-info/WHEEL +4 -0
  4. htcli-1.1.0.dist-info/entry_points.txt +2 -0
  5. htcli-1.1.0.dist-info/licenses/LICENSE +21 -0
  6. src/__init__.py +0 -0
  7. src/htcli/__init__.py +5 -0
  8. src/htcli/client/__init__.py +338 -0
  9. src/htcli/client/extrinsics/__init__.py +26 -0
  10. src/htcli/client/extrinsics/base.py +487 -0
  11. src/htcli/client/extrinsics/consensus.py +79 -0
  12. src/htcli/client/extrinsics/governance.py +714 -0
  13. src/htcli/client/extrinsics/identity.py +490 -0
  14. src/htcli/client/extrinsics/node.py +1054 -0
  15. src/htcli/client/extrinsics/overwatch.py +401 -0
  16. src/htcli/client/extrinsics/staking.py +1504 -0
  17. src/htcli/client/extrinsics/subnet.py +2218 -0
  18. src/htcli/client/extrinsics/validator.py +203 -0
  19. src/htcli/client/extrinsics/wallet.py +323 -0
  20. src/htcli/client/offchain/__init__.py +10 -0
  21. src/htcli/client/offchain/backup.py +385 -0
  22. src/htcli/client/offchain/config.py +541 -0
  23. src/htcli/client/offchain/wallet.py +839 -0
  24. src/htcli/client/rpc/__init__.py +20 -0
  25. src/htcli/client/rpc/chain.py +568 -0
  26. src/htcli/client/rpc/node.py +783 -0
  27. src/htcli/client/rpc/overwatch.py +680 -0
  28. src/htcli/client/rpc/staking.py +216 -0
  29. src/htcli/client/rpc/subnet.py +2104 -0
  30. src/htcli/client/rpc/wallet.py +912 -0
  31. src/htcli/commands/__init__.py +31 -0
  32. src/htcli/commands/chain/__init__.py +66 -0
  33. src/htcli/commands/chain/display.py +204 -0
  34. src/htcli/commands/chain/handlers.py +260 -0
  35. src/htcli/commands/config/__init__.py +158 -0
  36. src/htcli/commands/config/display.py +353 -0
  37. src/htcli/commands/config/handlers.py +347 -0
  38. src/htcli/commands/config/prompts.py +357 -0
  39. src/htcli/commands/consensus/__init__.py +61 -0
  40. src/htcli/commands/consensus/handlers.py +100 -0
  41. src/htcli/commands/governance/__init__.py +49 -0
  42. src/htcli/commands/governance/handlers.py +81 -0
  43. src/htcli/commands/node/__init__.py +304 -0
  44. src/htcli/commands/node/display.py +749 -0
  45. src/htcli/commands/node/error_handling.py +470 -0
  46. src/htcli/commands/node/handlers.py +844 -0
  47. src/htcli/commands/node/prompts.py +346 -0
  48. src/htcli/commands/overwatch/__init__.py +219 -0
  49. src/htcli/commands/overwatch/display.py +396 -0
  50. src/htcli/commands/overwatch/error_handling.py +276 -0
  51. src/htcli/commands/overwatch/handlers.py +443 -0
  52. src/htcli/commands/overwatch/prompts.py +359 -0
  53. src/htcli/commands/stake/__init__.py +736 -0
  54. src/htcli/commands/stake/display.py +1103 -0
  55. src/htcli/commands/stake/error_handling.py +425 -0
  56. src/htcli/commands/stake/handlers.py +1902 -0
  57. src/htcli/commands/stake/prompts.py +1080 -0
  58. src/htcli/commands/subnet/__init__.py +639 -0
  59. src/htcli/commands/subnet/display.py +801 -0
  60. src/htcli/commands/subnet/error_handling.py +524 -0
  61. src/htcli/commands/subnet/handlers.py +2855 -0
  62. src/htcli/commands/subnet/prompts.py +1225 -0
  63. src/htcli/commands/validator/__init__.py +192 -0
  64. src/htcli/commands/validator/display.py +54 -0
  65. src/htcli/commands/validator/handlers.py +340 -0
  66. src/htcli/commands/wallet/__init__.py +546 -0
  67. src/htcli/commands/wallet/display.py +806 -0
  68. src/htcli/commands/wallet/error_handling.py +210 -0
  69. src/htcli/commands/wallet/handlers.py +3040 -0
  70. src/htcli/commands/wallet/prompts.py +1518 -0
  71. src/htcli/config.py +184 -0
  72. src/htcli/dependencies.py +186 -0
  73. src/htcli/errors/__init__.py +63 -0
  74. src/htcli/errors/base.py +141 -0
  75. src/htcli/errors/display.py +20 -0
  76. src/htcli/errors/handlers.py +710 -0
  77. src/htcli/main.py +343 -0
  78. src/htcli/models/__init__.py +21 -0
  79. src/htcli/models/enums/enum_types.py +35 -0
  80. src/htcli/models/errors.py +103 -0
  81. src/htcli/models/requests/__init__.py +197 -0
  82. src/htcli/models/requests/config.py +70 -0
  83. src/htcli/models/requests/consensus.py +19 -0
  84. src/htcli/models/requests/governance.py +38 -0
  85. src/htcli/models/requests/identity.py +51 -0
  86. src/htcli/models/requests/key.py +22 -0
  87. src/htcli/models/requests/node.py +91 -0
  88. src/htcli/models/requests/overwatch.py +64 -0
  89. src/htcli/models/requests/staking.py +580 -0
  90. src/htcli/models/requests/subnet.py +195 -0
  91. src/htcli/models/requests/validator.py +139 -0
  92. src/htcli/models/requests/wallet.py +118 -0
  93. src/htcli/models/responses/__init__.py +147 -0
  94. src/htcli/models/responses/base.py +18 -0
  95. src/htcli/models/responses/chain.py +39 -0
  96. src/htcli/models/responses/config.py +58 -0
  97. src/htcli/models/responses/identity.py +102 -0
  98. src/htcli/models/responses/overwatch.py +51 -0
  99. src/htcli/models/responses/staking.py +502 -0
  100. src/htcli/models/responses/subnet.py +856 -0
  101. src/htcli/models/responses/wallet.py +185 -0
  102. src/htcli/ui/__init__.py +87 -0
  103. src/htcli/ui/colors.py +309 -0
  104. src/htcli/ui/components/__init__.py +60 -0
  105. src/htcli/ui/components/panels.py +174 -0
  106. src/htcli/ui/components/progress.py +166 -0
  107. src/htcli/ui/components/spinners.py +92 -0
  108. src/htcli/ui/components/tables.py +809 -0
  109. src/htcli/ui/components/trees.py +721 -0
  110. src/htcli/ui/display.py +336 -0
  111. src/htcli/ui/prompts.py +870 -0
  112. src/htcli/utils/__init__.py +76 -0
  113. src/htcli/utils/blockchain/__init__.py +75 -0
  114. src/htcli/utils/blockchain/formatting.py +368 -0
  115. src/htcli/utils/blockchain/patches.py +286 -0
  116. src/htcli/utils/blockchain/peer_id.py +186 -0
  117. src/htcli/utils/blockchain/staking.py +448 -0
  118. src/htcli/utils/blockchain/type_registry.py +1373 -0
  119. src/htcli/utils/blockchain/validation.py +179 -0
  120. src/htcli/utils/cache.py +613 -0
  121. src/htcli/utils/constants.py +38 -0
  122. src/htcli/utils/legacy/__init__.py +12 -0
  123. src/htcli/utils/legacy/colors.py +311 -0
  124. src/htcli/utils/legacy/crypto.py +1176 -0
  125. src/htcli/utils/legacy/formatting.py +452 -0
  126. src/htcli/utils/legacy/interactive.py +306 -0
  127. src/htcli/utils/legacy/subnet_manifest.py +265 -0
  128. src/htcli/utils/legacy/validation.py +488 -0
  129. src/htcli/utils/logging.py +183 -0
  130. src/htcli/utils/network/__init__.py +20 -0
  131. src/htcli/utils/network/subnet.py +344 -0
  132. src/htcli/utils/prompts.py +27 -0
  133. src/htcli/utils/scale_codec.py +155 -0
  134. src/htcli/utils/validation/__init__.py +57 -0
  135. src/htcli/utils/validation/prompt_validators.py +267 -0
  136. src/htcli/utils/wallet/__init__.py +65 -0
  137. src/htcli/utils/wallet/auth.py +151 -0
  138. src/htcli/utils/wallet/core.py +1069 -0
  139. src/htcli/utils/wallet/crypto.py +1615 -0
  140. src/htcli/utils/wallet/migration.py +159 -0
@@ -0,0 +1,1176 @@
1
+ """
2
+ Cryptographic utility functions for the Hypertensor CLI.
3
+ """
4
+
5
+ import base64
6
+ import hmac
7
+ import json
8
+ import secrets
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from cryptography.fernet import Fernet
14
+ from cryptography.hazmat.primitives import hashes
15
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
16
+ from substrateinterface import Keypair
17
+
18
+ from ..wallet.auth import get_unlock_password
19
+
20
+
21
+ def get_network_appropriate_key_type() -> str:
22
+ """
23
+ Get the appropriate key type based on current network configuration.
24
+
25
+ Returns:
26
+ The recommended key type for the current network
27
+ """
28
+ try:
29
+ from ..dependencies import get_config
30
+
31
+ config = get_config()
32
+
33
+ # If we're on mainnet or EVM-compatible network, use ECDSA for Bytes20 addresses
34
+ if config.network.network_type == "mainnet" or config.network.evm_compatible:
35
+ return config.wallet.mainnet_key_type
36
+ else:
37
+ return config.wallet.testnet_key_type
38
+
39
+ except Exception:
40
+ # Fallback to ECDSA if config is not available
41
+ return "ecdsa"
42
+
43
+
44
+ def validate_key_type_for_network(key_type: str) -> tuple[bool, str]:
45
+ """
46
+ Validate if a key type is appropriate for the current network.
47
+
48
+ Args:
49
+ key_type: The key type to validate
50
+
51
+ Returns:
52
+ Tuple of (is_valid, message)
53
+ """
54
+ try:
55
+ from ..dependencies import get_config
56
+
57
+ config = get_config()
58
+
59
+ # For mainnet/EVM networks, strongly recommend ECDSA
60
+ if config.network.network_type == "mainnet" or config.network.evm_compatible:
61
+ if key_type != "ecdsa":
62
+ return (
63
+ False,
64
+ f"For mainnet/EVM compatibility, ECDSA keys are required. You specified '{key_type}'.",
65
+ )
66
+
67
+ return True, ""
68
+
69
+ except Exception:
70
+ # If config unavailable, allow all types but warn
71
+ if key_type == "ecdsa":
72
+ return True, ""
73
+ else:
74
+ return (
75
+ True,
76
+ "Warning: ECDSA keys are recommended for mainnet compatibility.",
77
+ )
78
+
79
+
80
+ def detect_address_type(address: str) -> tuple[str, str, bool]:
81
+ """
82
+ Detect the type and format of an address.
83
+
84
+ Args:
85
+ address: The address to analyze
86
+
87
+ Returns:
88
+ Tuple of (address_type, format_name, is_evm_compatible)
89
+ """
90
+ if not address:
91
+ return "unknown", "Invalid", False
92
+
93
+ # EVM/ECDSA format (0x + 40 hex chars = 42 total)
94
+ if address.startswith("0x"):
95
+ if len(address) == 42:
96
+ return "ecdsa", "EVM/ECDSA (20-byte)", True
97
+ else:
98
+ return (
99
+ "invalid_evm",
100
+ f"Invalid EVM (expected 42 chars, got {len(address)})",
101
+ False,
102
+ )
103
+
104
+ # SS58/Substrate format (typically starts with 1-5, ~48 chars)
105
+ elif len(address) > 40 and address[0].isdigit():
106
+ return "ss58", "SS58/Substrate (32-byte)", False
107
+
108
+ # Short addresses or other formats
109
+ elif len(address) < 20:
110
+ return "unknown", "Too Short", False
111
+
112
+ else:
113
+ return "unknown", "Unknown Format", False
114
+
115
+
116
+ def format_address_display(
117
+ address: str, key_type: str = None, max_length: int = None
118
+ ) -> str:
119
+ """
120
+ Format address display with type information.
121
+
122
+ Args:
123
+ address: The address to format
124
+ key_type: Optional key type hint
125
+ max_length: Optional maximum length for truncation
126
+
127
+ Returns:
128
+ Formatted address string with type info
129
+ """
130
+ if not address or address == "N/A":
131
+ return "N/A"
132
+
133
+ # Apply truncation if requested
134
+ display_address = address
135
+ if max_length and len(address) > max_length:
136
+ display_address = address[:20] + "..." + address[-20:]
137
+
138
+ addr_type, format_name, is_compatible = detect_address_type(address)
139
+
140
+ if addr_type == "ecdsa":
141
+ return f"{display_address} [EVM/ECDSA]"
142
+ elif addr_type == "ss58":
143
+ return f"{display_address} [SS58/Substrate]"
144
+ else:
145
+ return f"{display_address} [{format_name}]"
146
+
147
+
148
+ def can_convert_to_ecdsa(address: str, key_type: str = None) -> tuple[bool, str]:
149
+ """
150
+ Check if an address can be converted to ECDSA format.
151
+
152
+ Args:
153
+ address: The address to check
154
+ key_type: The key type if known
155
+
156
+ Returns:
157
+ Tuple of (can_convert, explanation)
158
+ """
159
+ addr_type, format_name, is_compatible = detect_address_type(address)
160
+
161
+ if addr_type == "ecdsa":
162
+ return False, "Already in ECDSA format"
163
+ elif addr_type == "ss58":
164
+ return False, (
165
+ "Cannot convert SS58 addresses to ECDSA format. "
166
+ "SS58 addresses use different cryptographic keys (SR25519/Ed25519) "
167
+ "which are incompatible with ECDSA. You must generate a new ECDSA wallet."
168
+ )
169
+ else:
170
+ return False, f"Unknown address format: {format_name}"
171
+
172
+
173
+ def generate_password_hash(password: str, salt: bytes) -> bytes:
174
+ """
175
+ Generate a secure password hash using PBKDF2.
176
+
177
+ Args:
178
+ password: The password to hash
179
+ salt: Random salt for the hash
180
+
181
+ Returns:
182
+ The password hash
183
+ """
184
+ kdf = PBKDF2HMAC(
185
+ algorithm=hashes.SHA256(),
186
+ length=32,
187
+ salt=salt,
188
+ iterations=100000,
189
+ )
190
+ return kdf.derive(password.encode("utf-8"))
191
+
192
+
193
+ def verify_password(password: str, stored_hash: bytes, salt: bytes) -> bool:
194
+ """
195
+ Verify a password against a stored hash.
196
+
197
+ Args:
198
+ password: The password to verify
199
+ stored_hash: The stored password hash
200
+ salt: The salt used for the hash
201
+
202
+ Returns:
203
+ True if password matches, False otherwise
204
+ """
205
+ try:
206
+ kdf = PBKDF2HMAC(
207
+ algorithm=hashes.SHA256(),
208
+ length=32,
209
+ salt=salt,
210
+ iterations=100000,
211
+ )
212
+ computed_hash = kdf.derive(password.encode("utf-8"))
213
+ return hmac.compare_digest(computed_hash, stored_hash)
214
+ except Exception:
215
+ return False
216
+
217
+
218
+ def encrypt_private_key(
219
+ private_key: bytes, password: str
220
+ ) -> tuple[bytes, bytes, bytes, bytes]:
221
+ """
222
+ Encrypt private key with password and generate validation hash.
223
+
224
+ Args:
225
+ private_key: The private key to encrypt
226
+ password: The password for encryption
227
+
228
+ Returns:
229
+ Tuple of (encrypted_key, password_hash, password_salt, encryption_salt)
230
+ """
231
+ # Generate salt for password hashing
232
+ password_salt = secrets.token_bytes(16)
233
+
234
+ # Generate password hash for validation
235
+ password_hash = generate_password_hash(password, password_salt)
236
+
237
+ # Generate encryption key from password
238
+ encryption_salt = secrets.token_bytes(16)
239
+ encryption_key = generate_password_hash(password, encryption_salt)
240
+
241
+ # Encrypt private key
242
+ cipher = Fernet(base64.urlsafe_b64encode(encryption_key))
243
+ encrypted_key = cipher.encrypt(private_key)
244
+
245
+ return encrypted_key, password_hash, password_salt, encryption_salt
246
+
247
+
248
+ def decrypt_private_key(
249
+ encrypted_key: bytes,
250
+ password: str,
251
+ password_hash: bytes,
252
+ password_salt: bytes,
253
+ encryption_salt: bytes,
254
+ ) -> bytes:
255
+ """
256
+ Decrypt private key and validate password.
257
+
258
+ Args:
259
+ encrypted_key: The encrypted private key
260
+ password: The password for decryption
261
+ password_hash: The stored password hash for validation
262
+ password_salt: The salt used for password hashing
263
+ encryption_salt: The salt used for encryption key generation
264
+
265
+ Returns:
266
+ The decrypted private key
267
+
268
+ Raises:
269
+ ValueError: If password is incorrect
270
+ """
271
+ # Verify password first
272
+ if not verify_password(password, password_hash, password_salt):
273
+ raise ValueError("Invalid password")
274
+
275
+ # Generate encryption key from password
276
+ encryption_key = generate_password_hash(password, encryption_salt)
277
+
278
+ # Decrypt private key
279
+ cipher = Fernet(base64.urlsafe_b64encode(encryption_key))
280
+ return cipher.decrypt(encrypted_key)
281
+
282
+
283
+ def get_wallet_with_retry(name: str, max_attempts: int = 3) -> tuple[Keypair, dict]:
284
+ """
285
+ Get wallet with password retry attempts.
286
+
287
+ Args:
288
+ name: Wallet name
289
+ max_attempts: Maximum password attempts
290
+
291
+ Returns:
292
+ Tuple of (keypair, wallet_info)
293
+
294
+ Raises:
295
+ Exception: If all attempts fail
296
+ """
297
+ wallet_info = get_wallet_info_by_name(name)
298
+
299
+ if not wallet_info.get("is_encrypted", True):
300
+ # Unencrypted wallet, load directly
301
+ return load_keypair(name), wallet_info
302
+
303
+ attempts = 0
304
+ while attempts < max_attempts:
305
+ try:
306
+ attempts += 1
307
+ remaining = max_attempts - attempts + 1
308
+
309
+ password = get_unlock_password(
310
+ name,
311
+ prompt_message=f"Enter password for wallet '{name}'",
312
+ max_attempts=1, # Single attempt per call
313
+ )
314
+
315
+ keypair = load_keypair(name, password)
316
+ return keypair, wallet_info
317
+
318
+ except ValueError as e:
319
+ if "Invalid password" in str(e):
320
+ if remaining > 1:
321
+ print(f"❌ Invalid password. {remaining - 1} attempts remaining.")
322
+ else:
323
+ raise Exception(
324
+ f"Failed to unlock wallet '{name}' after {max_attempts} attempts"
325
+ ) from e
326
+ else:
327
+ raise
328
+ except Exception as e:
329
+ if attempts < max_attempts:
330
+ print(f"❌ Error: {str(e)}. {remaining - 1} attempts remaining.")
331
+ else:
332
+ raise Exception(f"Failed to load wallet '{name}': {str(e)}") from e
333
+
334
+ raise Exception(f"Failed to unlock wallet '{name}' after {max_attempts} attempts")
335
+
336
+
337
+ @dataclass
338
+ class KeypairInfo:
339
+ """Information about a keypair."""
340
+
341
+ name: str
342
+ key_type: str
343
+ public_key: str
344
+ ss58_address: str
345
+ mnemonic: Optional[str] = None # Recovery phrase
346
+ owner_address: Optional[str] = None # For hotkeys
347
+
348
+
349
+ def generate_coldkey_pair(
350
+ name: str, key_type: str = "ecdsa", password: Optional[str] = None
351
+ ) -> KeypairInfo:
352
+ """Generate a new coldkey pair."""
353
+ try:
354
+ # Generate random keypair
355
+ if key_type == "ecdsa":
356
+ mnemonic = Keypair.generate_mnemonic()
357
+ keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
358
+ elif key_type == "sr25519":
359
+ mnemonic = Keypair.generate_mnemonic()
360
+ keypair = Keypair.create_from_uri(mnemonic)
361
+ elif key_type == "ed25519":
362
+ mnemonic = Keypair.generate_mnemonic()
363
+ keypair = Keypair.create_from_uri(
364
+ mnemonic, crypto_type=0
365
+ ) # 0 for ed25519, 1 for sr25519
366
+ else:
367
+ raise ValueError(f"Unsupported key type: {key_type}")
368
+
369
+ # Create keypair info
370
+ keypair_info = KeypairInfo(
371
+ name=name,
372
+ key_type=key_type,
373
+ public_key=keypair.public_key.hex(),
374
+ ss58_address=keypair.ss58_address,
375
+ mnemonic=mnemonic, # Include the recovery phrase
376
+ owner_address=None, # Coldkeys don't have owners
377
+ )
378
+
379
+ # Use the password directly from the CLI function
380
+ # The CLI function already handles the password prompting
381
+ save_coldkey(name, keypair, password)
382
+
383
+ return keypair_info
384
+
385
+ except Exception as e:
386
+ raise Exception(f"Failed to generate coldkey: {str(e)}") from e
387
+
388
+
389
+ def generate_hotkey_pair(
390
+ name: str,
391
+ owner_address: str,
392
+ key_type: str = "ecdsa",
393
+ password: Optional[str] = None,
394
+ ) -> KeypairInfo:
395
+ """Generate a new hotkey pair owned by a coldkey."""
396
+ try:
397
+ # Generate random keypair
398
+ if key_type == "ecdsa":
399
+ mnemonic = Keypair.generate_mnemonic()
400
+ keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
401
+ elif key_type == "sr25519":
402
+ mnemonic = Keypair.generate_mnemonic()
403
+ keypair = Keypair.create_from_uri(mnemonic)
404
+ elif key_type == "ed25519":
405
+ mnemonic = Keypair.generate_mnemonic()
406
+ keypair = Keypair.create_from_uri(
407
+ mnemonic, crypto_type=0
408
+ ) # 0 for ed25519, 1 for sr25519
409
+ else:
410
+ raise ValueError(f"Unsupported key type: {key_type}")
411
+
412
+ # Create keypair info
413
+ keypair_info = KeypairInfo(
414
+ name=name,
415
+ key_type=key_type,
416
+ public_key=keypair.public_key.hex(),
417
+ ss58_address=keypair.ss58_address,
418
+ mnemonic=mnemonic, # Include the recovery phrase
419
+ owner_address=owner_address, # Hotkeys have owners
420
+ )
421
+
422
+ # Use the password directly from the CLI function
423
+ # The CLI function already handles the password prompting
424
+ save_hotkey(name, keypair, owner_address, password)
425
+
426
+ return keypair_info
427
+
428
+ except Exception as e:
429
+ raise Exception(f"Failed to generate hotkey: {str(e)}") from e
430
+
431
+
432
+ def import_keypair(
433
+ name: str,
434
+ private_key: str,
435
+ key_type: str = "ecdsa",
436
+ password: Optional[str] = None,
437
+ ) -> KeypairInfo:
438
+ """Import a keypair from private key."""
439
+ try:
440
+ # Remove 0x prefix if present
441
+ if private_key.startswith("0x"):
442
+ private_key = private_key[2:]
443
+
444
+ # Validate private key length based on key type
445
+ if key_type == "ecdsa":
446
+ if len(private_key) != 64:
447
+ raise ValueError("ECDSA private key must be 64 characters (32 bytes)")
448
+ else:
449
+ if len(private_key) != 64:
450
+ raise ValueError("Private key must be 64 characters (32 bytes)")
451
+
452
+ # Create keypair from private key
453
+ if key_type == "ecdsa":
454
+ keypair = Keypair.create_from_private_key(private_key, crypto_type=2)
455
+ elif key_type == "sr25519":
456
+ keypair = Keypair.create_from_private_key(private_key, ss58_format=42)
457
+ elif key_type == "ed25519":
458
+ keypair = Keypair.create_from_private_key(
459
+ private_key, crypto_type=0, ss58_format=42
460
+ )
461
+ else:
462
+ raise ValueError(f"Unsupported key type: {key_type}")
463
+
464
+ # Create keypair info
465
+ keypair_info = KeypairInfo(
466
+ name=name,
467
+ key_type=key_type,
468
+ public_key=keypair.public_key.hex(),
469
+ ss58_address=keypair.ss58_address,
470
+ mnemonic=None, # No mnemonic when importing from private key
471
+ owner_address=None, # This is for coldkeys
472
+ )
473
+
474
+ # Save the keypair
475
+ save_coldkey(name, keypair, password)
476
+
477
+ return keypair_info
478
+
479
+ except Exception as e:
480
+ raise Exception(f"Failed to import keypair: {str(e)}") from e
481
+
482
+
483
+ # Preseeded wallet private keys for development/testing
484
+ PRESEEDED_WALLETS = {
485
+ "alith": "0x5fb92d6e98884f76de468fa3f6278f8807c48bebc13595d45af5bdc4da702133",
486
+ "baltathar": "0x8075991ce870b93a8870eca0c0f91913d12f47948ca0fd25b49c6fa7cdbeee8b",
487
+ "charleth": "0x0b6e18cafb6ed99687ec547bd28139cafdd2bffe70e6b688025de6b445aa5c5b",
488
+ "dorothy": "0x39539ab1876910bbf3a223d84a29e28f1cb4e2e456503e7e91ed39b2e7223d68",
489
+ "ethan": "0x7dce9bc8babb68fec1409be38c8e1a52650206a7ed90ff956ae8a6d15eeaaef4",
490
+ "faith": "0xb9d2ea9a615f3165812e8d44de0d24da9bbd164b65c4f0573e1ce2c8dbd9c8df",
491
+ }
492
+
493
+
494
+ def get_preseeded_wallet_info():
495
+ """Get information about available preseeded wallets."""
496
+ return {
497
+ "alith": {
498
+ "name": "Alith",
499
+ "private_key": PRESEEDED_WALLETS["alith"],
500
+ "address": "0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac",
501
+ "description": "Default development account with pre-funded balance",
502
+ },
503
+ "baltathar": {
504
+ "name": "Baltathar",
505
+ "private_key": PRESEEDED_WALLETS["baltathar"],
506
+ "address": "0x3Cd0A705a2DC65e5b1E1205896BaA2be8A07c6e0",
507
+ "description": "Second development account with pre-funded balance",
508
+ },
509
+ "charleth": {
510
+ "name": "Charleth",
511
+ "private_key": PRESEEDED_WALLETS["charleth"],
512
+ "address": "0x798d4Ba9baf0064Ec19eB4F0a1a45785ae9D6DFc",
513
+ "description": "Third development account",
514
+ },
515
+ "dorothy": {
516
+ "name": "Dorothy",
517
+ "private_key": PRESEEDED_WALLETS["dorothy"],
518
+ "address": "0x773539d4Ac0e786233D90A233654ccEE26a613D9",
519
+ "description": "Fourth development account",
520
+ },
521
+ "ethan": {
522
+ "name": "Ethan",
523
+ "private_key": PRESEEDED_WALLETS["ethan"],
524
+ "address": "0xFf64d3F6efE2317EE2807d223a0Bdc4c0c49dfDB",
525
+ "description": "Fifth development account",
526
+ },
527
+ "faith": {
528
+ "name": "Faith",
529
+ "private_key": PRESEEDED_WALLETS["faith"],
530
+ "address": "0xC0F0f4ab324C46e55D02D0033343B4Be8A55532d",
531
+ "description": "Sixth development account",
532
+ },
533
+ }
534
+
535
+
536
+ def import_preseeded_wallet(
537
+ preseeded_name: str,
538
+ wallet_name: Optional[str] = None,
539
+ password: Optional[str] = None,
540
+ ) -> KeypairInfo:
541
+ """Import a preseeded development wallet (Alith, Baltathar, etc.)."""
542
+ preseeded_name = preseeded_name.lower()
543
+
544
+ if preseeded_name not in PRESEEDED_WALLETS:
545
+ available = ", ".join(PRESEEDED_WALLETS.keys())
546
+ raise ValueError(
547
+ f"Unknown preseeded wallet '{preseeded_name}'. Available: {available}"
548
+ )
549
+
550
+ private_key = PRESEEDED_WALLETS[preseeded_name]
551
+
552
+ # Use preseeded name as wallet name if not provided
553
+ if wallet_name is None:
554
+ wallet_name = preseeded_name
555
+
556
+ # Import using the existing private key import function
557
+ return import_keypair(
558
+ name=wallet_name,
559
+ private_key=private_key,
560
+ key_type="ecdsa", # All preseeded wallets are ECDSA
561
+ password=password,
562
+ )
563
+
564
+
565
+ def import_keypair_from_mnemonic(
566
+ name: str,
567
+ mnemonic: str,
568
+ key_type: str = "ecdsa",
569
+ password: Optional[str] = None,
570
+ ) -> KeypairInfo:
571
+ """Import an existing keypair from mnemonic phrase."""
572
+ try:
573
+ # Import keypair from mnemonic
574
+ if key_type == "ecdsa":
575
+ keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
576
+ elif key_type == "sr25519":
577
+ keypair = Keypair.create_from_mnemonic(
578
+ mnemonic, ss58_format=42, crypto_type=1
579
+ )
580
+ elif key_type == "ed25519":
581
+ keypair = Keypair.create_from_mnemonic(
582
+ mnemonic, ss58_format=42, crypto_type=0
583
+ )
584
+ else:
585
+ raise ValueError(f"Unsupported key type: {key_type}")
586
+
587
+ # Create keypair info
588
+ keypair_info = KeypairInfo(
589
+ name=name,
590
+ key_type=key_type,
591
+ public_key=keypair.public_key.hex(),
592
+ ss58_address=keypair.ss58_address,
593
+ owner_address=None, # Coldkeys don't have owners
594
+ )
595
+
596
+ # Use provided password or None (no prompting)
597
+ save_keypair(name, keypair, password)
598
+
599
+ return keypair_info
600
+
601
+ except Exception as e:
602
+ raise Exception(f"Failed to import keypair from mnemonic: {str(e)}") from e
603
+
604
+
605
+ def import_hotkey_from_private_key(
606
+ name: str,
607
+ private_key: str,
608
+ owner_address: str,
609
+ key_type: str = "ecdsa",
610
+ password: Optional[str] = None,
611
+ ) -> KeypairInfo:
612
+ """Import an existing hotkey from private key."""
613
+ try:
614
+ # Import keypair
615
+ if key_type == "ecdsa":
616
+ keypair = Keypair.create_from_private_key(private_key, crypto_type=2)
617
+ elif key_type == "sr25519":
618
+ keypair = Keypair.create_from_private_key(private_key)
619
+ elif key_type == "ed25519":
620
+ keypair = Keypair.create_from_private_key(
621
+ private_key, crypto_type=0
622
+ ) # 0 for ed25519, 1 for sr25519
623
+ else:
624
+ raise ValueError(f"Unsupported key type: {key_type}")
625
+
626
+ # Create keypair info
627
+ keypair_info = KeypairInfo(
628
+ name=name,
629
+ key_type=key_type,
630
+ public_key=keypair.public_key.hex(),
631
+ ss58_address=keypair.ss58_address,
632
+ owner_address=owner_address, # Hotkeys have owners
633
+ )
634
+
635
+ # Use provided password or None (no prompting)
636
+ save_hotkey(name, keypair, owner_address, password)
637
+
638
+ return keypair_info
639
+
640
+ except Exception as e:
641
+ raise Exception(f"Failed to import hotkey from private key: {str(e)}") from e
642
+
643
+
644
+ def import_hotkey_from_mnemonic(
645
+ name: str,
646
+ mnemonic: str,
647
+ owner_address: str,
648
+ key_type: str = "ecdsa",
649
+ password: Optional[str] = None,
650
+ ) -> KeypairInfo:
651
+ """Import an existing hotkey from mnemonic phrase."""
652
+ try:
653
+ # Import keypair from mnemonic
654
+ if key_type == "ecdsa":
655
+ keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
656
+ elif key_type == "sr25519":
657
+ keypair = Keypair.create_from_mnemonic(
658
+ mnemonic, ss58_format=42, crypto_type=1
659
+ )
660
+ elif key_type == "ed25519":
661
+ keypair = Keypair.create_from_mnemonic(
662
+ mnemonic, ss58_format=42, crypto_type=0
663
+ )
664
+ else:
665
+ raise ValueError(f"Unsupported key type: {key_type}")
666
+
667
+ # Create keypair info
668
+ keypair_info = KeypairInfo(
669
+ name=name,
670
+ key_type=key_type,
671
+ public_key=keypair.public_key.hex(),
672
+ ss58_address=keypair.ss58_address,
673
+ owner_address=owner_address, # Hotkeys have owners
674
+ )
675
+
676
+ # Use provided password or None (no prompting)
677
+ save_hotkey(name, keypair, owner_address, password)
678
+
679
+ return keypair_info
680
+
681
+ except Exception as e:
682
+ raise Exception(f"Failed to import hotkey from mnemonic: {str(e)}") from e
683
+
684
+
685
+ def save_coldkey(name: str, keypair: Keypair, password: Optional[str]):
686
+ """Save a coldkey to disk with encryption."""
687
+ try:
688
+ # Create wallet directory
689
+ wallet_dir = Path.home() / ".htcli" / "wallets"
690
+ wallet_dir.mkdir(parents=True, exist_ok=True)
691
+
692
+ if password is not None:
693
+ # Encrypt private key with password and generate validation hash
694
+ encrypted_key, password_hash, password_salt, encryption_salt = (
695
+ encrypt_private_key(keypair.private_key, password)
696
+ )
697
+
698
+ # Create wallet data with encryption
699
+ wallet_data = {
700
+ "name": name,
701
+ "key_type": (
702
+ "ecdsa"
703
+ if keypair.crypto_type == 2
704
+ else ("sr25519" if keypair.crypto_type == 1 else "ed25519")
705
+ ),
706
+ "public_key": keypair.public_key.hex(),
707
+ "ss58_address": keypair.ss58_address,
708
+ "encrypted_private_key": base64.b64encode(encrypted_key).decode(),
709
+ "password_hash": base64.b64encode(password_hash).decode(),
710
+ "password_salt": base64.b64encode(password_salt).decode(),
711
+ "encryption_salt": base64.b64encode(encryption_salt).decode(),
712
+ "is_hotkey": False, # This is a coldkey
713
+ "owner_address": None, # Coldkeys don't have owners
714
+ "is_encrypted": True,
715
+ }
716
+ else:
717
+ # Store private key without encryption (less secure but user's choice)
718
+ private_key_bytes = keypair.private_key
719
+
720
+ # Create wallet data without encryption
721
+ wallet_data = {
722
+ "name": name,
723
+ "key_type": (
724
+ "ecdsa"
725
+ if keypair.crypto_type == 2
726
+ else ("sr25519" if keypair.crypto_type == 1 else "ed25519")
727
+ ),
728
+ "public_key": keypair.public_key.hex(),
729
+ "ss58_address": keypair.ss58_address,
730
+ "private_key": private_key_bytes.hex(), # Store unencrypted
731
+ "is_hotkey": False, # This is a coldkey
732
+ "owner_address": None, # Coldkeys don't have owners
733
+ "is_encrypted": False,
734
+ }
735
+
736
+ # Save to file
737
+ wallet_file = wallet_dir / f"{name}.json"
738
+ with open(wallet_file, "w") as f:
739
+ json.dump(wallet_data, f, indent=2)
740
+
741
+ # Set secure permissions
742
+ wallet_file.chmod(0o600)
743
+
744
+ except Exception as e:
745
+ raise Exception(f"Failed to save coldkey: {str(e)}") from e
746
+
747
+
748
+ def save_hotkey(
749
+ name: str, keypair: Keypair, owner_address: str, password: Optional[str]
750
+ ):
751
+ """Save a hotkey to disk with encryption."""
752
+ try:
753
+ # Create wallet directory
754
+ wallet_dir = Path.home() / ".htcli" / "wallets"
755
+ wallet_dir.mkdir(parents=True, exist_ok=True)
756
+
757
+ if password is not None:
758
+ # Encrypt private key with password and generate validation hash
759
+ encrypted_key, password_hash, password_salt, encryption_salt = (
760
+ encrypt_private_key(keypair.private_key, password)
761
+ )
762
+
763
+ # Create wallet data with encryption
764
+ wallet_data = {
765
+ "name": name,
766
+ "key_type": (
767
+ "ecdsa"
768
+ if keypair.crypto_type == 2
769
+ else ("sr25519" if keypair.crypto_type == 1 else "ed25519")
770
+ ),
771
+ "public_key": keypair.public_key.hex(),
772
+ "ss58_address": keypair.ss58_address,
773
+ "encrypted_private_key": base64.b64encode(encrypted_key).decode(),
774
+ "password_hash": base64.b64encode(password_hash).decode(),
775
+ "password_salt": base64.b64encode(password_salt).decode(),
776
+ "encryption_salt": base64.b64encode(encryption_salt).decode(),
777
+ "is_hotkey": True, # This is a hotkey
778
+ "owner_address": owner_address, # Hotkeys have owners
779
+ "is_encrypted": True,
780
+ }
781
+ else:
782
+ # Store private key without encryption (less secure but user's choice)
783
+ private_key_bytes = keypair.private_key
784
+
785
+ # Create wallet data without encryption
786
+ wallet_data = {
787
+ "name": name,
788
+ "key_type": (
789
+ "ecdsa"
790
+ if keypair.crypto_type == 2
791
+ else ("sr25519" if keypair.crypto_type == 1 else "ed25519")
792
+ ),
793
+ "public_key": keypair.public_key.hex(),
794
+ "ss58_address": keypair.ss58_address,
795
+ "private_key": private_key_bytes.hex(), # Store unencrypted
796
+ "is_hotkey": True, # This is a hotkey
797
+ "owner_address": owner_address, # Hotkeys have owners
798
+ "is_encrypted": False,
799
+ }
800
+
801
+ # Save to file
802
+ wallet_file = wallet_dir / f"{name}.json"
803
+ with open(wallet_file, "w") as f:
804
+ json.dump(wallet_data, f, indent=2)
805
+
806
+ # Set secure permissions
807
+ wallet_file.chmod(0o600)
808
+
809
+ except Exception as e:
810
+ raise Exception(f"Failed to save hotkey: {str(e)}") from e
811
+
812
+
813
+ def save_keypair(name: str, keypair: Keypair, password: str):
814
+ """Save a keypair to disk with encryption (legacy function)."""
815
+ save_coldkey(name, keypair, password)
816
+
817
+
818
+ def load_keypair(name: str, password: Optional[str] = None) -> Keypair:
819
+ """Load a keypair from disk."""
820
+ try:
821
+ wallet_dir = Path.home() / ".htcli" / "wallets"
822
+ keypair_file = wallet_dir / f"{name}.json"
823
+
824
+ if not keypair_file.exists():
825
+ raise FileNotFoundError(f"Keypair '{name}' not found")
826
+
827
+ with open(keypair_file) as f:
828
+ keypair_data = json.load(f)
829
+
830
+ # Check if the key is encrypted
831
+ is_encrypted = keypair_data.get(
832
+ "is_encrypted", True
833
+ ) # Default to True for backward compatibility
834
+
835
+ if is_encrypted:
836
+ # Get password for decryption (no length validation for existing wallets)
837
+ decrypt_password = password or get_unlock_password(
838
+ name,
839
+ prompt_message="Enter password to unlock this keypair",
840
+ )
841
+
842
+ # Handle new encryption format (with password hash validation)
843
+ if "password_hash" in keypair_data and "password_salt" in keypair_data:
844
+ # New format with password hash validation
845
+ encrypted_private_key = base64.b64decode(
846
+ keypair_data["encrypted_private_key"]
847
+ )
848
+ password_hash = base64.b64decode(keypair_data["password_hash"])
849
+ password_salt = base64.b64decode(keypair_data["password_salt"])
850
+ encryption_salt = base64.b64decode(keypair_data["encryption_salt"])
851
+
852
+ private_key_bytes = decrypt_private_key(
853
+ encrypted_private_key,
854
+ decrypt_password,
855
+ password_hash,
856
+ password_salt,
857
+ encryption_salt,
858
+ )
859
+ else:
860
+ # Legacy format (backward compatibility)
861
+ salt = base64.b64decode(keypair_data["salt"])
862
+ encrypted_private_key = base64.b64decode(
863
+ keypair_data["encrypted_private_key"]
864
+ )
865
+
866
+ # Try to decrypt with legacy method
867
+ try:
868
+ cipher = Fernet(salt)
869
+ private_key_bytes = cipher.decrypt(encrypted_private_key)
870
+ except Exception as e:
871
+ raise ValueError("Invalid password or corrupted wallet file") from e
872
+ else:
873
+ # Key is not encrypted, load directly
874
+ if "private_key" in keypair_data:
875
+ private_key_bytes = bytes.fromhex(keypair_data["private_key"])
876
+ else:
877
+ raise ValueError("Key file is corrupted: missing private key")
878
+
879
+ # Create keypair based on key type
880
+ # Match mesh-template pattern - no ss58_format for ECDSA
881
+ key_type = keypair_data["key_type"].lower()
882
+ if key_type == "sr25519":
883
+ keypair = Keypair.create_from_private_key(
884
+ private_key_bytes.hex()
885
+ )
886
+ elif key_type == "ecdsa":
887
+ # For ECDSA keys, use crypto_type=2 (match mesh-template)
888
+ keypair = Keypair.create_from_private_key(
889
+ private_key_bytes.hex(), crypto_type=2
890
+ )
891
+ else: # ed25519
892
+ keypair = Keypair.create_from_private_key(
893
+ private_key_bytes.hex(), crypto_type=0
894
+ )
895
+
896
+ return keypair
897
+
898
+ except ValueError as e:
899
+ # Re-raise ValueError directly for password validation
900
+ raise e
901
+ except Exception as e:
902
+ raise Exception(f"Failed to load keypair: {str(e)}") from e
903
+
904
+
905
+ def list_keys() -> list[dict]:
906
+ """List all available keys."""
907
+ try:
908
+ wallet_dir = Path.home() / ".htcli" / "wallets"
909
+ if not wallet_dir.exists():
910
+ return []
911
+
912
+ keys = []
913
+ for keypair_file in wallet_dir.glob("*.json"):
914
+ try:
915
+ with open(keypair_file) as f:
916
+ keypair_data = json.load(f)
917
+
918
+ # Return as dictionary for compatibility with wallet commands
919
+ key_info = {
920
+ "name": keypair_data["name"],
921
+ "key_type": keypair_data["key_type"],
922
+ "public_key": keypair_data["public_key"],
923
+ "ss58_address": keypair_data["ss58_address"],
924
+ "address": keypair_data[
925
+ "ss58_address"
926
+ ], # Alias for ownership utils
927
+ # Add hotkey/coldkey information
928
+ "is_hotkey": keypair_data.get("is_hotkey", False),
929
+ "owner_address": keypair_data.get("owner_address", None),
930
+ # Add encryption status
931
+ "is_encrypted": keypair_data.get("is_encrypted", True),
932
+ }
933
+ keys.append(key_info)
934
+ except Exception:
935
+ # Skip corrupted files
936
+ continue
937
+
938
+ return keys
939
+
940
+ except Exception as e:
941
+ raise Exception(f"Failed to list keys: {str(e)}") from e
942
+
943
+
944
+ def get_wallet_info_by_name(name: str) -> dict:
945
+ """Get wallet information by name without loading the private key."""
946
+ try:
947
+ wallet_dir = Path.home() / ".htcli" / "wallets"
948
+ keypair_file = wallet_dir / f"{name}.json"
949
+
950
+ if not keypair_file.exists():
951
+ raise FileNotFoundError(f"Wallet '{name}' not found")
952
+
953
+ with open(keypair_file) as f:
954
+ keypair_data = json.load(f)
955
+
956
+ # Return wallet info without private key
957
+ wallet_info = {
958
+ "name": keypair_data["name"],
959
+ "key_type": keypair_data["key_type"],
960
+ "public_key": keypair_data["public_key"],
961
+ "ss58_address": keypair_data["ss58_address"],
962
+ "address": keypair_data["ss58_address"], # Alias for compatibility
963
+ "is_hotkey": keypair_data.get("is_hotkey", False),
964
+ "owner_address": keypair_data.get("owner_address", None),
965
+ "is_encrypted": keypair_data.get("is_encrypted", True),
966
+ }
967
+
968
+ return wallet_info
969
+
970
+ except Exception as e:
971
+ raise Exception(f"Failed to get wallet info: {str(e)}") from e
972
+
973
+
974
+ def wallet_name_exists(name: str) -> bool:
975
+ """Check if a wallet name already exists."""
976
+ try:
977
+ wallet_dir = Path.home() / ".htcli" / "wallets"
978
+ keypair_file = wallet_dir / f"{name}.json"
979
+ return keypair_file.exists()
980
+ except Exception:
981
+ return False
982
+
983
+
984
+ def delete_keypair(name: str) -> bool:
985
+ """Delete a keypair from disk."""
986
+ try:
987
+ wallet_dir = Path.home() / ".htcli" / "wallets"
988
+ keypair_file = wallet_dir / f"{name}.json"
989
+
990
+ if keypair_file.exists():
991
+ keypair_file.unlink()
992
+ return True
993
+ else:
994
+ return False
995
+
996
+ except Exception as e:
997
+ raise Exception(f"Failed to delete keypair: {str(e)}") from e
998
+
999
+
1000
+ def delete_coldkey_and_hotkeys(coldkey_name: str) -> dict:
1001
+ """Delete a coldkey and all its associated hotkeys."""
1002
+ try:
1003
+ # First, get the coldkey info to get its address
1004
+ coldkey_info = get_wallet_info_by_name(coldkey_name)
1005
+ coldkey_address = coldkey_info["ss58_address"]
1006
+
1007
+ # Check if it's actually a coldkey
1008
+ if coldkey_info.get("is_hotkey", False):
1009
+ raise Exception(f"'{coldkey_name}' is a hotkey, not a coldkey")
1010
+
1011
+ # Find all hotkeys owned by this coldkey
1012
+ all_keys = list_keys()
1013
+ associated_hotkeys = []
1014
+
1015
+ for key_info in all_keys:
1016
+ if (
1017
+ key_info.get("is_hotkey", False)
1018
+ and key_info.get("owner_address") == coldkey_address
1019
+ ):
1020
+ associated_hotkeys.append(key_info["name"])
1021
+
1022
+ # Delete the coldkey first
1023
+ coldkey_deleted = delete_keypair(coldkey_name)
1024
+ if not coldkey_deleted:
1025
+ raise Exception(f"Failed to delete coldkey '{coldkey_name}'")
1026
+
1027
+ # Delete all associated hotkeys
1028
+ hotkeys_deleted = []
1029
+ for hotkey_name in associated_hotkeys:
1030
+ try:
1031
+ if delete_keypair(hotkey_name):
1032
+ hotkeys_deleted.append(hotkey_name)
1033
+ else:
1034
+ print(f"Warning: Failed to delete hotkey '{hotkey_name}'")
1035
+ except Exception as e:
1036
+ print(f"Warning: Error deleting hotkey '{hotkey_name}': {str(e)}")
1037
+
1038
+ return {
1039
+ "coldkey_deleted": coldkey_name,
1040
+ "coldkey_address": coldkey_address,
1041
+ "hotkeys_deleted": hotkeys_deleted,
1042
+ "total_hotkeys_deleted": len(hotkeys_deleted),
1043
+ }
1044
+
1045
+ except Exception as e:
1046
+ raise Exception(f"Failed to delete coldkey and hotkeys: {str(e)}") from e
1047
+
1048
+
1049
+ def update_coldkey(
1050
+ current_name: str,
1051
+ new_name: Optional[str] = None,
1052
+ new_password: Optional[str] = None,
1053
+ remove_password: bool = False,
1054
+ ) -> dict:
1055
+ """Update a coldkey's properties."""
1056
+ try:
1057
+ # Load the current keypair
1058
+ keypair = load_keypair(current_name)
1059
+
1060
+ # Get current wallet info
1061
+ wallet_info = get_wallet_info_by_name(current_name)
1062
+
1063
+ # Verify it's a coldkey
1064
+ if wallet_info.get("is_hotkey", False):
1065
+ raise Exception(f"'{current_name}' is a hotkey, not a coldkey")
1066
+
1067
+ # Determine new name
1068
+ final_name = new_name if new_name else current_name
1069
+
1070
+ # Check if new name already exists (if changing name)
1071
+ if new_name and new_name != current_name:
1072
+ if wallet_name_exists(new_name):
1073
+ raise Exception(f"Wallet name '{new_name}' already exists")
1074
+
1075
+ # Determine password
1076
+ if remove_password:
1077
+ final_password = None
1078
+ elif new_password:
1079
+ final_password = new_password
1080
+ else:
1081
+ # Keep current password (will be handled by save function)
1082
+ final_password = None
1083
+
1084
+ # Save with new properties
1085
+ save_coldkey(final_name, keypair, final_password)
1086
+
1087
+ # Delete old file if name changed
1088
+ if new_name and new_name != current_name:
1089
+ delete_keypair(current_name)
1090
+
1091
+ return {
1092
+ "old_name": current_name,
1093
+ "new_name": final_name,
1094
+ "key_type": wallet_info["key_type"],
1095
+ "ss58_address": wallet_info["ss58_address"],
1096
+ "password_updated": new_password is not None or remove_password,
1097
+ "name_updated": new_name is not None and new_name != current_name,
1098
+ }
1099
+
1100
+ except Exception as e:
1101
+ raise Exception(f"Failed to update coldkey: {str(e)}") from e
1102
+
1103
+
1104
+ def update_hotkey(
1105
+ current_name: str,
1106
+ new_name: Optional[str] = None,
1107
+ new_password: Optional[str] = None,
1108
+ remove_password: bool = False,
1109
+ new_owner_name: Optional[str] = None,
1110
+ ) -> dict:
1111
+ """Update a hotkey's properties."""
1112
+ try:
1113
+ # Load the current keypair
1114
+ keypair = load_keypair(current_name)
1115
+
1116
+ # Get current wallet info
1117
+ wallet_info = get_wallet_info_by_name(current_name)
1118
+
1119
+ # Verify it's a hotkey
1120
+ if not wallet_info.get("is_hotkey", False):
1121
+ raise Exception(f"'{current_name}' is a coldkey, not a hotkey")
1122
+
1123
+ # Determine new name
1124
+ final_name = new_name if new_name else current_name
1125
+
1126
+ # Check if new name already exists (if changing name)
1127
+ if new_name and new_name != current_name:
1128
+ if wallet_name_exists(new_name):
1129
+ raise Exception(f"Wallet name '{new_name}' already exists")
1130
+
1131
+ # Determine owner address
1132
+ owner_address = wallet_info["owner_address"]
1133
+ if new_owner_name:
1134
+ # Validate new owner exists and is a coldkey
1135
+ try:
1136
+ new_owner_info = get_wallet_info_by_name(new_owner_name)
1137
+ if new_owner_info.get("is_hotkey", False):
1138
+ raise Exception(
1139
+ f"'{new_owner_name}' is a hotkey. Please provide a coldkey wallet name as the owner."
1140
+ )
1141
+ owner_address = new_owner_info["ss58_address"]
1142
+ except FileNotFoundError as e:
1143
+ raise Exception(
1144
+ f"Owner wallet '{new_owner_name}' not found. Please provide an existing coldkey wallet name."
1145
+ ) from e
1146
+
1147
+ # Determine password
1148
+ if remove_password:
1149
+ final_password = None
1150
+ elif new_password:
1151
+ final_password = new_password
1152
+ else:
1153
+ # Keep current password (will be handled by save function)
1154
+ final_password = None
1155
+
1156
+ # Save with new properties
1157
+ save_hotkey(final_name, keypair, owner_address, final_password)
1158
+
1159
+ # Delete old file if name changed
1160
+ if new_name and new_name != current_name:
1161
+ delete_keypair(current_name)
1162
+
1163
+ return {
1164
+ "old_name": current_name,
1165
+ "new_name": final_name,
1166
+ "key_type": wallet_info["key_type"],
1167
+ "ss58_address": wallet_info["ss58_address"],
1168
+ "old_owner_address": wallet_info["owner_address"],
1169
+ "new_owner_address": owner_address,
1170
+ "password_updated": new_password is not None or remove_password,
1171
+ "name_updated": new_name is not None and new_name != current_name,
1172
+ "owner_updated": new_owner_name is not None,
1173
+ }
1174
+
1175
+ except Exception as e:
1176
+ raise Exception(f"Failed to update hotkey: {str(e)}") from e