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,1069 @@
1
+ """
2
+ Wallet management utilities for the Hypertensor CLI.
3
+ """
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import typer
13
+ from substrateinterface import Keypair
14
+
15
+ from src.htcli.ui.display import print_success
16
+
17
+ from ...utils.logging import get_logger
18
+ from .crypto import public_key_to_evm_address
19
+
20
+ # Use centralized logging framework
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class WalletType(Enum):
25
+ COLDKEY = 0
26
+ INDEPENDENT_HOTKEY = 1
27
+ OWNED_HOTKEY = 2
28
+
29
+
30
+ def get_wallet_directory() -> Path:
31
+ """Get the wallet directory from config or use default."""
32
+ try:
33
+ from ...dependencies import get_config
34
+
35
+ config = get_config()
36
+ if config and config.wallet and config.wallet.path:
37
+ # Expand ~ and other path variables
38
+ wallet_path = Path(config.wallet.path).expanduser()
39
+ return wallet_path
40
+ except Exception:
41
+ # Fallback to default if config is not available
42
+ pass
43
+
44
+ # Default fallback
45
+ return Path.home() / ".htcli" / "wallets"
46
+
47
+
48
+ def generate_mnemonic() -> str:
49
+ """
50
+ Generate a new mnemonic phrase.
51
+
52
+ Returns:
53
+ str: A new mnemonic phrase
54
+ """
55
+ return Keypair.generate_mnemonic()
56
+
57
+
58
+ def prompt_for_wallet_selection(
59
+ message: str = "Select wallet",
60
+ wallet_type: str = "hotkey",
61
+ auto_password: bool = True,
62
+ ) -> str:
63
+ """
64
+ Prompt user to select a wallet by name from available wallets.
65
+
66
+ Args:
67
+ message: The prompt message to display
68
+ wallet_type: Type of wallet to show - "hotkey", "coldkey", or "all"
69
+
70
+ Returns:
71
+ str: The selected wallet name
72
+
73
+ Raises:
74
+ ValueError: If no wallets are available
75
+ """
76
+ from ...dependencies import get_client
77
+ from ...ui.prompts import select_prompt
78
+
79
+ try:
80
+ # Get available wallets from the client
81
+ client = get_client()
82
+ response = client.offchain.wallet.list_wallets()
83
+ wallets = response.get("data", [])
84
+
85
+ if not wallets:
86
+ raise ValueError(
87
+ "No wallets found. Please create a wallet first using 'htcli wallet create'"
88
+ )
89
+
90
+ # Filter wallets based on type
91
+ if wallet_type == "hotkey":
92
+ filtered_wallets = [w for w in wallets if w.get("is_hotkey", True)]
93
+ elif wallet_type == "coldkey":
94
+ filtered_wallets = [w for w in wallets if not w.get("is_hotkey", False)]
95
+ else: # "all"
96
+ filtered_wallets = wallets
97
+
98
+ if not filtered_wallets:
99
+ wallet_type_display = (
100
+ "hotkey"
101
+ if wallet_type == "hotkey"
102
+ else "coldkey"
103
+ if wallet_type == "coldkey"
104
+ else ""
105
+ )
106
+ from ...ui.display import print_error
107
+
108
+ print_error(
109
+ f"No {wallet_type_display} wallets found. Please create a {wallet_type_display} wallet first using 'htcli wallet create --type {wallet_type}'"
110
+ )
111
+ raise typer.Abort()
112
+
113
+ # Create address to name mapping for owner display
114
+ address_to_name = {}
115
+ for wallet in wallets:
116
+ if not wallet.get("is_hotkey", False): # This is a coldkey
117
+ address_to_name[wallet.get("ss58_address")] = wallet.get("name")
118
+
119
+ # Extract wallet names and create selection choices
120
+ wallet_choices = []
121
+ for wallet in filtered_wallets:
122
+ name = wallet.get("name", "Unknown")
123
+ address = wallet.get("ss58_address", "Unknown")
124
+
125
+ if wallet_type == "hotkey":
126
+ # For hotkeys, show owner information
127
+ owner_address = wallet.get("owner_address", "N/A")
128
+ if owner_address != "N/A" and owner_address in address_to_name:
129
+ owner_display = f"{address_to_name[owner_address]}"
130
+ display_text = f"{name} (Owner: {owner_display})"
131
+ else:
132
+ display_text = (
133
+ f"{name} (Owner: {owner_address[:8]}...{owner_address[-8:]})"
134
+ )
135
+ else:
136
+ # For coldkeys or all, show address
137
+ display_text = f"{name} ({address[:8]}...{address[-8:]})"
138
+
139
+ wallet_choices.append((name, display_text, name))
140
+
141
+ return select_prompt(message, wallet_choices)
142
+
143
+ except Exception as e:
144
+ from ...ui.display import print_error
145
+
146
+ print_error(f"Failed to load wallets: {str(e)}")
147
+ raise
148
+
149
+
150
+ def prompt_for_wallet_and_keypair(
151
+ message: str = "Select wallet", wallet_type: str = "hotkey"
152
+ ) -> tuple[str, "Keypair"]:
153
+ """
154
+ Prompt user to select a wallet and return both name and keypair.
155
+ Handles password prompting automatically for encrypted wallets.
156
+
157
+ Args:
158
+ message: The prompt message to display
159
+ wallet_type: Type of wallet to show - "hotkey", "coldkey", or "all"
160
+
161
+ Returns:
162
+ tuple: (wallet_name, keypair)
163
+
164
+ Raises:
165
+ typer.Abort: If no wallets are available or user cancels
166
+ """
167
+ from ...dependencies import get_client
168
+ from ...ui.prompts import password_prompt
169
+
170
+ # Get wallet name from selection
171
+ wallet_name = prompt_for_wallet_selection(message, wallet_type, auto_password=False)
172
+
173
+ # Get client and load wallet
174
+ client = get_client()
175
+ response = client.offchain.wallet.list_wallets()
176
+ wallets = response.get("data", [])
177
+
178
+ # Find the selected wallet
179
+ selected_wallet = None
180
+ for wallet in wallets:
181
+ if wallet.get("name") == wallet_name:
182
+ selected_wallet = wallet
183
+ break
184
+
185
+ if not selected_wallet:
186
+ raise ValueError(f"Wallet '{wallet_name}' not found")
187
+
188
+ # Extract wallet metadata for disambiguation
189
+ is_hotkey = selected_wallet.get("is_hotkey", False)
190
+ owner_address = selected_wallet.get("owner_address") if is_hotkey else None
191
+
192
+ # Check if wallet is encrypted and prompt for password if needed
193
+ if selected_wallet.get("is_encrypted", False):
194
+ password = password_prompt(f"Enter password for wallet '{wallet_name}'")
195
+ keypair = client.offchain.wallet.get_keypair(
196
+ wallet_name, password, is_hotkey=is_hotkey, owner_address=owner_address
197
+ )
198
+ else:
199
+ # For unencrypted wallets, get keypair directly
200
+ keypair = client.offchain.wallet.get_keypair(
201
+ wallet_name, None, is_hotkey=is_hotkey, owner_address=owner_address
202
+ )
203
+
204
+ return wallet_name, keypair
205
+
206
+
207
+ def retrieve_wallet_with_validation(
208
+ wallet_type: str = "coldkey",
209
+ purpose: str = "sign the transaction",
210
+ only_existing_wallets: bool = False,
211
+ ) -> tuple[str, "Keypair"]:
212
+ """
213
+ Comprehensive wallet retrieval mechanism that handles:
214
+ - Address vs wallet name selection
215
+ - Wallet type filtering (hotkey/coldkey)
216
+ - Password handling with retry attempts
217
+ - Option to create new wallet if needed
218
+
219
+ Args:
220
+ wallet_type: Type of wallet to retrieve - "hotkey" or "coldkey"
221
+ purpose: Description of what the wallet will be used for
222
+ only_existing_wallets: If True, skip initial selection and only allow selecting from existing wallets.
223
+ This is useful for owner-only operations that require an existing wallet.
224
+
225
+ Returns:
226
+ tuple: (wallet_name_or_address, keypair)
227
+
228
+ Raises:
229
+ typer.Abort: If user cancels or max retries exceeded
230
+ """
231
+ from ...dependencies import get_client
232
+ from ...ui.display import print_error
233
+ from ...ui.prompts import confirm_prompt, select_prompt, text_prompt
234
+
235
+ try:
236
+ client = get_client()
237
+ except Exception as e:
238
+ print_error(f"Failed to initialize client: {str(e)}")
239
+ print_error("Please check your configuration and try again.")
240
+ raise typer.Abort() from e
241
+
242
+ # If only_existing_wallets is True, skip the initial selection and go directly to wallet selection
243
+ if only_existing_wallets:
244
+ return _select_existing_wallet(client, wallet_type, purpose, allow_create_new=False)
245
+
246
+ # Step 1: Ask if user wants to provide address or wallet name
247
+ input_type = select_prompt(
248
+ f"How do you want to provide the {wallet_type} for {purpose}?",
249
+ [
250
+ ("address", f"Provide {wallet_type} address directly"),
251
+ ("wallet", f"Select from existing {wallet_type} wallets"),
252
+ ("create", f"Create new {wallet_type} wallet"),
253
+ ],
254
+ )
255
+
256
+ if input_type == "address":
257
+ # Step 2a: Get address directly
258
+ address = text_prompt(f"Enter {wallet_type} address")
259
+
260
+ # Validate address format
261
+ if wallet_type == "hotkey":
262
+ if not client.offchain.wallet.validate_ethereum_address(address):
263
+ print_error(
264
+ f"Invalid {wallet_type} address format. Expected Bytes20 format (0x + 40 hex characters)"
265
+ )
266
+ raise typer.Abort()
267
+ else: # coldkey
268
+ # For coldkey, we need to find a wallet with this address
269
+ from ...utils.wallet.crypto import list_keys
270
+
271
+ wallets = list_keys()
272
+
273
+ matching_wallet = None
274
+ for wallet in wallets:
275
+ # Check both ss58_address and derived address from public_key
276
+ if not wallet.get("is_hotkey", False):
277
+ # Match by ss58_address (primary)
278
+ if wallet.get("ss58_address") == address:
279
+ matching_wallet = wallet
280
+ break
281
+ # Also match by public_key (keypair derives 0x + public_key as address)
282
+ if wallet.get("public_key"):
283
+ derived_addr = "0x" + wallet.get("public_key")
284
+ if derived_addr.lower() == address.lower():
285
+ matching_wallet = wallet
286
+ break
287
+
288
+ if not matching_wallet:
289
+ print_error(f"No {wallet_type} wallet found with address: {address}")
290
+ if confirm_prompt("Would you like to create a new wallet instead?"):
291
+ return _create_new_wallet(client, wallet_type)
292
+ raise typer.Abort()
293
+
294
+ # Get keypair for the matching wallet
295
+ return _get_keypair_with_password_retry(
296
+ client, matching_wallet["name"], matching_wallet, 3
297
+ )
298
+
299
+ # For hotkey addresses, we don't need a keypair (just the address)
300
+ return address, None
301
+
302
+ elif input_type == "wallet":
303
+ # Step 2b: Select from existing wallets
304
+ return _select_existing_wallet(client, wallet_type, purpose, allow_create_new=True)
305
+
306
+ else: # create
307
+ # Step 2c: Create new wallet
308
+ return _create_new_wallet(client, wallet_type)
309
+
310
+
311
+ def _select_existing_wallet(
312
+ client, wallet_type: str, purpose: str, allow_create_new: bool = True
313
+ ) -> tuple[str, "Keypair"]:
314
+ """Select from existing wallets of specified type.
315
+
316
+ Args:
317
+ client: Client instance
318
+ wallet_type: Type of wallet - "hotkey" or "coldkey"
319
+ purpose: Description of what the wallet will be used for
320
+ allow_create_new: If False, do not show option to create new wallet
321
+ """
322
+ from ...ui.display import print_error
323
+ from ...ui.prompts import confirm_prompt, select_prompt
324
+ from ...utils.wallet.crypto import list_keys
325
+
326
+ try:
327
+ # Get available wallets with proper structure including is_hotkey
328
+ wallets = list_keys()
329
+
330
+ # Ensure wallets is a list and not None
331
+ if wallets is None:
332
+ print_error("Failed to retrieve wallet information.")
333
+ if allow_create_new and confirm_prompt(f"Would you like to create a new {wallet_type} wallet?"):
334
+ return _create_new_wallet(client, wallet_type)
335
+ raise typer.Abort()
336
+
337
+ # Filter by wallet type
338
+ if wallet_type == "hotkey":
339
+ filtered_wallets = [w for w in wallets if w.get("is_hotkey", False)]
340
+ else: # coldkey
341
+ filtered_wallets = [w for w in wallets if not w.get("is_hotkey", False)]
342
+
343
+ # Check if we have any wallets of the requested type
344
+ if not filtered_wallets:
345
+ print_error(f"No {wallet_type} wallets found.")
346
+ if allow_create_new and confirm_prompt(f"Would you like to create a new {wallet_type} wallet?"):
347
+ return _create_new_wallet(client, wallet_type)
348
+ raise typer.Abort()
349
+
350
+ # Create selection choices
351
+ wallet_choices = []
352
+
353
+ for wallet in filtered_wallets:
354
+ name = wallet.get("name", "Unknown")
355
+ address = wallet.get("ss58_address", "Unknown")
356
+
357
+ if wallet_type == "hotkey":
358
+ # For hotkeys, always show owner information to disambiguate
359
+ owner_address = wallet.get("owner_address")
360
+ if owner_address:
361
+ # Find owner wallet name
362
+ owner_display = None
363
+ for w in wallets:
364
+ if (
365
+ w.get("ss58_address") == owner_address
366
+ or w.get("evm_address", "").lower() == owner_address.lower()
367
+ ):
368
+ owner_display = w.get("name", "Unknown")
369
+ break
370
+
371
+ if owner_display:
372
+ display_text = f"{name} (Owner: {owner_display})"
373
+ else:
374
+ display_text = f"{name} (Owner: {owner_address[:8]}...{owner_address[-8:]})"
375
+ else:
376
+ display_text = f"{name} (No owner)"
377
+ else:
378
+ # For coldkeys, show address
379
+ display_text = f"{name} ({address[:8]}...{address[-8:]})"
380
+
381
+ # Create unique key for this wallet (for hotkeys, include owner_address to disambiguate)
382
+ if wallet_type == "hotkey":
383
+ owner_addr = wallet.get("owner_address") or ""
384
+ wallet_key = f"{name}__owner_{owner_addr}"
385
+ else:
386
+ wallet_key = f"{name}__address_{address}"
387
+
388
+ # Store the full wallet dict as the value so we can use it directly
389
+ wallet_choices.append((wallet_key, display_text, wallet))
390
+
391
+ # Add option to create new wallet only if allowed
392
+ if allow_create_new:
393
+ wallet_choices.append(
394
+ ("CREATE_NEW", f"Create new {wallet_type} wallet", "CREATE_NEW")
395
+ )
396
+
397
+ prompt_message = f"Select {wallet_type} wallet for {purpose}"
398
+ selected_wallet = select_prompt(prompt_message, wallet_choices)
399
+
400
+ if selected_wallet == "CREATE_NEW":
401
+ return _create_new_wallet(client, wallet_type)
402
+
403
+ # The selected_wallet should be the wallet dict (stored as value in choices tuple)
404
+ if not isinstance(selected_wallet, dict):
405
+ # Fallback: if for some reason we got a string key instead of dict
406
+ # Find the wallet from filtered_wallets by matching the key
407
+ selected_key = str(selected_wallet)
408
+ selected_wallet = None
409
+ for wallet in filtered_wallets:
410
+ name = wallet.get("name", "Unknown")
411
+ if wallet_type == "hotkey":
412
+ owner_addr = wallet.get("owner_address") or ""
413
+ wallet_key = f"{name}__owner_{owner_addr}"
414
+ else:
415
+ address = wallet.get("ss58_address", "Unknown")
416
+ wallet_key = f"{name}__address_{address}"
417
+
418
+ if wallet_key == selected_key:
419
+ selected_wallet = wallet
420
+ break
421
+
422
+ if not selected_wallet or not isinstance(selected_wallet, dict):
423
+ raise ValueError(f"Selected wallet not found or invalid")
424
+
425
+ # Get wallet name from the selected wallet dict
426
+ selected_name = selected_wallet.get("name")
427
+
428
+ # Get keypair with password retry
429
+ # selected_wallet already has is_hotkey and owner_address from the wallet data
430
+ # _get_keypair_with_password_retry will extract these and pass them to get_keypair
431
+ return _get_keypair_with_password_retry(
432
+ client, selected_name, selected_wallet, 3
433
+ )
434
+
435
+ except Exception as e:
436
+ print_error(f"Error retrieving wallets: {str(e)}")
437
+ # Propagate actual error upward so callers can surface context
438
+ raise
439
+
440
+
441
+ def _get_keypair_with_password_retry(
442
+ client, wallet_name: str, wallet_info: dict, max_attempts: int = 3
443
+ ) -> tuple[str, "Keypair"]:
444
+ """Get keypair with password retry mechanism."""
445
+ from ...ui.display import print_error
446
+ from ...ui.prompts import password_prompt
447
+
448
+ # Extract wallet type information for disambiguation
449
+ is_hotkey = wallet_info.get("is_hotkey", False)
450
+ owner_address = wallet_info.get("owner_address")
451
+
452
+ # Check if wallet is encrypted
453
+ if not wallet_info.get("is_encrypted", False):
454
+ # Unencrypted wallet - no password needed
455
+ try:
456
+ keypair = client.offchain.wallet.get_keypair(
457
+ wallet_name, None, is_hotkey=is_hotkey, owner_address=owner_address
458
+ )
459
+ return wallet_name, keypair
460
+ except Exception as e:
461
+ raise ValueError(f"Failed to unlock wallet '{wallet_name}': {str(e)}") from e
462
+
463
+ # Encrypted wallet - need password
464
+ for attempt in range(max_attempts):
465
+ try:
466
+ remaining = max_attempts - attempt
467
+ password = password_prompt(
468
+ f"Enter password for wallet '{wallet_name}' (attempt {attempt + 1}/{max_attempts})"
469
+ )
470
+ keypair = client.offchain.wallet.get_keypair(
471
+ wallet_name, password, is_hotkey=is_hotkey, owner_address=owner_address
472
+ )
473
+ print_success(f"Successfully unlocked wallet '{wallet_name}'")
474
+ return wallet_name, keypair
475
+ except ValueError as e:
476
+ if "Invalid password" in str(e):
477
+ if attempt < max_attempts - 1:
478
+ print_error(
479
+ f"Invalid password. {remaining - 1} attempts remaining."
480
+ )
481
+ else:
482
+ print_error("Invalid password. Maximum attempts exceeded.")
483
+ raise
484
+ else:
485
+ # Don't print error here - let the caller handle it
486
+ raise
487
+ except Exception as e:
488
+ error_message = str(e)
489
+ if "Invalid password" in error_message:
490
+ if attempt < max_attempts - 1:
491
+ print_error(
492
+ f"Invalid password. {remaining - 1} attempts remaining."
493
+ )
494
+ continue
495
+ print_error("Invalid password. Maximum attempts exceeded.")
496
+ raise ValueError(
497
+ f"Failed to unlock wallet '{wallet_name}': Invalid password"
498
+ ) from e
499
+ raise ValueError(
500
+ f"Failed to unlock wallet '{wallet_name}': {error_message}"
501
+ ) from e
502
+
503
+
504
+ def resolve_coldkey_and_get_keypair(
505
+ coldkey_input: str,
506
+ ) -> tuple[str, "Keypair"]:
507
+ """
508
+ Resolve coldkey input (address or wallet name) and get the keypair.
509
+
510
+ Args:
511
+ coldkey_input: Either a coldkey address (ss58 or 0x format) or wallet name
512
+
513
+ Returns:
514
+ tuple: (wallet_name, keypair)
515
+
516
+ Raises:
517
+ ValueError: If coldkey cannot be resolved or wallet not found
518
+ """
519
+ from ...dependencies import get_client
520
+ from ...utils.wallet.crypto import get_wallet_info_by_name, list_keys
521
+
522
+ coldkey_input = coldkey_input.strip()
523
+ client = get_client()
524
+
525
+ # First, try to find by wallet name
526
+ try:
527
+ wallet_info = get_wallet_info_by_name(coldkey_input, is_hotkey=False)
528
+ # If found and it's a coldkey (not hotkey)
529
+ if not wallet_info.get("is_hotkey", False):
530
+ # Get keypair for this wallet
531
+ return _get_keypair_with_password_retry(
532
+ client, coldkey_input, wallet_info, 3
533
+ )
534
+ except FileNotFoundError:
535
+ # Not a wallet name, try as address
536
+ pass
537
+ except Exception as e:
538
+ raise ValueError(f"Failed to resolve coldkey '{coldkey_input}': {str(e)}") from e
539
+
540
+ # If not found by name, try to find by address
541
+ wallets = list_keys()
542
+ matching_wallet = None
543
+
544
+ for wallet in wallets:
545
+ if wallet.get("is_hotkey", False):
546
+ continue # Skip hotkeys
547
+
548
+ # Match by ss58_address (primary)
549
+ if wallet.get("ss58_address") and wallet.get("ss58_address") == coldkey_input:
550
+ matching_wallet = wallet
551
+ break
552
+
553
+ # Match by evm_address
554
+ if wallet.get("evm_address") and wallet.get("evm_address").lower() == coldkey_input.lower():
555
+ matching_wallet = wallet
556
+ break
557
+
558
+ # Match by address field
559
+ if wallet.get("address") and wallet.get("address").lower() == coldkey_input.lower():
560
+ matching_wallet = wallet
561
+ break
562
+
563
+ # Also match by public_key (keypair derives 0x + public_key as address)
564
+ if wallet.get("public_key"):
565
+ derived_addr = "0x" + wallet.get("public_key")
566
+ if derived_addr.lower() == coldkey_input.lower():
567
+ matching_wallet = wallet
568
+ break
569
+
570
+ if not matching_wallet:
571
+ raise ValueError(
572
+ f"No coldkey wallet found with address or name: {coldkey_input}"
573
+ )
574
+
575
+ # Get keypair for the matching wallet
576
+ return _get_keypair_with_password_retry(
577
+ client, matching_wallet["name"], matching_wallet, 3
578
+ )
579
+
580
+
581
+ def resolve_coldkey_address(coldkey_input: str) -> str:
582
+ """
583
+ Resolve a coldkey wallet input (name or address) to a usable address string.
584
+
585
+ This is a lightweight resolver that does NOT unlock the wallet.
586
+ For ECDSA keys, always returns EVM address format.
587
+
588
+ Args:
589
+ coldkey_input: Wallet name or address.
590
+
591
+ Returns:
592
+ Resolved EVM address string (0x... format).
593
+
594
+ Raises:
595
+ ValueError: If the input is empty or cannot be resolved.
596
+ """
597
+ from ...utils.wallet.crypto import get_wallet_info_by_name, list_keys
598
+
599
+ if coldkey_input is None:
600
+ raise ValueError("Coldkey value cannot be empty")
601
+
602
+ value = coldkey_input.strip()
603
+ if not value:
604
+ raise ValueError("Coldkey value cannot be empty")
605
+
606
+ # Direct address passthrough (EVM format)
607
+ if value.startswith("0x") and len(value) == 42:
608
+ return value.lower()
609
+
610
+ # FIX: Don't accept SS58 addresses - only EVM format
611
+ # if len(value) in (48, 49) and value.startswith("5"):
612
+ # return value
613
+
614
+ # Try resolving by wallet name (coldkey only)
615
+ try:
616
+ wallet_info = get_wallet_info_by_name(value, is_hotkey=False)
617
+ if wallet_info and not wallet_info.get("is_hotkey", False):
618
+ # FIX: Always use evm_address for ECDSA keys
619
+ key_type = wallet_info.get("key_type", "").lower()
620
+ if key_type == "ecdsa":
621
+ resolved = wallet_info.get("evm_address") or wallet_info.get("address")
622
+ else:
623
+ resolved = (
624
+ wallet_info.get("evm_address")
625
+ or wallet_info.get("address")
626
+ or wallet_info.get("ss58_address")
627
+ )
628
+ if resolved:
629
+ return resolved
630
+ except FileNotFoundError:
631
+ pass
632
+ except Exception as e:
633
+ raise ValueError(f"Failed to resolve coldkey '{value}': {str(e)}") from e
634
+
635
+ # Fallback: scan known wallets for matching name or address
636
+ wallets = list_keys()
637
+ for wallet in wallets:
638
+ if wallet.get("is_hotkey", False):
639
+ continue
640
+
641
+ # FIX: Match by evm_address first (for ECDSA)
642
+ if wallet.get("evm_address") and wallet.get("evm_address").lower() == value.lower():
643
+ return wallet.get("evm_address")
644
+
645
+ # Match by address field (should be EVM for ECDSA)
646
+ if wallet.get("address") and wallet.get("address").lower() == value.lower():
647
+ return wallet.get("address")
648
+
649
+ # Match by name
650
+ if wallet.get("name") == value:
651
+ key_type = wallet.get("key_type", "").lower()
652
+ if key_type == "ecdsa":
653
+ return wallet.get("evm_address") or wallet.get("address")
654
+ else:
655
+ return (
656
+ wallet.get("evm_address")
657
+ or wallet.get("address")
658
+ or wallet.get("ss58_address")
659
+ )
660
+
661
+ raise ValueError(f"No coldkey wallet found with address or name: {value}")
662
+
663
+
664
+ def _create_new_wallet(client, wallet_type: str) -> tuple[str, "Keypair"]:
665
+ """Create a new wallet of specified type."""
666
+ from ...ui.display import print_error, print_info, print_success
667
+ from ...ui.prompts import confirm_prompt, password_prompt, text_prompt
668
+
669
+ print_info(f"Creating new {wallet_type} wallet...")
670
+
671
+ # Get wallet name
672
+ wallet_name = text_prompt(f"Enter name for new {wallet_type} wallet")
673
+
674
+ # Ask for encryption
675
+ encrypt = confirm_prompt(
676
+ f"Encrypt the {wallet_type} wallet with a password?", default=True
677
+ )
678
+
679
+ password = None
680
+ if encrypt:
681
+ password = password_prompt(
682
+ f"Enter password for new {wallet_type} wallet", confirm=True
683
+ )
684
+
685
+ try:
686
+ if wallet_type == "hotkey":
687
+ # For hotkeys, we need an owner (coldkey)
688
+ print_info("Hotkeys must have an owner (coldkey wallet).")
689
+
690
+ # Get available coldkeys
691
+ try:
692
+ wallets_response = client.offchain.wallet.list_wallets()
693
+ all_wallets = wallets_response.get("data", [])
694
+
695
+ # Filter for coldkeys only
696
+ coldkey_wallets = [
697
+ w for w in all_wallets if not w.get("is_hotkey", False)
698
+ ]
699
+
700
+ if coldkey_wallets:
701
+ # Create selection choices
702
+ coldkey_choices = []
703
+ for _i, wallet in enumerate(coldkey_wallets):
704
+ name = wallet.get("name", "Unknown")
705
+ address = wallet.get("ss58_address", "Unknown")
706
+ display_text = f"{name} ({address[:8]}...{address[-8:]})"
707
+ coldkey_choices.append((name, display_text, name))
708
+
709
+ # Add option to enter address manually
710
+ coldkey_choices.append(
711
+ ("MANUAL", "Enter coldkey address manually", "MANUAL")
712
+ )
713
+
714
+ # Let user select from available coldkeys
715
+ from ...ui.prompts import select_prompt
716
+
717
+ selected_owner = select_prompt(
718
+ "Select owner coldkey wallet", coldkey_choices
719
+ )
720
+
721
+ if selected_owner == "MANUAL":
722
+ # Fallback to manual entry
723
+ owner_input = text_prompt("Enter coldkey wallet address")
724
+ owner_address = owner_input
725
+ print_info(f"Using provided address as owner: {owner_address}")
726
+ else:
727
+ # Find the selected coldkey
728
+ selected_wallet = None
729
+ for wallet in coldkey_wallets:
730
+ if wallet.get("name") == selected_owner:
731
+ selected_wallet = wallet
732
+ break
733
+
734
+ if selected_wallet:
735
+ owner_address = selected_wallet["ss58_address"]
736
+ print_info(
737
+ f"Using coldkey wallet '{selected_owner}' as owner: {owner_address}"
738
+ )
739
+ else:
740
+ raise ValueError(
741
+ f"Selected coldkey '{selected_owner}' not found"
742
+ )
743
+ else:
744
+ # No coldkeys available, ask for manual entry
745
+ print_info(
746
+ "No coldkey wallets found. Please provide a coldkey address manually."
747
+ )
748
+ owner_input = text_prompt("Enter owner coldkey wallet address")
749
+ owner_address = owner_input
750
+ print_info(f"Using provided address as owner: {owner_address}")
751
+
752
+ except Exception as e:
753
+ # Fallback to manual entry if listing fails
754
+ print_info(f"Could not list coldkey wallets: {str(e)}")
755
+ owner_input = text_prompt("Enter owner coldkey wallet address")
756
+ owner_address = owner_input
757
+ print_info(f"Using provided address as owner: {owner_address}")
758
+
759
+ # Create hotkey wallet with owner
760
+ result = client.offchain.wallet.create_hotkey_wallet(
761
+ wallet_name, owner_address, password
762
+ )
763
+ else:
764
+ # Create coldkey wallet
765
+ result = client.offchain.wallet.create_coldkey_wallet(wallet_name, password)
766
+
767
+ if result.get("success"):
768
+ print_success(f"Successfully created {wallet_type} wallet '{wallet_name}'")
769
+ # Get the keypair for the newly created wallet
770
+ # For hotkeys, pass is_hotkey=True and owner_address to help locate the wallet
771
+ if wallet_type == "hotkey":
772
+ keypair = client.offchain.wallet.get_keypair(
773
+ wallet_name, password, is_hotkey=True, owner_address=owner_address
774
+ )
775
+ else:
776
+ keypair = client.offchain.wallet.get_keypair(wallet_name, password)
777
+ return wallet_name, keypair
778
+ else:
779
+ raise Exception(result.get("message", "Failed to create wallet"))
780
+
781
+ except Exception as e:
782
+ print_error(f"Failed to create {wallet_type} wallet: {str(e)}")
783
+ raise
784
+
785
+
786
+ def create_keypair_from_mnemonic(mnemonic: str) -> Keypair:
787
+ """
788
+ Create a keypair from a mnemonic phrase.
789
+
790
+ Args:
791
+ mnemonic: The mnemonic phrase to create the keypair from
792
+
793
+ Returns:
794
+ Keypair: The generated keypair
795
+
796
+ Raises:
797
+ RuntimeError: If the mnemonic is invalid
798
+ """
799
+ try:
800
+ # Match mesh-template pattern - no ss58_format for ECDSA
801
+ keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
802
+ logger.info("Created keypair from mnemonic:")
803
+
804
+ return keypair
805
+ except Exception as e:
806
+ raise RuntimeError(f"Failed to create keypair from mnemonic: {e}") from e
807
+
808
+
809
+ def get_account_id(public_key: bytes) -> str:
810
+ """
811
+ Generate the account ID from a public key.
812
+ This is a 32-byte blake2b hash of the public key.
813
+
814
+ Args:
815
+ public_key: The public key bytes
816
+
817
+ Returns:
818
+ str: The account ID in hex format with 0x prefix
819
+ """
820
+ # Use blake2b hash function with 32-byte output
821
+ blake2b = hashlib.blake2b(digest_size=32)
822
+ blake2b.update(public_key)
823
+ return "0x" + blake2b.hexdigest()
824
+
825
+
826
+ def encrypt_data(data: bytes, password: str) -> bytes:
827
+ """
828
+ Encrypt data using XOR with the password and add a password hash for validation.
829
+
830
+ Args:
831
+ data: The data to encrypt
832
+ password: The password to use for encryption
833
+
834
+ Returns:
835
+ bytes: The encrypted data with password hash
836
+ """
837
+ # For empty password, return data as is (unencrypted)
838
+ if not password:
839
+ return data
840
+
841
+ # Generate password hash for validation
842
+ password_hash = hashlib.sha256(password.encode()).digest()
843
+
844
+ # Encrypt the data
845
+ key_bytes = password.encode("utf-8")
846
+ encrypted = bytes(data[i] ^ key_bytes[i % len(key_bytes)] for i in range(len(data)))
847
+
848
+ # Combine password hash and encrypted data
849
+ return password_hash + encrypted
850
+
851
+
852
+ def decrypt_data(encrypted_data: bytes, password: str) -> bytes:
853
+ """
854
+ Decrypt data that was encrypted with XOR using the password.
855
+ Also validates the password using the stored hash.
856
+
857
+ Args:
858
+ encrypted_data: The encrypted data with password hash
859
+ password: The password used for encryption
860
+
861
+ Returns:
862
+ bytes: The decrypted data
863
+
864
+ Raises:
865
+ RuntimeError: If the password is incorrect
866
+ """
867
+ if not password:
868
+ return encrypted_data
869
+
870
+ # Extract password hash and encrypted data
871
+ stored_hash = encrypted_data[:32] # First 32 bytes are the hash
872
+ encrypted = encrypted_data[32:] # Rest is the encrypted data
873
+
874
+ # Verify password
875
+ password_hash = hashlib.sha256(password.encode()).digest()
876
+ if password_hash != stored_hash:
877
+ raise RuntimeError("Invalid password")
878
+
879
+ # Decrypt the data
880
+ key_bytes = password.encode("utf-8")
881
+ return bytes(
882
+ encrypted[i] ^ key_bytes[i % len(key_bytes)] for i in range(len(encrypted))
883
+ )
884
+
885
+
886
+ def create_wallet(
887
+ name: str, # This will be the file name
888
+ wallet_dir: Optional[Path] = None, # Accept the full target directory path
889
+ is_hotkey: bool = False, # True for hotkey, False for coldkey
890
+ password: Optional[str] = None, # Password for encryption
891
+ owner_address: Optional[str] = None, # Required for owned hotkeys
892
+ mnemonic: Optional[str] = None, # Optional mnemonic for regeneration,
893
+ force: bool = False, # Skip confirmation prompt or overwrite existing wallet
894
+ ) -> tuple[str, str, str]: # Return main wallet file path, ss58_address, and mnemonic
895
+ """
896
+ Create a Hypertensor-compatible wallet in the specified directory.
897
+ Generates a new keypair and saves it as a JSON file with encrypted private key.
898
+
899
+ Args:
900
+ name: The desired file name.
901
+ wallet_dir: The full Path object for the directory where the wallet files should be created.
902
+ is_hotkey: True for hotkey, False for coldkey.
903
+ password: Optional password for encryption.
904
+ owner_address: Required for owned hotkeys, specifies the coldkey address that owns this hotkey.
905
+ mnemonic: Optional mnemonic phrase for regeneration. If provided, uses this instead of generating a new one.
906
+
907
+ Returns:
908
+ tuple of (main_wallet_file_path, ss58_address, mnemonic)
909
+ """
910
+ if not wallet_dir:
911
+ wallet_dir = get_wallet_directory()
912
+
913
+ # Create directory if it doesn't exist
914
+ wallet_dir.mkdir(parents=True, exist_ok=True)
915
+
916
+ main_wallet_file_path = wallet_dir / f"{name}.json"
917
+
918
+ # Check if wallet file already exists
919
+ if main_wallet_file_path.exists():
920
+ if not force:
921
+ raise ValueError(
922
+ f"Wallet file '{name}' already exists in {wallet_dir}. Use --force to overwrite existing file or choose another name."
923
+ )
924
+ else:
925
+ # Remove existing file
926
+ logger.info(
927
+ f"Wallet file '{name}' already exists in {wallet_dir}. Overwriting existing file."
928
+ )
929
+ logger.info(f"Removing existing file '{name}'...")
930
+ main_wallet_file_path.unlink()
931
+ logger.info(f"Removed existing file '{name}'.")
932
+
933
+ # Validate owner_address for owned hotkeys
934
+ if is_hotkey and owner_address:
935
+ if not (owner_address.startswith("5") or owner_address.startswith("0x")):
936
+ raise ValueError(
937
+ "Owner address must be a valid SS58 address starting with '5' or EVM address starting with '0x'"
938
+ )
939
+
940
+ # Generate or use provided mnemonic
941
+ if not mnemonic:
942
+ mnemonic = Keypair.generate_mnemonic()
943
+
944
+ # Create keypair from mnemonic
945
+ keypair = create_keypair_from_mnemonic(mnemonic)
946
+ evm_address = public_key_to_evm_address(keypair.public_key)
947
+
948
+ # Prepare wallet data
949
+ wallet_data = {
950
+ "name": name,
951
+ "key_type": "ecdsa", # Default to ecdsa for EVM compatibility
952
+ "public_key": keypair.public_key.hex(),
953
+ "ss58_address": keypair.ss58_address,
954
+ "evm_address": evm_address,
955
+ "is_hotkey": is_hotkey,
956
+ "owner_address": owner_address,
957
+ "is_encrypted": password is not None,
958
+ }
959
+
960
+ # Store private key
961
+ if password:
962
+ encrypted_private_key = encrypt_data(keypair.private_key, password)
963
+ wallet_data["private_key"] = "0x" + encrypted_private_key.hex()
964
+ else:
965
+ wallet_data["private_key"] = "0x" + keypair.private_key.hex()
966
+
967
+ try:
968
+ with open(main_wallet_file_path, "w") as f:
969
+ json.dump(wallet_data, f, indent=2)
970
+ os.chmod(main_wallet_file_path, 0o600) # Secure file permissions
971
+ except Exception as e:
972
+ if main_wallet_file_path.exists():
973
+ os.remove(main_wallet_file_path)
974
+ raise RuntimeError(f"Failed to save wallet file: {e}") from e
975
+
976
+ return str(main_wallet_file_path), evm_address, mnemonic
977
+
978
+
979
+ def import_wallet(
980
+ name: str, wallet_dir: Optional[Path] = None, password: Optional[str] = None
981
+ ) -> Keypair:
982
+ """
983
+ Import a wallet and return its keypair.
984
+
985
+ Args:
986
+ name (str): Name of the wallet
987
+ wallet_dir (Path): Directory containing the wallet file
988
+ password (str, optional): Password for encrypted wallets
989
+
990
+ Returns:
991
+ Keypair: The wallet's keypair
992
+
993
+ Raises:
994
+ ValueError: If wallet file is not found or invalid
995
+ RuntimeError: If password is incorrect or wallet is corrupted
996
+ """
997
+ if not wallet_dir:
998
+ wallet_dir = get_wallet_directory()
999
+
1000
+ wallet_path = wallet_dir / f"{name}.json"
1001
+ if not wallet_path.exists():
1002
+ raise ValueError(f"Wallet file not found at {wallet_path}")
1003
+
1004
+ try:
1005
+ with open(wallet_path) as f:
1006
+ wallet_data = json.load(f)
1007
+ except json.JSONDecodeError as j:
1008
+ raise ValueError("Invalid wallet file: not a valid JSON file") from j
1009
+
1010
+ # Get private key
1011
+ private_key_hex = wallet_data.get("private_key", "").replace("0x", "")
1012
+ if not private_key_hex:
1013
+ raise ValueError("Invalid wallet file: missing private key")
1014
+
1015
+ try:
1016
+ private_key_bytes = bytes.fromhex(private_key_hex)
1017
+ except ValueError as v:
1018
+ raise ValueError("Invalid wallet file: private key is not valid hex") from v
1019
+
1020
+ # Check if wallet is encrypted
1021
+ is_encrypted = wallet_data.get("is_encrypted", False)
1022
+
1023
+ # For unencrypted wallets
1024
+ if not is_encrypted:
1025
+ if password:
1026
+ raise RuntimeError("Invalid password: This wallet is not encrypted")
1027
+ try:
1028
+ # Match mesh-template pattern - no ss58_format for ECDSA
1029
+ return Keypair.create_from_private_key(private_key_bytes, crypto_type=2)
1030
+ except Exception as e:
1031
+ raise RuntimeError(f"Failed to create keypair: {str(e)}") from e
1032
+
1033
+ # For encrypted wallets
1034
+ if not password:
1035
+ raise RuntimeError(
1036
+ "Invalid password: Wallet is encrypted but no password provided"
1037
+ )
1038
+
1039
+ try:
1040
+ private_key_bytes = decrypt_data(private_key_bytes, password)
1041
+ if not is_valid_private_key(private_key_bytes):
1042
+ raise RuntimeError("Invalid password: Failed to decrypt private key")
1043
+ except Exception as e:
1044
+ raise RuntimeError("Invalid password: Failed to decrypt private key") from e
1045
+
1046
+ try:
1047
+ # Match mesh-template pattern - no ss58_format for ECDSA
1048
+ return Keypair.create_from_private_key(private_key_bytes, crypto_type=2)
1049
+ except Exception as e:
1050
+ raise RuntimeError(f"Failed to create keypair: {str(e)}") from e
1051
+
1052
+
1053
+ def is_valid_private_key(private_key_bytes: bytes) -> bool:
1054
+ """
1055
+ Validate if the decrypted private key is valid.
1056
+
1057
+ Args:
1058
+ private_key_bytes (bytes): The decrypted private key bytes
1059
+
1060
+ Returns:
1061
+ bool: True if the private key is valid, False otherwise
1062
+ """
1063
+ try:
1064
+ # Try to create a keypair with the private key
1065
+ # Match mesh-template pattern - no ss58_format for ECDSA
1066
+ Keypair.create_from_private_key(private_key_bytes, crypto_type=2)
1067
+ return True
1068
+ except Exception:
1069
+ return False