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,1518 @@
1
+ """
2
+ Wallet command prompting logic.
3
+
4
+ Handles user interaction and input validation for wallet operations.
5
+ Uses HTCLI UI components and Pydantic models for proper validation.
6
+ """
7
+
8
+ import re
9
+ from typing import Optional
10
+
11
+ from ...models.requests import (
12
+ PreseededWalletRequest,
13
+ WalletCreateRequest,
14
+ WalletDeleteRequest,
15
+ WalletRestoreRequest,
16
+ WalletTransferRequest,
17
+ WalletUpdateRequest,
18
+ )
19
+ from ...errors.base import AmbiguousWalletError
20
+ from ...ui.colors import error, info, warning
21
+ from ...ui.components import HTCLITable
22
+ from ...ui.display import HTCLIConsole
23
+ from ...ui.prompts import (
24
+ HTCLIPrompt,
25
+ amount_prompt,
26
+ confirm_prompt,
27
+ integer_prompt,
28
+ password_prompt,
29
+ prompt_for_optional,
30
+ prompt_for_required,
31
+ select_prompt,
32
+ text_prompt,
33
+ )
34
+ from ...utils.blockchain.validation import validate_wallet_name
35
+ from ...utils.validation import validate_wallet_name_prompt
36
+ from ...utils.wallet.crypto import (
37
+ _find_wallet_entries,
38
+ format_address_display,
39
+ get_wallet_info_by_name,
40
+ list_keys,
41
+ )
42
+
43
+ console = HTCLIConsole()
44
+ prompt = HTCLIPrompt()
45
+
46
+
47
+ def validate_password_strength(password: str) -> bool:
48
+ """Validate password strength."""
49
+ if len(password) < 8:
50
+ console.print(error("Password must be at least 8 characters long."))
51
+ return False
52
+ if not re.search(r"[A-Z]", password):
53
+ console.print(error("Password must contain at least one uppercase letter."))
54
+ return False
55
+ if not re.search(r"[a-z]", password):
56
+ console.print(error("Password must contain at least one lowercase letter."))
57
+ return False
58
+ if not re.search(r"\d", password):
59
+ console.print(error("Password must contain at least one digit."))
60
+ return False
61
+ if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password):
62
+ console.print(error("Password must contain at least one special character."))
63
+ return False
64
+ return True
65
+
66
+
67
+ def prompt_wallet_creation(
68
+ name: Optional[str] = None,
69
+ key_type: Optional[str] = None,
70
+ wallet_type: str = "coldkey",
71
+ coldkey_name: Optional[str] = None,
72
+ password: Optional[str] = None,
73
+ ) -> WalletCreateRequest:
74
+ """Collect wallet creation parameters from user."""
75
+
76
+ # Validate wallet name if provided
77
+ if name is not None:
78
+ is_valid, error_msg = validate_wallet_name_prompt(name)
79
+ if not is_valid:
80
+ console.print(error(f"Invalid wallet name: {error_msg}"))
81
+ name = None
82
+
83
+ # Get wallet name and check for existence
84
+ if not name:
85
+ if wallet_type == "hotkey":
86
+ # For hotkeys, we'll check uniqueness after we know the coldkey
87
+ # So we just validate the name format here
88
+ prompt_text = f"{wallet_type.capitalize()} name"
89
+ name = text_prompt(
90
+ prompt_text,
91
+ validator=validate_wallet_name,
92
+ error_message="Invalid wallet name. Use alphanumeric characters, hyphens, and underscores only.",
93
+ )
94
+ else:
95
+ # For coldkeys, check against all existing wallets
96
+ existing_wallets = [w["name"] for w in list_keys()]
97
+ while True:
98
+ prompt_text = (
99
+ f"{wallet_type.capitalize()} name"
100
+ if wallet_type in ["hotkey", "coldkey"]
101
+ else "Wallet name"
102
+ )
103
+ name = text_prompt(
104
+ prompt_text,
105
+ validator=validate_wallet_name,
106
+ error_message="Invalid wallet name. Use alphanumeric characters, hyphens, and underscores only.",
107
+ )
108
+ if name in existing_wallets:
109
+ console.print(
110
+ error(
111
+ f"Wallet with name '{name}' already exists. Please choose another name."
112
+ )
113
+ )
114
+ else:
115
+ break
116
+
117
+ # Set key type to ECDSA for HyperTensor EVM compatibility
118
+ if not key_type:
119
+ key_type = "ecdsa"
120
+
121
+ # Get coldkey address for hotkeys
122
+ if wallet_type == "hotkey" and not coldkey_name:
123
+ coldkeys = [w for w in list_keys() if not w.get("is_hotkey")]
124
+ if not coldkeys:
125
+ raise ValueError("No coldkeys found. Please create a coldkey first.")
126
+
127
+ console.print(info("Available coldkeys:"))
128
+ table = HTCLITable(title="Coldkeys")
129
+ table.add_column("Index", style="white")
130
+ table.add_column("Name", style="white")
131
+ table.add_column("Encrypted", style="dim")
132
+ for i, w in enumerate(coldkeys):
133
+ encrypted_status = "✅ Yes" if w.get("is_encrypted") else "❌ No"
134
+ table.add_row(str(i + 1), w["name"], encrypted_status)
135
+ console.print(table.table)
136
+
137
+ choice = prompt_for_required(
138
+ "Select coldkey", int, f"Enter a number from 1 to {len(coldkeys)}"
139
+ )
140
+ coldkey_name = coldkeys[choice - 1]["name"]
141
+
142
+ # For hotkeys, check if the selected coldkey already has a hotkey with this name
143
+ if wallet_type == "hotkey" and coldkey_name:
144
+ from ...utils.wallet.crypto import coldkey_has_hotkey, get_wallet_info_by_name
145
+
146
+ try:
147
+ coldkey_info = get_wallet_info_by_name(coldkey_name, is_hotkey=False)
148
+ coldkey_address = coldkey_info.get("ss58_address") or coldkey_info.get(
149
+ "address"
150
+ )
151
+
152
+ if coldkey_has_hotkey(coldkey_address, name):
153
+ console.print(
154
+ error(
155
+ f"Coldkey '{coldkey_name}' already has a hotkey named '{name}'. "
156
+ "Please choose a different name."
157
+ )
158
+ )
159
+ # Re-prompt for name
160
+ while True:
161
+ name = text_prompt(
162
+ f"{wallet_type.capitalize()} name",
163
+ validator=validate_wallet_name,
164
+ error_message="Invalid wallet name. Use alphanumeric characters, hyphens, and underscores only.",
165
+ )
166
+ if not coldkey_has_hotkey(coldkey_address, name):
167
+ break
168
+ console.print(
169
+ error(
170
+ f"Coldkey '{coldkey_name}' already has a hotkey named '{name}'. "
171
+ "Please choose a different name."
172
+ )
173
+ )
174
+ except FileNotFoundError:
175
+ # Coldkey not found, will be handled later
176
+ pass
177
+
178
+ # Get optional password
179
+ # If password is empty string, it means explicitly no password (from --no-password flag)
180
+ # If password is None, prompt the user
181
+ if password is None:
182
+ if confirm_prompt("Encrypt wallet with password?", default=True):
183
+ while True:
184
+ password = password_prompt("Enter password", confirm=True)
185
+ if validate_password_strength(password):
186
+ break
187
+ elif password == "":
188
+ # Explicitly no password (from --no-password flag), set to None for storage
189
+ password = None
190
+
191
+ return WalletCreateRequest(
192
+ name=name,
193
+ key_type=key_type,
194
+ wallet_type=wallet_type,
195
+ owner_address=coldkey_name,
196
+ password=password,
197
+ )
198
+
199
+
200
+ def prompt_wallet_restoration(
201
+ name: Optional[str] = None,
202
+ private_key: Optional[str] = None,
203
+ mnemonic: Optional[str] = None,
204
+ key_type: Optional[str] = None,
205
+ wallet_type: str = "coldkey",
206
+ coldkey_name: Optional[str] = None,
207
+ password: Optional[str] = None,
208
+ ) -> WalletRestoreRequest:
209
+ """Collect wallet restoration parameters from user."""
210
+
211
+ # Get wallet name
212
+ if not name:
213
+ prompt_text = "Hotkey name" if wallet_type == "hotkey" else "Wallet name"
214
+ name = text_prompt(prompt_text, validator=validate_wallet_name)
215
+
216
+ # Get restoration method
217
+ if not private_key and not mnemonic:
218
+ method = select_prompt(
219
+ "Restoration method",
220
+ choices=[
221
+ ("mnemonic", "Mnemonic phrase (12 or 24 words)", "mnemonic"),
222
+ ("private_key", "Private key (64-character hex)", "private_key"),
223
+ ],
224
+ default="mnemonic",
225
+ )
226
+
227
+ if method == "mnemonic":
228
+ mnemonic = text_prompt("Enter mnemonic phrase")
229
+ else:
230
+ private_key = text_prompt(
231
+ "Enter private key (64 hex characters)",
232
+ validator=lambda x: len(x) == 64
233
+ and all(c in "0123456789abcdefABCDEF" for c in x),
234
+ )
235
+
236
+ # Get key type
237
+ if not key_type:
238
+ key_type = select_prompt(
239
+ "Select key type",
240
+ choices=[
241
+ ("ecdsa", "ECDSA", "ecdsa"),
242
+ ("sr25519", "SR25519", "sr25519"),
243
+ ("ed25519", "Ed25519", "ed25519"),
244
+ ],
245
+ default="ecdsa",
246
+ )
247
+
248
+ # Get coldkey for hotkeys
249
+ if wallet_type == "hotkey" and not coldkey_name:
250
+ coldkeys = [w for w in list_keys() if not w.get("is_hotkey")]
251
+ if not coldkeys:
252
+ raise ValueError(
253
+ "No coldkeys found. Please create a coldkey first to assign as coldkey."
254
+ )
255
+
256
+ console.print(info("Available coldkeys:"))
257
+ table = HTCLITable(title="Coldkeys")
258
+ table.add_column("Index", style="htcli.value")
259
+ table.add_column("Name", style="htcli.value")
260
+ table.add_column("Encrypted", style="htcli.subtitle")
261
+ for i, w in enumerate(coldkeys):
262
+ encrypted_status = "✅ Yes" if w.get("is_encrypted") else "❌ No"
263
+ table.add_row(str(i + 1), w["name"], encrypted_status)
264
+ table.render()
265
+
266
+ choice = prompt_for_required(
267
+ "Select coldkey for the restored hotkey",
268
+ int,
269
+ f"Enter a number from 1 to {len(coldkeys)}",
270
+ )
271
+ coldkey_name = coldkeys[choice - 1]["name"]
272
+
273
+ # Get optional password
274
+ if password is None:
275
+ if confirm_prompt("Encrypt wallet with password?", default=True):
276
+ while True:
277
+ password = password_prompt("Enter password", confirm=True)
278
+ if validate_password_strength(password):
279
+ break
280
+
281
+ return WalletRestoreRequest(
282
+ name=name,
283
+ private_key=private_key,
284
+ mnemonic=mnemonic,
285
+ key_type=key_type,
286
+ wallet_type=wallet_type,
287
+ owner_address=coldkey_name,
288
+ password=password,
289
+ )
290
+
291
+
292
+ def prompt_preseeded_wallet(
293
+ preseeded_name: str,
294
+ custom_name: Optional[str] = None,
295
+ key_type: Optional[str] = None,
296
+ password: Optional[str] = None,
297
+ ) -> PreseededWalletRequest:
298
+ """Collect preseeded wallet import parameters."""
299
+
300
+ # Get custom name if not provided
301
+ if not custom_name:
302
+ custom_name = prompt_for_optional(
303
+ "Custom wallet name", str, f"Leave empty to use '{preseeded_name}'"
304
+ )
305
+
306
+ # Get key type with default
307
+ if not key_type:
308
+ key_type = "ecdsa" # Preseeded wallets default to ECDSA
309
+
310
+ # Get optional password
311
+ if password is None:
312
+ if confirm_prompt("Encrypt wallet with password?", default=False):
313
+ password = password_prompt("Enter password", confirm=True, min_length=8)
314
+
315
+ return PreseededWalletRequest(
316
+ preseeded_name=preseeded_name,
317
+ custom_name=custom_name,
318
+ key_type=key_type,
319
+ password=password,
320
+ )
321
+
322
+
323
+ def prompt_wallet_update(
324
+ current_name: Optional[str] = None,
325
+ new_name: Optional[str] = None,
326
+ new_password: Optional[str] = None,
327
+ remove_password: Optional[bool] = None,
328
+ new_coldkey: Optional[str] = None,
329
+ is_hotkey: bool = False,
330
+ coldkey_name: Optional[str] = None,
331
+ ) -> WalletUpdateRequest:
332
+ """Collect wallet update parameters."""
333
+
334
+ # Initialize disambiguation variables for hotkeys
335
+ owner_address = None
336
+ owner_coldkey_name = None
337
+
338
+ # Get current wallet name
339
+ if not current_name:
340
+ # Display list of existing wallets for user to choose from
341
+ console.print(info("📋 Available Wallets"))
342
+
343
+ all_keys = list_keys()
344
+ filtered_keys = [k for k in all_keys if k.get("is_hotkey", False) == is_hotkey]
345
+
346
+ if not filtered_keys:
347
+ wallet_type = "hotkeys" if is_hotkey else "coldkeys"
348
+ console.print(warning(f"No {wallet_type} found."))
349
+ raise ValueError(f"No {wallet_type} available to update")
350
+
351
+ # Display wallets in a table
352
+ table = HTCLITable(
353
+ title=f"Available {'Hotkeys' if is_hotkey else 'Coldkeys'}",
354
+ border_style="blue",
355
+ header_style="bold cyan",
356
+ )
357
+
358
+ if is_hotkey:
359
+ table.add_column("Index", style="bold white", width=6)
360
+ table.add_column("Name", style="bold cyan", width=25)
361
+ table.add_column("Coldkey", style="bold yellow", width=25)
362
+ table.add_column("Address", style="white", width=30)
363
+ table.add_column("Encrypted", style="dim", width=10)
364
+ else:
365
+ table.add_column("Index", style="bold white", width=8)
366
+ table.add_column("Name", style="bold cyan", width=30)
367
+ table.add_column("Address", style="white", width=40)
368
+ table.add_column("Encrypted", style="dim", width=12)
369
+
370
+ for i, wallet in enumerate(filtered_keys):
371
+ encrypted_status = "🔒 Yes" if wallet.get("is_encrypted", False) else "No"
372
+ # Format address in MetaMask style: 0x<first 5 chars>...<last 5 chars>
373
+ address = (
374
+ wallet.get("ss58_address")
375
+ or wallet.get("evm_address")
376
+ or wallet.get("address", "N/A")
377
+ )
378
+ if address != "N/A":
379
+ # Use the format_address_display function for consistent MetaMask-style formatting
380
+ address_display = format_address_display(address)
381
+ else:
382
+ address_display = address
383
+
384
+ if is_hotkey:
385
+ # Try to resolve coldkey name from coldkey address
386
+ coldkey_name_display = "Unknown"
387
+ coldkey_address = wallet.get("owner_address")
388
+ if coldkey_address:
389
+ # Try to find the coldkey with this address
390
+ for key in all_keys:
391
+ if key.get("ss58_address") == coldkey_address and not key.get(
392
+ "is_hotkey", False
393
+ ):
394
+ coldkey_name_display = key.get(
395
+ "name", coldkey_address[:20] + "..."
396
+ )
397
+ break
398
+ else:
399
+ coldkey_name_display = coldkey_address[:20] + "..."
400
+
401
+ table.add_row(
402
+ str(i + 1),
403
+ wallet.get("name", "N/A"),
404
+ coldkey_name_display,
405
+ address_display,
406
+ encrypted_status,
407
+ )
408
+ else:
409
+ table.add_row(
410
+ str(i + 1),
411
+ wallet.get("name", "N/A"),
412
+ address_display,
413
+ encrypted_status,
414
+ )
415
+
416
+ console.print(table.table)
417
+ console.print()
418
+
419
+ # Let user select by index
420
+ choice = prompt_for_required(
421
+ f"Select wallet to update (1-{len(filtered_keys)})",
422
+ int,
423
+ f"Enter a number from 1 to {len(filtered_keys)}",
424
+ )
425
+
426
+ if not (1 <= choice <= len(filtered_keys)):
427
+ raise ValueError(
428
+ f"Invalid selection. Please choose between 1 and {len(filtered_keys)}"
429
+ )
430
+
431
+ selected_wallet = filtered_keys[choice - 1]
432
+ current_name = selected_wallet["name"]
433
+
434
+ # For hotkeys, extract coldkey info from the selected wallet
435
+ if is_hotkey and not coldkey_name:
436
+ coldkey_address = selected_wallet.get("owner_address")
437
+ if coldkey_address:
438
+ # Find the coldkey name from the owner address
439
+ for key in all_keys:
440
+ if key.get("ss58_address") == coldkey_address and not key.get(
441
+ "is_hotkey", False
442
+ ):
443
+ coldkey_name = key.get("name")
444
+ break
445
+ else:
446
+ # If we can't find the coldkey name, we'll need to ask
447
+ # But try to get it from the wallet info
448
+ try:
449
+ wallet_info = get_wallet_info_by_name(
450
+ current_name, is_hotkey=True
451
+ )
452
+ # Try to find coldkey by address
453
+ for key in all_keys:
454
+ if key.get("ss58_address") == wallet_info.get(
455
+ "owner_address"
456
+ ) and not key.get("is_hotkey", False):
457
+ coldkey_name = key.get("name")
458
+ break
459
+ except FileNotFoundError:
460
+ pass
461
+
462
+ # If we have coldkey_name, set owner_address and owner_coldkey_name for disambiguation
463
+ if is_hotkey and coldkey_name:
464
+ try:
465
+ coldkey_info = get_wallet_info_by_name(coldkey_name, is_hotkey=False)
466
+ owner_address = (
467
+ coldkey_info.get("evm_address")
468
+ or coldkey_info.get("ss58_address")
469
+ or coldkey_info.get("address")
470
+ )
471
+ owner_coldkey_name = coldkey_name
472
+ except FileNotFoundError:
473
+ pass
474
+ else:
475
+ # Validate that the provided wallet name exists
476
+ # For hotkeys, use coldkey_name to disambiguate if provided
477
+ # If coldkey_name is NOT provided and wallet is ambiguous, prompt user
478
+
479
+ if is_hotkey and coldkey_name:
480
+ # Get the coldkey address to use for disambiguation
481
+ try:
482
+ coldkey_info = get_wallet_info_by_name(coldkey_name, is_hotkey=False)
483
+ # Prefer evm_address for ECDSA keys (Hypertensor EVM uses ECDSA)
484
+ # But also check ss58_address and address fields
485
+ owner_address = (
486
+ coldkey_info.get("evm_address")
487
+ or coldkey_info.get("ss58_address")
488
+ or coldkey_info.get("address")
489
+ )
490
+ if not owner_address:
491
+ raise ValueError(
492
+ f"Could not determine address for coldkey '{coldkey_name}'"
493
+ )
494
+ owner_coldkey_name = coldkey_name
495
+ except FileNotFoundError:
496
+ raise ValueError(
497
+ f"Coldkey '{coldkey_name}' not found. Please check the coldkey name."
498
+ )
499
+
500
+ # Try to get wallet info with disambiguation
501
+ try:
502
+ wallet_info = get_wallet_info_by_name(
503
+ current_name,
504
+ is_hotkey=is_hotkey,
505
+ owner_address=owner_address,
506
+ owner_coldkey_name=owner_coldkey_name,
507
+ )
508
+ except AmbiguousWalletError as e:
509
+ # FIX: Handle ambiguity by prompting user to select owner coldkey
510
+ if is_hotkey and len(e.matches) > 1:
511
+ console.print(
512
+ warning(
513
+ f"Found {len(e.matches)} hotkeys named '{current_name}'. Please select which one to update:"
514
+ )
515
+ )
516
+ table = HTCLITable(title="Select Hotkey")
517
+ table.add_column("Index", style="bold white", width=6)
518
+ table.add_column("Coldkey", style="bold yellow", width=25)
519
+ table.add_column("Address", style="white", width=30)
520
+
521
+ for i, match in enumerate(e.matches):
522
+ owner_ck = match.get("owner_coldkey_name", "Unknown")
523
+ address = (
524
+ match.get("evm_address")
525
+ or match.get("ss58_address")
526
+ or match.get("address", "N/A")
527
+ )
528
+ if address != "N/A":
529
+ address_display = format_address_display(address)
530
+ else:
531
+ address_display = address
532
+ table.add_row(str(i + 1), owner_ck, address_display)
533
+
534
+ console.print(table.table)
535
+ console.print()
536
+
537
+ choice = prompt_for_required(
538
+ f"Select hotkey to update (1-{len(e.matches)})",
539
+ int,
540
+ f"Enter a number from 1 to {len(e.matches)}",
541
+ )
542
+
543
+ if not (1 <= choice <= len(e.matches)):
544
+ raise ValueError(
545
+ f"Invalid selection. Please choose between 1 and {len(e.matches)}"
546
+ )
547
+
548
+ selected_hotkey = e.matches[choice - 1]
549
+ # Update coldkey_name from the selected hotkey
550
+ coldkey_name = selected_hotkey.get("owner_coldkey_name")
551
+ if coldkey_name:
552
+ # Get the coldkey address for disambiguation
553
+ try:
554
+ coldkey_info = get_wallet_info_by_name(
555
+ coldkey_name, is_hotkey=False
556
+ )
557
+ owner_address = (
558
+ coldkey_info.get("evm_address")
559
+ or coldkey_info.get("ss58_address")
560
+ or coldkey_info.get("address")
561
+ )
562
+ owner_coldkey_name = coldkey_name
563
+ except FileNotFoundError:
564
+ pass
565
+
566
+ # Retry with the selected coldkey
567
+ wallet_info = get_wallet_info_by_name(
568
+ current_name,
569
+ is_hotkey=is_hotkey,
570
+ owner_address=owner_address,
571
+ owner_coldkey_name=owner_coldkey_name,
572
+ )
573
+ else:
574
+ # Re-raise if we can't handle it
575
+ raise
576
+ except ValueError as e:
577
+ # Handle old-style ValueError for backward compatibility
578
+ error_str = str(e)
579
+ if "ambiguous" in error_str.lower() and is_hotkey:
580
+ # Extract the matches from the error message or find them ourselves
581
+ all_keys = list_keys()
582
+ hotkey_matches = [
583
+ k
584
+ for k in all_keys
585
+ if k.get("is_hotkey", False) and k.get("name") == current_name
586
+ ]
587
+
588
+ if len(hotkey_matches) > 1:
589
+ console.print(
590
+ warning(
591
+ f"Found {len(hotkey_matches)} hotkeys named '{current_name}'. Please select which one to update:"
592
+ )
593
+ )
594
+ table = HTCLITable(title="Select Hotkey")
595
+ table.add_column("Index", style="bold white", width=6)
596
+ table.add_column("Coldkey", style="bold yellow", width=25)
597
+ table.add_column("Address", style="white", width=30)
598
+
599
+ for i, match in enumerate(hotkey_matches):
600
+ owner_ck = match.get("owner_coldkey_name", "Unknown")
601
+ address = (
602
+ match.get("evm_address")
603
+ or match.get("ss58_address")
604
+ or match.get("address", "N/A")
605
+ )
606
+ if address != "N/A":
607
+ address_display = format_address_display(address)
608
+ else:
609
+ address_display = address
610
+ table.add_row(str(i + 1), owner_ck, address_display)
611
+
612
+ console.print(table.table)
613
+ console.print()
614
+
615
+ choice = prompt_for_required(
616
+ f"Select hotkey to update (1-{len(hotkey_matches)})",
617
+ int,
618
+ f"Enter a number from 1 to {len(hotkey_matches)}",
619
+ )
620
+
621
+ if not (1 <= choice <= len(hotkey_matches)):
622
+ raise ValueError(
623
+ f"Invalid selection. Please choose between 1 and {len(hotkey_matches)}"
624
+ )
625
+
626
+ selected_hotkey = hotkey_matches[choice - 1]
627
+ coldkey_name = selected_hotkey.get("owner_coldkey_name")
628
+ if coldkey_name:
629
+ try:
630
+ coldkey_info = get_wallet_info_by_name(
631
+ coldkey_name, is_hotkey=False
632
+ )
633
+ owner_address = (
634
+ coldkey_info.get("evm_address")
635
+ or coldkey_info.get("ss58_address")
636
+ or coldkey_info.get("address")
637
+ )
638
+ owner_coldkey_name = coldkey_name
639
+ except FileNotFoundError:
640
+ pass
641
+
642
+ wallet_info = get_wallet_info_by_name(
643
+ current_name,
644
+ is_hotkey=is_hotkey,
645
+ owner_address=owner_address,
646
+ owner_coldkey_name=owner_coldkey_name,
647
+ )
648
+ else:
649
+ raise
650
+ else:
651
+ raise
652
+ except FileNotFoundError as err:
653
+ # FIX: Check if wallet exists but under different owner to provide better error
654
+ found_broken = None
655
+ try:
656
+ # Try finding it without owner constraint
657
+ found_broken = get_wallet_info_by_name(
658
+ current_name, is_hotkey=is_hotkey
659
+ )
660
+ except Exception:
661
+ # Still not found or ambiguous
662
+ pass
663
+
664
+ if found_broken:
665
+ # If we get here, it exists!
666
+ msg = f"Wallet '{current_name}' not found"
667
+ if coldkey_name:
668
+ msg += f" under owner '{coldkey_name}'"
669
+
670
+ actual_owner = found_broken.get("owner_coldkey_name")
671
+ if actual_owner:
672
+ msg += f". It appears to be owned by '{actual_owner}'."
673
+ if is_hotkey:
674
+ msg += (
675
+ f"\n\nTry running with '--coldkey {actual_owner}' instead."
676
+ )
677
+ else:
678
+ msg += ". Please check the wallet owner."
679
+
680
+ raise ValueError(msg) from err
681
+
682
+ # Use specific generic error if totally not found
683
+ raise ValueError(
684
+ f"Wallet '{current_name}' not found. Please check the wallet name."
685
+ ) from err
686
+
687
+ wallet_type_actual = wallet_info.get("is_hotkey", False)
688
+
689
+ # Validate wallet type matches expected type
690
+ if wallet_type_actual != is_hotkey:
691
+ expected = "hotkey" if is_hotkey else "coldkey"
692
+ actual = "hotkey" if wallet_type_actual else "coldkey"
693
+ raise ValueError(
694
+ f"Wallet '{current_name}' is a {actual}, but you're trying to update a {expected}. "
695
+ f"Use 'htcli wallet update-{'hotkey' if wallet_type_actual else 'coldkey'}' instead."
696
+ )
697
+
698
+ # For hotkeys, get the coldkey name only if we still don't have it (e.g., when wallet name provided via CLI)
699
+ if is_hotkey and not coldkey_name:
700
+ coldkey_name = prompt_for_required(
701
+ "Current coldkey name",
702
+ str,
703
+ "Specify which coldkey owns this hotkey",
704
+ validator=validate_wallet_name,
705
+ )
706
+ # Get owner address for disambiguation if we just got the coldkey name
707
+ if coldkey_name:
708
+ try:
709
+ coldkey_info = get_wallet_info_by_name(coldkey_name, is_hotkey=False)
710
+ owner_address = (
711
+ coldkey_info.get("evm_address")
712
+ or coldkey_info.get("ss58_address")
713
+ or coldkey_info.get("address")
714
+ )
715
+ owner_coldkey_name = coldkey_name
716
+ except FileNotFoundError:
717
+ raise ValueError(
718
+ f"Coldkey '{coldkey_name}' not found. Please check the coldkey name."
719
+ )
720
+
721
+ # Ask what to update
722
+ console.print(info("What would you like to update?"))
723
+
724
+ # Get new name if not provided
725
+ if new_name is None:
726
+ prompt_text = "New hotkey name" if is_hotkey else "New wallet name"
727
+ new_name = prompt_for_optional(
728
+ prompt_text,
729
+ str,
730
+ "Leave empty to keep current name",
731
+ validator=validate_wallet_name,
732
+ )
733
+
734
+ # Check if wallet is encrypted - use disambiguation if available
735
+ wallet_info = get_wallet_info_by_name(
736
+ current_name,
737
+ is_hotkey=is_hotkey,
738
+ owner_address=owner_address if is_hotkey else None,
739
+ owner_coldkey_name=owner_coldkey_name if is_hotkey else None,
740
+ )
741
+ is_currently_encrypted = wallet_info.get("is_encrypted", False)
742
+
743
+ # Password update options - only show if wallet has password protection
744
+ current_password = None
745
+ if new_password is None and remove_password is None:
746
+ if is_currently_encrypted:
747
+ # Wallet is password-protected, ask what to do
748
+ password_action = select_prompt(
749
+ "Password action",
750
+ choices=[
751
+ ("keep", "Keep current password", "keep"),
752
+ ("change", "Change password", "change"),
753
+ ("remove", "Remove password protection", "remove"),
754
+ ],
755
+ default="keep",
756
+ )
757
+
758
+ if password_action == "change":
759
+ # Ask for current password to decrypt (no min_length validation for current password)
760
+ current_password = password_prompt(
761
+ "Enter current password to decrypt wallet",
762
+ min_length=0, # Don't validate current password length
763
+ )
764
+ # Then ask for new password
765
+ while True:
766
+ new_password = password_prompt("Enter new password", confirm=True)
767
+ if validate_password_strength(new_password):
768
+ break
769
+ elif password_action == "remove":
770
+ # Ask for current password to decrypt before removing (no min_length validation for current password)
771
+ current_password = password_prompt(
772
+ "Enter current password to decrypt wallet",
773
+ min_length=0, # Don't validate current password length
774
+ )
775
+ remove_password = True
776
+ else:
777
+ # Wallet is not password-protected, ask if user wants to add protection
778
+ if confirm_prompt(
779
+ "This wallet is not password-protected. Would you like to add password protection?",
780
+ default=False,
781
+ ):
782
+ while True:
783
+ new_password = password_prompt("Enter new password", confirm=True)
784
+ if validate_password_strength(new_password):
785
+ break
786
+
787
+ # Get new coldkey for hotkeys
788
+ if is_hotkey and new_coldkey is None:
789
+ # First, get current coldkey info to display
790
+ current_coldkey_name = "Unknown"
791
+ current_coldkey_address = "Unknown"
792
+ try:
793
+ wallet_info = get_wallet_info_by_name(current_name, is_hotkey=is_hotkey)
794
+ if coldkey_name: # If we have the coldkey name from earlier
795
+ try:
796
+ coldkey_info = get_wallet_info_by_name(
797
+ coldkey_name, is_hotkey=False
798
+ )
799
+ current_coldkey_name = coldkey_info.get("name", coldkey_name)
800
+ current_coldkey_address = coldkey_info.get(
801
+ "ss58_address", "Unknown"
802
+ )
803
+ except FileNotFoundError:
804
+ current_coldkey_name = coldkey_name
805
+ except (FileNotFoundError, KeyError):
806
+ pass
807
+
808
+ # Ask user if they want to change coldkey
809
+ console.print(info("\n💰 Current Coldkey Information:"))
810
+ console.print(f" Coldkey Name: {current_coldkey_name}")
811
+ console.print(f" Coldkey Address: {current_coldkey_address}\n")
812
+
813
+ change_coldkey = confirm_prompt(
814
+ "Do you want to change the coldkey for this hotkey?",
815
+ default=False,
816
+ )
817
+
818
+ if change_coldkey:
819
+ new_coldkey = prompt_for_required(
820
+ "New coldkey name",
821
+ str,
822
+ "Enter the name of the new coldkey",
823
+ validator=validate_wallet_name,
824
+ )
825
+ else:
826
+ new_coldkey = None
827
+
828
+ return WalletUpdateRequest(
829
+ current_name=current_name,
830
+ new_name=new_name,
831
+ new_password=new_password,
832
+ remove_password=remove_password or False,
833
+ current_password=current_password,
834
+ new_owner=new_coldkey,
835
+ owner_name=coldkey_name if is_hotkey else None,
836
+ )
837
+
838
+
839
+ def prompt_wallet_deletion(
840
+ coldkey: Optional[str] = None,
841
+ hotkey: Optional[str] = None,
842
+ force: Optional[bool] = None,
843
+ ) -> WalletDeleteRequest:
844
+ """Collect wallet deletion parameters.
845
+
846
+ Args:
847
+ coldkey: Coldkey name to delete (required for coldkey deletion or non-orphaned hotkey deletion)
848
+ hotkey: Hotkey name to delete (optional, requires coldkey unless hotkey is orphaned)
849
+ force: Skip confirmation prompts
850
+ """
851
+ # Validate parameters
852
+ if not coldkey and not hotkey:
853
+ raise ValueError("Either --coldkey or --hotkey must be provided")
854
+
855
+ if hotkey:
856
+ # Deleting a hotkey
857
+ if not validate_wallet_name(hotkey):
858
+ raise ValueError(f"Invalid hotkey name: {hotkey}")
859
+ wallet_name = hotkey
860
+ is_hotkey = True
861
+
862
+ if coldkey:
863
+ # Owner specified - validate coldkey name
864
+ if not validate_wallet_name(coldkey):
865
+ raise ValueError(f"Invalid coldkey name: {coldkey}")
866
+ # else: orphaned hotkey - no coldkey validation needed
867
+ else:
868
+ # Deleting a coldkey - coldkey is required
869
+ if not coldkey:
870
+ raise ValueError("--coldkey is required for coldkey deletion")
871
+ if not validate_wallet_name(coldkey):
872
+ raise ValueError(f"Invalid coldkey name: {coldkey}")
873
+ wallet_name = coldkey
874
+ is_hotkey = False
875
+
876
+ names = [wallet_name]
877
+
878
+ # Check if wallet exists
879
+ wallet_owners: dict[str, Optional[str]] = (
880
+ {}
881
+ ) # name -> owner_address for disambiguation
882
+ owner_context: dict[str, dict[str, Optional[str]]] = {}
883
+
884
+ if is_hotkey:
885
+ # For hotkey deletion
886
+ if coldkey:
887
+ # Owner specified - we know the owner
888
+ wallet_owners[wallet_name] = None # Will be resolved from coldkey
889
+ owner_context[wallet_name] = {"address": None, "coldkey": coldkey}
890
+ else:
891
+ # Orphaned hotkey - no owner
892
+ wallet_owners[wallet_name] = None
893
+ owner_context[wallet_name] = {"address": None, "coldkey": None}
894
+ else:
895
+ # For coldkey deletion, no owner needed
896
+ wallet_owners[wallet_name] = None
897
+ owner_context[wallet_name] = {"address": None, "coldkey": wallet_name}
898
+
899
+ # Verify wallet exists
900
+ try:
901
+ if is_hotkey:
902
+ if coldkey:
903
+ # Get coldkey info to resolve owner address
904
+ coldkey_info = get_wallet_info_by_name(coldkey, is_hotkey=False)
905
+ owner_address = (
906
+ coldkey_info.get("evm_address")
907
+ or coldkey_info.get("ss58_address")
908
+ or coldkey_info.get("address")
909
+ )
910
+ owner_context[wallet_name]["address"] = owner_address
911
+
912
+ # Check if hotkey exists
913
+ hotkey_info = get_wallet_info_by_name(
914
+ wallet_name,
915
+ is_hotkey=True,
916
+ owner_address=owner_address,
917
+ owner_coldkey_name=coldkey,
918
+ )
919
+ else:
920
+ # Orphaned hotkey - find it without owner context
921
+ all_keys = list_keys()
922
+ matching_hotkeys = [
923
+ k
924
+ for k in all_keys
925
+ if k.get("is_hotkey", False) and k.get("name") == wallet_name
926
+ ]
927
+
928
+ if not matching_hotkeys:
929
+ raise FileNotFoundError(f"Hotkey '{wallet_name}' not found")
930
+
931
+ # Use first match (for orphaned hotkeys, there should typically be only one)
932
+ hotkey_info = matching_hotkeys[0]
933
+ owner_address = hotkey_info.get("owner_address")
934
+ owner_coldkey_name = hotkey_info.get("owner_coldkey_name")
935
+ owner_context[wallet_name]["address"] = owner_address
936
+ owner_context[wallet_name]["coldkey"] = owner_coldkey_name
937
+
938
+ # Verify the hotkey file exists by trying to get wallet info
939
+ hotkey_info_verified = get_wallet_info_by_name(
940
+ wallet_name,
941
+ is_hotkey=True,
942
+ owner_address=owner_address,
943
+ owner_coldkey_name=owner_coldkey_name,
944
+ )
945
+ else:
946
+ # Check if coldkey exists
947
+ coldkey_info = get_wallet_info_by_name(wallet_name, is_hotkey=False)
948
+ except (ValueError, FileNotFoundError) as e:
949
+ console.print(warning(f"❌ Wallet does not exist: {str(e)}"))
950
+ raise KeyboardInterrupt("Operation cancelled")
951
+
952
+ # Get confirmation with granular control
953
+ if not force:
954
+ # Collect deletion decision
955
+ deletion_plan = {}
956
+ owner_meta = owner_context.get(wallet_name, {})
957
+ owner_address = owner_meta.get("address")
958
+ owner_coldkey_name = owner_meta.get("coldkey")
959
+
960
+ if is_hotkey:
961
+ # Deleting a hotkey
962
+ owner_display = (
963
+ owner_coldkey_name
964
+ if owner_coldkey_name
965
+ else "orphaned (no valid owner)"
966
+ )
967
+ console.print(
968
+ warning(f"⚠️ Deleting hotkey: {wallet_name} (owned by {owner_display})")
969
+ )
970
+ deletion_plan[wallet_name] = {
971
+ "type": "hotkey",
972
+ "delete_hotkeys": False,
973
+ "associated_hotkeys": [],
974
+ }
975
+ else:
976
+ # Deleting a coldkey - check for associated hotkeys
977
+ wallet_info = get_wallet_info_by_name(wallet_name, is_hotkey=False)
978
+ all_keys = list_keys()
979
+
980
+ # Get all possible address formats from coldkey info
981
+ coldkey_addresses = set()
982
+ if wallet_info.get("address"):
983
+ coldkey_addresses.add(wallet_info["address"])
984
+ if wallet_info.get("ss58_address"):
985
+ coldkey_addresses.add(wallet_info["ss58_address"])
986
+ if wallet_info.get("evm_address"):
987
+ coldkey_addresses.add(wallet_info["evm_address"])
988
+
989
+ # Find hotkeys by matching any address format OR owner_coldkey_name
990
+ associated_hotkeys = []
991
+ for key in all_keys:
992
+ if not key.get("is_hotkey", False):
993
+ continue
994
+
995
+ # Check if owner_address matches any of the coldkey's address formats
996
+ owner_address = key.get("owner_address")
997
+ owner_matches = owner_address and owner_address in coldkey_addresses
998
+
999
+ # Check if owner_coldkey_name matches
1000
+ owner_name_matches = key.get("owner_coldkey_name") == wallet_name
1001
+
1002
+ if owner_matches or owner_name_matches:
1003
+ associated_hotkeys.append(key)
1004
+
1005
+ console.print(warning(f"⚠️ Deleting coldkey: {wallet_name}"))
1006
+
1007
+ if associated_hotkeys:
1008
+ console.print(f"Found {len(associated_hotkeys)} associated hotkey(s):")
1009
+ for hotkey in associated_hotkeys:
1010
+ console.print(f" • {hotkey['name']}")
1011
+
1012
+ # Ask about deleting associated hotkeys
1013
+ delete_hotkeys = confirm_prompt(
1014
+ f"Delete {len(associated_hotkeys)} associated hotkey(s) along with coldkey '{wallet_name}'?",
1015
+ default=True,
1016
+ )
1017
+
1018
+ deletion_plan[wallet_name] = {
1019
+ "type": "coldkey",
1020
+ "delete_hotkeys": delete_hotkeys,
1021
+ "associated_hotkeys": associated_hotkeys if delete_hotkeys else [],
1022
+ }
1023
+ else:
1024
+ console.print(
1025
+ f"No associated hotkeys found for coldkey '{wallet_name}'"
1026
+ )
1027
+ deletion_plan[wallet_name] = {
1028
+ "type": "coldkey",
1029
+ "delete_hotkeys": False,
1030
+ "associated_hotkeys": [],
1031
+ }
1032
+
1033
+ console.print()
1034
+ console.print(warning("🗑️ Deletion Summary:"))
1035
+ for name, plan in deletion_plan.items():
1036
+ if plan["type"] == "coldkey":
1037
+ if plan["delete_hotkeys"] and plan["associated_hotkeys"]:
1038
+ console.print(
1039
+ f" • {name} (coldkey) + {len(plan['associated_hotkeys'])} hotkey(s):"
1040
+ )
1041
+ # Show hotkey names and owner
1042
+ for hotkey in plan["associated_hotkeys"]:
1043
+ hotkey_name = hotkey.get("name", "Unknown")
1044
+ console.print(f" - {hotkey_name} (owned by {name})")
1045
+ else:
1046
+ console.print(
1047
+ f" • {name} (coldkey only - hotkeys will become orphaned)"
1048
+ )
1049
+ else:
1050
+ console.print(f" • {name} (hotkey)")
1051
+
1052
+ console.print()
1053
+ console.print(warning("This action cannot be undone!"))
1054
+
1055
+ confirmed = confirm_prompt(
1056
+ "Proceed with the deletion plan above?", default=False
1057
+ )
1058
+
1059
+ if not confirmed:
1060
+ raise KeyboardInterrupt("Operation cancelled")
1061
+
1062
+ # Store the deletion plan in the request
1063
+ return WalletDeleteRequest(
1064
+ names=names,
1065
+ force=force,
1066
+ deletion_plan=deletion_plan,
1067
+ wallet_owners=wallet_owners,
1068
+ wallet_owner_context=owner_context,
1069
+ )
1070
+
1071
+ return WalletDeleteRequest(
1072
+ names=names,
1073
+ force=force,
1074
+ wallet_owners=wallet_owners,
1075
+ wallet_owner_context=owner_context,
1076
+ )
1077
+
1078
+
1079
+ def prompt_wallet_transfer(
1080
+ from_wallet: Optional[str] = None,
1081
+ to_address: Optional[str] = None,
1082
+ amount: Optional[str] = None,
1083
+ password: Optional[str] = None,
1084
+ ) -> WalletTransferRequest:
1085
+ """Collect wallet transfer parameters."""
1086
+ # Get source wallet
1087
+ if not from_wallet:
1088
+ keys = list_keys()
1089
+ if not keys:
1090
+ raise Exception("No wallets found.")
1091
+
1092
+ # Filter out hotkeys for transfer (only coldkeys have balances)
1093
+ coldkeys = [key for key in keys if not key.get("is_hotkey", False)]
1094
+
1095
+ if not coldkeys:
1096
+ raise Exception(
1097
+ "No coldkey wallets found. Only coldkeys can be used for transfers."
1098
+ )
1099
+
1100
+ console.print("\n[bold]Available coldkey wallets:[/bold]")
1101
+ for i, key in enumerate(coldkeys, 1):
1102
+ # Coldkey styling - blue/cyan theme
1103
+ key_type = "[bold cyan]Coldkey[/bold cyan]"
1104
+ name_color = "[bold cyan]"
1105
+ address_color = "[cyan]"
1106
+
1107
+ console.print(
1108
+ f"{i}. {name_color}{key['name']}[/] ({key_type}) - {address_color}{key['ss58_address']}[/]"
1109
+ )
1110
+
1111
+ while True:
1112
+ try:
1113
+ choice = integer_prompt(
1114
+ f"\nEnter wallet number (1-{len(coldkeys)})",
1115
+ min_value=1,
1116
+ max_value=len(coldkeys),
1117
+ )
1118
+ from_wallet = coldkeys[choice - 1]["name"]
1119
+ break
1120
+ except (ValueError, IndexError):
1121
+ console.print(error("Invalid selection. Please try again."))
1122
+
1123
+ # Get destination address
1124
+ if not to_address:
1125
+ # Show destination options
1126
+ dest_choice = select_prompt(
1127
+ "Destination type",
1128
+ choices=[
1129
+ ("wallet", "Select wallet", "wallet"),
1130
+ ("address", "Enter address", "address"),
1131
+ ],
1132
+ default="wallet",
1133
+ )
1134
+
1135
+ if dest_choice == "wallet":
1136
+ # Show coldkey wallets for destination selection
1137
+ keys = list_keys()
1138
+ coldkeys = [key for key in keys if not key.get("is_hotkey", False)]
1139
+
1140
+ if not coldkeys:
1141
+ raise Exception("No coldkey wallets found for destination.")
1142
+
1143
+ console.print("\n[bold]Available destination wallets:[/bold]")
1144
+ for i, key in enumerate(coldkeys, 1):
1145
+ # Coldkey styling - blue/cyan theme
1146
+ key_type = "[bold cyan]Coldkey[/bold cyan]"
1147
+ name_color = "[bold cyan]"
1148
+ address_color = "[cyan]"
1149
+
1150
+ console.print(
1151
+ f"{i}. {name_color}{key['name']}[/] ({key_type}) - {address_color}{key['ss58_address']}[/]"
1152
+ )
1153
+
1154
+ while True:
1155
+ try:
1156
+ choice = integer_prompt(
1157
+ f"\nEnter destination wallet number (1-{len(coldkeys)})",
1158
+ min_value=1,
1159
+ max_value=len(coldkeys),
1160
+ )
1161
+ dest_wallet_info = coldkeys[choice - 1]
1162
+
1163
+ # Check if destination is the same as source wallet
1164
+ if dest_wallet_info["name"] == from_wallet:
1165
+ console.print(
1166
+ error(
1167
+ "❌ Cannot transfer to the same wallet. Please select a different destination."
1168
+ )
1169
+ )
1170
+ continue
1171
+
1172
+ to_address = dest_wallet_info["ss58_address"]
1173
+ console.print(
1174
+ f"[green]✓[/green] Selected destination: {dest_wallet_info['name']} ({to_address})"
1175
+ )
1176
+ break
1177
+ except (ValueError, IndexError):
1178
+ console.print(error("Invalid selection. Please try again."))
1179
+ else:
1180
+ # Use text_prompt for direct address input
1181
+ to_address = text_prompt("Destination address", required=True)
1182
+
1183
+ # Try to resolve as wallet name if not a valid address
1184
+ from ...utils.blockchain.validation import detect_address_type
1185
+
1186
+ if detect_address_type(to_address)[0] == "unknown":
1187
+ # Check if it's a valid wallet name
1188
+ from ...utils.blockchain.validation import validate_wallet_name
1189
+
1190
+ if validate_wallet_name(to_address):
1191
+ try:
1192
+ # Try to resolve as wallet name
1193
+ dest_wallet_info = get_wallet_info_by_name(
1194
+ to_address, is_hotkey=False
1195
+ )
1196
+
1197
+ # Check if destination is the same as source wallet
1198
+ if dest_wallet_info["name"] == from_wallet:
1199
+ console.print(
1200
+ error(
1201
+ "❌ Cannot transfer to the same wallet. Please enter a different destination."
1202
+ )
1203
+ )
1204
+ raise Exception("Cannot transfer to the same wallet.")
1205
+
1206
+ console.print(
1207
+ f"[green]✓[/green] Resolved wallet '{to_address}' to address: {dest_wallet_info['ss58_address']}"
1208
+ )
1209
+ to_address = dest_wallet_info["ss58_address"]
1210
+ except FileNotFoundError:
1211
+ console.print(
1212
+ error(f"Destination wallet '{to_address}' not found.")
1213
+ )
1214
+ raise Exception(
1215
+ f"Destination wallet '{to_address}' not found."
1216
+ ) from None
1217
+ except Exception as e:
1218
+ console.print(
1219
+ error(
1220
+ f"Error accessing destination wallet '{to_address}': {str(e)}"
1221
+ )
1222
+ )
1223
+ raise Exception(
1224
+ f"Error accessing destination wallet '{to_address}': {str(e)}"
1225
+ ) from e
1226
+ else:
1227
+ console.print(
1228
+ error("Invalid destination address or wallet name format.")
1229
+ )
1230
+ raise Exception(
1231
+ "Invalid destination address or wallet name format."
1232
+ )
1233
+
1234
+ # Get amount with validation
1235
+ if not amount:
1236
+ # Get sender's balance for validation
1237
+ try:
1238
+ from ...dependencies import get_client
1239
+
1240
+ client = get_client()
1241
+ wallet_info = get_wallet_info_by_name(from_wallet, is_hotkey=False)
1242
+ balance_response = client.rpc.wallet.get_balance(
1243
+ wallet_info["ss58_address"]
1244
+ )
1245
+
1246
+ if balance_response.success:
1247
+ max_balance = balance_response.balance / 1e18 # Convert wei to TENSOR
1248
+ console.print(info(f"💰 Your balance: {max_balance:,.4f} TENSOR"))
1249
+
1250
+ # Set reasonable maximum (either balance or 1 billion TENSOR, whichever is lower)
1251
+ max_amount = min(max_balance, 1_000_000_000) # 1 billion TENSOR max
1252
+ else:
1253
+ # If can't get balance, set a reasonable default max
1254
+ max_amount = 1_000_000_000 # 1 billion TENSOR
1255
+ console.print(info("⚠️ Could not fetch balance, using default limit"))
1256
+ except Exception as e:
1257
+ console.print(info(f"⚠️ Balance check failed: {str(e)}"))
1258
+ max_amount = 1_000_000_000 # 1 billion TENSOR default
1259
+
1260
+ amount_float = amount_prompt(
1261
+ "Transfer amount",
1262
+ currency="TENSOR",
1263
+ min_amount=0.000001,
1264
+ max_amount=max_amount,
1265
+ )
1266
+ amount = str(amount_float)
1267
+ else:
1268
+ # Validate CLI-provided amount
1269
+ try:
1270
+ amount_float = float(amount)
1271
+ if amount_float <= 0:
1272
+ raise ValueError("Amount must be positive")
1273
+ if amount_float > 1_000_000_000: # 1 billion TENSOR
1274
+ raise ValueError("Amount too large. Maximum: 1,000,000,000 TENSOR")
1275
+ if amount_float < 0.000001:
1276
+ raise ValueError("Amount too small. Minimum: 0.000001 TENSOR")
1277
+ except ValueError as e:
1278
+ console.print(error(f"Invalid amount: {str(e)}"))
1279
+ raise
1280
+
1281
+ # Get password if needed
1282
+ if password is None:
1283
+ # Check if the wallet is encrypted
1284
+ try:
1285
+ wallet_info = get_wallet_info_by_name(from_wallet, is_hotkey=False)
1286
+ if wallet_info.get("encrypted", False):
1287
+ console.print(info("Wallet password required for encrypted wallet"))
1288
+ password = password_prompt("Enter wallet password", min_length=1)
1289
+ else:
1290
+ console.print(info("Wallet is not encrypted, no password needed"))
1291
+ password = None
1292
+ except Exception as e:
1293
+ # If we can't determine encryption status, skip password prompt
1294
+ console.print(
1295
+ info(f"Could not determine wallet encryption status: {str(e)}")
1296
+ )
1297
+ console.print(info("Skipping password prompt"))
1298
+ password = None
1299
+
1300
+ return WalletTransferRequest(
1301
+ from_wallet=from_wallet, to_address=to_address, amount=amount, password=password
1302
+ )
1303
+
1304
+
1305
+ def prompt_wallet_selection(prompt_text: str = "Select wallet") -> str:
1306
+ """Interactive prompt for wallet selection."""
1307
+ keys = list_keys()
1308
+ if not keys:
1309
+ raise Exception("No wallets found.")
1310
+
1311
+ console.print(f"\n[bold]{prompt_text}:[/bold]")
1312
+ for i, key in enumerate(keys, 1):
1313
+ if key.get("is_hotkey", False):
1314
+ # Hotkey styling - yellow/orange theme
1315
+ key_type = "[bold yellow]Hotkey[/bold yellow]"
1316
+ name_color = "[bold yellow]"
1317
+ address_color = "[yellow]"
1318
+ else:
1319
+ # Coldkey styling - blue/cyan theme
1320
+ key_type = "[bold cyan]Coldkey[/bold cyan]"
1321
+ name_color = "[bold cyan]"
1322
+ address_color = "[cyan]"
1323
+
1324
+ console.print(
1325
+ f"{i}. {name_color}{key['name']}[/] ({key_type}) - {address_color}{key['ss58_address']}[/]"
1326
+ )
1327
+
1328
+ while True:
1329
+ try:
1330
+ choice = integer_prompt(
1331
+ f"Enter wallet number (1-{len(keys)})",
1332
+ min_value=1,
1333
+ max_value=len(keys),
1334
+ )
1335
+ return keys[choice - 1]["name"]
1336
+ except (ValueError, IndexError):
1337
+ console.print(error("Invalid selection. Please try again."))
1338
+
1339
+
1340
+ def prompt_wallet_balance(
1341
+ wallet_name: Optional[str] = None,
1342
+ address: Optional[str] = None,
1343
+ show_all: bool = False,
1344
+ format_type: Optional[str] = None,
1345
+ show_guidance: Optional[bool] = None,
1346
+ ) -> tuple[Optional[str], Optional[str], bool, str, bool]:
1347
+ """Interactive prompt for balance arguments."""
1348
+ # If wallet_name is provided, check if it's actually an address
1349
+ if wallet_name:
1350
+ from ...utils.blockchain.validation import detect_address_type
1351
+
1352
+ addr_type, _, is_valid = detect_address_type(wallet_name)
1353
+ if is_valid and addr_type == "ecdsa":
1354
+ # It's an Ethereum address, move it to address parameter
1355
+ address = wallet_name
1356
+ wallet_name = None
1357
+
1358
+ if not wallet_name and not address and not show_all:
1359
+ choice = select_prompt(
1360
+ "Check balance by",
1361
+ choices=[
1362
+ ("wallet", "Specific wallet", "wallet"),
1363
+ ("address", "Specific address", "address"),
1364
+ ("all", "All coldkey wallets", "all"),
1365
+ ],
1366
+ default="wallet",
1367
+ )
1368
+ if choice == "wallet":
1369
+ keys = list_keys()
1370
+ if not keys:
1371
+ raise Exception("No wallets found.")
1372
+
1373
+ # Filter out hotkeys for balance check (only coldkeys have balances)
1374
+ coldkeys = [key for key in keys if not key.get("is_hotkey", False)]
1375
+
1376
+ if not coldkeys:
1377
+ raise Exception(
1378
+ "No coldkey wallets found. Only coldkeys have balances."
1379
+ )
1380
+
1381
+ console.print("\n[bold]Available coldkey wallets:[/bold]")
1382
+ for i, key in enumerate(coldkeys, 1):
1383
+ # Coldkey styling - blue/cyan theme
1384
+ key_type = "[bold cyan]Coldkey[/bold cyan]"
1385
+ name_color = "[bold cyan]"
1386
+ address_color = "[cyan]"
1387
+
1388
+ console.print(
1389
+ f"{i}. {name_color}{key['name']}[/] ({key_type}) - {address_color}{key['ss58_address']}[/]"
1390
+ )
1391
+ while True:
1392
+ try:
1393
+ choice = integer_prompt(
1394
+ f"\nEnter wallet number (1-{len(coldkeys)})",
1395
+ min_value=1,
1396
+ max_value=len(coldkeys),
1397
+ )
1398
+ wallet_name = coldkeys[choice - 1]["name"]
1399
+ break
1400
+ except (ValueError, IndexError):
1401
+ console.print(error("Invalid selection. Please try again."))
1402
+ elif choice == "address":
1403
+ from ...utils.blockchain.validation import detect_address_type
1404
+
1405
+ while True:
1406
+ address = text_prompt("Enter address to check balance")
1407
+ if detect_address_type(address)[0] != "unknown":
1408
+ break
1409
+ else:
1410
+ console.print("Invalid address format.")
1411
+ else: # all
1412
+ show_all = True
1413
+
1414
+ if format_type is None:
1415
+ format_type = select_prompt(
1416
+ "Output format", choices=["table", "json"], default="table"
1417
+ )
1418
+
1419
+ if show_guidance is None:
1420
+ show_guidance = confirm_prompt("Show comprehensive guidance?", default=True)
1421
+
1422
+ return wallet_name, address, show_all, format_type, show_guidance
1423
+
1424
+
1425
+ def prompt_identity_update(
1426
+ hotkey_name: Optional[str] = None,
1427
+ name: Optional[str] = None,
1428
+ url: Optional[str] = None,
1429
+ image: Optional[str] = None,
1430
+ discord: Optional[str] = None,
1431
+ x: Optional[str] = None,
1432
+ telegram: Optional[str] = None,
1433
+ github: Optional[str] = None,
1434
+ hugging_face: Optional[str] = None,
1435
+ description: Optional[str] = None,
1436
+ misc: Optional[str] = None,
1437
+ password: Optional[str] = None,
1438
+ ):
1439
+ """Collect identity update parameters."""
1440
+ from ...models.requests.identity import IdentityRegisterRequest
1441
+
1442
+ # Get hotkey name if not provided
1443
+ if not hotkey_name:
1444
+ hotkey_name = prompt_wallet_selection("Select hotkey to update identity")
1445
+
1446
+ # Get identity fields
1447
+ if name is None:
1448
+ name = prompt_for_optional(
1449
+ "Display Name", str, "Enter display name for identity"
1450
+ )
1451
+
1452
+ if description is None:
1453
+ description = prompt_for_optional("Description", str, "Enter short description")
1454
+
1455
+ if url is None:
1456
+ url = prompt_for_optional("Website URL", str, "Enter website URL")
1457
+
1458
+ if image is None:
1459
+ image = prompt_for_optional("Image URL", str, "Enter profile image URL")
1460
+
1461
+ if discord is None:
1462
+ discord = prompt_for_optional("Discord Handle", str, "Enter Discord handle")
1463
+
1464
+ if x is None:
1465
+ x = prompt_for_optional("X (Twitter) Handle", str, "Enter X handle")
1466
+
1467
+ if telegram is None:
1468
+ telegram = prompt_for_optional("Telegram Handle", str, "Enter Telegram handle")
1469
+
1470
+ if github is None:
1471
+ github = prompt_for_optional("GitHub URL", str, "Enter GitHub profile URL")
1472
+
1473
+ if hugging_face is None:
1474
+ hugging_face = prompt_for_optional(
1475
+ "Hugging Face URL", str, "Enter Hugging Face profile URL"
1476
+ )
1477
+
1478
+ if misc is None:
1479
+ misc = prompt_for_optional("Misc Data", str, "Enter any additional data")
1480
+
1481
+ # Get password if needed
1482
+ if password is None:
1483
+ # Check if the wallet is encrypted
1484
+ try:
1485
+ wallet_info = get_wallet_info_by_name(hotkey_name, is_hotkey=True)
1486
+ if wallet_info.get("is_encrypted", False) or wallet_info.get(
1487
+ "encrypted", False
1488
+ ):
1489
+ console.print(info("Wallet password required for encrypted wallet"))
1490
+ password = password_prompt("Enter wallet password", min_length=1)
1491
+ else:
1492
+ password = None
1493
+ except Exception:
1494
+ # If we can't determine encryption status, prompt just in case
1495
+ if confirm_prompt("Is your wallet password protected?", default=False):
1496
+ password = password_prompt("Enter wallet password", min_length=1)
1497
+ else:
1498
+ password = None
1499
+
1500
+ # Construct the request model
1501
+ # Note: We return a tuple of (hotkey_name, password, request_model)
1502
+ # because the IdentityRegisterRequest doesn't include the hotkey name/password
1503
+ # in the way we need for the handler
1504
+ request = IdentityRegisterRequest(
1505
+ hotkey="", # Will be filled by handler with address
1506
+ name=name or "",
1507
+ url=url or "",
1508
+ image=image or "",
1509
+ discord=discord or "",
1510
+ x=x or "",
1511
+ telegram=telegram or "",
1512
+ github=github or "",
1513
+ hugging_face=hugging_face or "",
1514
+ description=description or "",
1515
+ misc=misc or "",
1516
+ )
1517
+
1518
+ return hotkey_name, password, request