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,267 @@
1
+ """
2
+ Prompt validation functions extracted from Pydantic models.
3
+
4
+ These validators return (bool, Optional[str]) tuples:
5
+ - bool: True if valid, False if invalid
6
+ - Optional[str]: Error message if invalid, None if valid
7
+ """
8
+
9
+ from typing import Optional, Union
10
+
11
+
12
+ def validate_subnet_name(name: Union[str, None]) -> tuple[bool, Optional[str]]:
13
+ """Validate subnet name."""
14
+ if name is None:
15
+ return False, "Subnet name cannot be None"
16
+ if not name or not name.strip():
17
+ return False, "Subnet name cannot be empty"
18
+ if len(name.strip()) > 1024:
19
+ return False, "Subnet name must be 1024 characters or less"
20
+ return True, None
21
+
22
+
23
+ def validate_subnet_repo(repo: Union[str, None]) -> tuple[bool, Optional[str]]:
24
+ """Validate repository URL."""
25
+ import re
26
+
27
+ if repo is None:
28
+ return False, "Repository URL cannot be None"
29
+ if not repo or not repo.strip():
30
+ return False, "Repository URL cannot be empty"
31
+ if len(repo.strip()) > 1024:
32
+ return False, "Repository URL must be 1024 characters or less"
33
+
34
+ # Validate URL format - must be http:// or https://
35
+ repo_stripped = repo.strip()
36
+ if not re.match(r"^https?://[^\s/$.?#].[^\s]*$", repo_stripped):
37
+ return False, "Repository URL must be a valid HTTP/HTTPS URL (e.g., https://github.com/user/repo)"
38
+
39
+ return True, None
40
+
41
+
42
+ def validate_subnet_min_stake(min_stake: Union[int, None]) -> tuple[bool, Optional[str]]:
43
+ """Validate minimum stake (in wei).
44
+
45
+ Requirements:
46
+ - Must be at least 100 TENSOR (100 * 1e18 wei)
47
+ """
48
+ MIN_TENSOR = 100
49
+ MIN_WEI = int(MIN_TENSOR * 1e18) # 100 TENSOR
50
+
51
+ if min_stake is None:
52
+ return False, "Minimum stake cannot be None"
53
+ if min_stake < 0:
54
+ return False, "min_stake cannot be negative"
55
+ if min_stake < MIN_WEI:
56
+ return False, f"min_stake must be at least {MIN_TENSOR} TENSOR (got {min_stake / 1e18:.4f} TENSOR)"
57
+ return True, None
58
+
59
+
60
+ def validate_subnet_max_stake(
61
+ max_stake: Union[int, None], min_stake: Optional[int] = None
62
+ ) -> tuple[bool, Optional[str]]:
63
+ """Validate maximum stake (in wei).
64
+
65
+ Requirements:
66
+ - Must be at most 1000 TENSOR (1000 * 1e18 wei)
67
+ - Must be greater than or equal to min_stake
68
+ """
69
+ MAX_TENSOR = 1000
70
+ MAX_WEI = int(MAX_TENSOR * 1e18) # 1000 TENSOR
71
+
72
+ if max_stake is None:
73
+ return False, "Maximum stake cannot be None"
74
+ if max_stake < 0:
75
+ return False, "max_stake cannot be negative"
76
+ if max_stake > MAX_WEI:
77
+ return False, f"max_stake must be at most {MAX_TENSOR} TENSOR (got {max_stake / 1e18:.4f} TENSOR)"
78
+ if min_stake is not None and max_stake < min_stake:
79
+ return False, f"max_stake must be greater than or equal to min_stake (min: {min_stake / 1e18:.4f} TENSOR, max: {max_stake / 1e18:.4f} TENSOR)"
80
+ return True, None
81
+
82
+
83
+ def validate_subnet_delegate_stake_percentage(
84
+ percentage: Union[int, None]
85
+ ) -> tuple[bool, Optional[str]]:
86
+ """Validate delegate stake percentage (in wei format)."""
87
+ if percentage is None:
88
+ return False, "Delegate stake percentage cannot be None"
89
+ MIN_ALLOWED = 50_000_000_000_000_000 # 5%
90
+ MAX_ALLOWED = 950_000_000_000_000_000 # 95%
91
+ if percentage < MIN_ALLOWED or percentage > MAX_ALLOWED:
92
+ return False, f"delegate_stake_percentage must be between {MIN_ALLOWED / 1e16:.0f}% and {MAX_ALLOWED / 1e16:.0f}%"
93
+ return True, None
94
+
95
+
96
+ def validate_subnet_max_registered_nodes(
97
+ max_nodes: Union[int, None]
98
+ ) -> tuple[bool, Optional[str]]:
99
+ """Validate maximum registered nodes."""
100
+ if max_nodes is None:
101
+ return False, "Maximum registered nodes cannot be None"
102
+ if max_nodes < 1 or max_nodes > 64:
103
+ return False, "max_registered_nodes must be between 1 and 64"
104
+ return True, None
105
+
106
+
107
+ def validate_subnet_bootnodes(bootnodes: Union[set, list, None]) -> tuple[bool, Optional[str]]:
108
+ """Validate bootnodes."""
109
+ if bootnodes is None:
110
+ return True, None # Bootnodes are optional
111
+ if bootnodes and len(bootnodes) > 32:
112
+ return False, "Too many bootnodes (maximum 32)"
113
+ return True, None
114
+
115
+
116
+ def validate_subnet_initial_coldkeys(
117
+ coldkeys: Union[dict, list, None]
118
+ ) -> tuple[bool, Optional[str]]:
119
+ """Validate initial coldkeys."""
120
+ if coldkeys is None:
121
+ return True, None # Initial coldkeys are optional
122
+ if isinstance(coldkeys, dict):
123
+ if coldkeys and len(coldkeys) > 0 and len(coldkeys) < 3:
124
+ return False, "If providing initial coldkeys, provide at least 3 for meaningful consensus"
125
+ # Validate max_registrations values
126
+ for address, max_regs in coldkeys.items():
127
+ if max_regs < 1:
128
+ return False, f"max_registrations for {address} must be >= 1, got {max_regs}"
129
+ elif isinstance(coldkeys, list):
130
+ if coldkeys and len(coldkeys) > 0 and len(coldkeys) < 3:
131
+ return False, "If providing initial coldkeys, provide at least 3 for meaningful consensus"
132
+ return True, None
133
+
134
+
135
+ def validate_hotkey_address(address: Union[str, None]) -> tuple[bool, Optional[str]]:
136
+ """Validate hotkey address (Ethereum format)."""
137
+ if address is None:
138
+ return False, "Hotkey address cannot be None"
139
+ if not address or len(address) != 42 or not address.startswith("0x"):
140
+ return False, "Hotkey must be a valid Ethereum address (0x + 40 hex characters)"
141
+ # Check hex characters
142
+ hex_part = address[2:]
143
+ if not all(c in "0123456789abcdefABCDEF" for c in hex_part):
144
+ return False, "Hotkey address contains invalid hex characters"
145
+ return True, None
146
+
147
+
148
+ def validate_wallet_name(name: Union[str, None]) -> tuple[bool, Optional[str]]:
149
+ """Validate wallet name."""
150
+ if name is None:
151
+ return False, "Wallet name cannot be None"
152
+ if not name or not name.strip():
153
+ return False, "Wallet name cannot be empty"
154
+ if len(name.strip()) > 50:
155
+ return False, "Wallet name must be 50 characters or less"
156
+ # Should be alphanumeric with hyphens and underscores
157
+ import re
158
+ if not re.match(r"^[a-zA-Z0-9_-]+$", name.strip()):
159
+ return False, "Wallet name must contain only alphanumeric characters, hyphens, and underscores"
160
+ return True, None
161
+
162
+
163
+ def validate_subnet_id(subnet_id: Union[int, str, None]) -> tuple[bool, Optional[str]]:
164
+ """Validate subnet ID."""
165
+ if subnet_id is None:
166
+ return False, "Subnet ID cannot be None"
167
+ try:
168
+ if isinstance(subnet_id, str):
169
+ subnet_id = int(subnet_id)
170
+ if subnet_id < 0:
171
+ return False, "Subnet ID must be non-negative"
172
+ return True, None
173
+ except (ValueError, TypeError):
174
+ return False, "Subnet ID must be a valid integer"
175
+
176
+
177
+ def validate_node_id(node_id: Union[int, str, None]) -> tuple[bool, Optional[str]]:
178
+ """Validate node ID."""
179
+ if node_id is None:
180
+ return False, "Node ID cannot be None"
181
+ try:
182
+ if isinstance(node_id, str):
183
+ node_id = int(node_id)
184
+ if node_id < 0:
185
+ return False, "Node ID must be non-negative"
186
+ return True, None
187
+ except (ValueError, TypeError):
188
+ return False, "Node ID must be a valid integer"
189
+
190
+
191
+ def validate_stake_amount(amount: Union[int, float, str, None]) -> tuple[bool, Optional[str]]:
192
+ """Validate stake amount.
193
+
194
+ All amounts are treated as TENSOR and converted to WEI for validation.
195
+ Integers, floats, and strings are all assumed to be in TENSOR units.
196
+ """
197
+ if amount is None:
198
+ return False, "Stake amount cannot be None"
199
+ try:
200
+ # Convert to float first to handle all input types
201
+ if isinstance(amount, str):
202
+ amount_float = float(amount)
203
+ elif isinstance(amount, int):
204
+ # Integers are treated as TENSOR (e.g., 80 = 80 TENSOR)
205
+ amount_float = float(amount)
206
+ elif isinstance(amount, float):
207
+ amount_float = amount
208
+ else:
209
+ return False, "Stake amount must be a valid number"
210
+
211
+ # Convert TENSOR to WEI for validation
212
+ amount_wei = int(amount_float * 1e18)
213
+
214
+ if amount_wei <= 0:
215
+ return False, "Stake amount must be positive"
216
+ if amount_wei < 1e15: # 0.001 TENSOR minimum
217
+ return False, "Stake amount too small (minimum 0.001 TENSOR)"
218
+ return True, None
219
+ except (ValueError, TypeError):
220
+ return False, "Stake amount must be a valid number"
221
+
222
+
223
+ def validate_rpc_url(url: Union[str, None]) -> tuple[bool, Optional[str]]:
224
+ """Validate RPC URL."""
225
+ if url is None:
226
+ return False, "RPC URL cannot be None"
227
+ if not url or not url.strip():
228
+ return False, "RPC URL cannot be empty"
229
+ import re
230
+ if not re.match(r"^(ws|wss|http|https)://", url.strip()):
231
+ return False, "RPC URL must start with ws://, wss://, http://, or https://"
232
+ if len(url.strip()) < 10:
233
+ return False, "RPC URL is too short"
234
+ return True, None
235
+
236
+
237
+ def validate_timeout(timeout: Union[int, str, None]) -> tuple[bool, Optional[str]]:
238
+ """Validate timeout value."""
239
+ if timeout is None:
240
+ return False, "Timeout cannot be None"
241
+ try:
242
+ if isinstance(timeout, str):
243
+ timeout = int(timeout)
244
+ if timeout < 1:
245
+ return False, "Timeout must be at least 1 second"
246
+ if timeout > 3600:
247
+ return False, "Timeout must be at most 3600 seconds (1 hour)"
248
+ return True, None
249
+ except (ValueError, TypeError):
250
+ return False, "Timeout must be a valid integer"
251
+
252
+
253
+ def validate_retry_attempts(attempts: Union[int, str, None]) -> tuple[bool, Optional[str]]:
254
+ """Validate retry attempts."""
255
+ if attempts is None:
256
+ return False, "Retry attempts cannot be None"
257
+ try:
258
+ if isinstance(attempts, str):
259
+ attempts = int(attempts)
260
+ if attempts < 0:
261
+ return False, "Retry attempts must be non-negative"
262
+ if attempts > 100:
263
+ return False, "Retry attempts must be at most 100"
264
+ return True, None
265
+ except (ValueError, TypeError):
266
+ return False, "Retry attempts must be a valid integer"
267
+
@@ -0,0 +1,65 @@
1
+ """
2
+ Wallet utilities for the Hypertensor CLI.
3
+ Handles wallet creation, management, authentication, and cryptographic operations.
4
+ """
5
+
6
+ from .core import (
7
+ create_keypair_from_mnemonic,
8
+ generate_mnemonic,
9
+ prompt_for_wallet_and_keypair,
10
+ prompt_for_wallet_selection,
11
+ retrieve_wallet_with_validation,
12
+ )
13
+ from .crypto import (
14
+ KeypairInfo,
15
+ decrypt_private_key,
16
+ delete_coldkey_and_hotkeys,
17
+ delete_keypair,
18
+ encrypt_private_key,
19
+ generate_coldkey_pair,
20
+ generate_hotkey_pair,
21
+ get_wallet_directory,
22
+ get_wallet_info_by_name,
23
+ import_hotkey_from_mnemonic,
24
+ import_hotkey_from_private_key,
25
+ import_keypair,
26
+ import_keypair_from_mnemonic,
27
+ import_preseeded_wallet,
28
+ list_keys,
29
+ load_keypair,
30
+ update_coldkey,
31
+ update_hotkey,
32
+ wallet_name_exists,
33
+ )
34
+
35
+ __all__ = [
36
+ # Wallet creation and import operations
37
+ "generate_coldkey_pair",
38
+ "generate_hotkey_pair",
39
+ "import_keypair",
40
+ "import_keypair_from_mnemonic",
41
+ "import_hotkey_from_private_key",
42
+ "import_hotkey_from_mnemonic",
43
+ "import_preseeded_wallet",
44
+ # Wallet management
45
+ "get_wallet_info_by_name",
46
+ "list_keys",
47
+ "wallet_name_exists",
48
+ "load_keypair",
49
+ "delete_keypair",
50
+ "delete_coldkey_and_hotkeys",
51
+ "update_coldkey",
52
+ "update_hotkey",
53
+ # Cryptographic operations
54
+ "encrypt_private_key",
55
+ "decrypt_private_key",
56
+ # Core utilities
57
+ "generate_mnemonic",
58
+ "create_keypair_from_mnemonic",
59
+ "prompt_for_wallet_selection",
60
+ "prompt_for_wallet_and_keypair",
61
+ "retrieve_wallet_with_validation",
62
+ # Utilities
63
+ "get_wallet_directory",
64
+ "KeypairInfo",
65
+ ]
@@ -0,0 +1,151 @@
1
+ import sys
2
+ import termios
3
+ import tty
4
+ from typing import Optional
5
+
6
+ from rich.console import Console
7
+ from rich.prompt import Prompt
8
+ from substrateinterface import Keypair
9
+
10
+ from .crypto import get_wallet_info_by_name, load_keypair
11
+
12
+ # Use a single console instance for consistent output
13
+ _console = Console()
14
+
15
+
16
+ def _clear_line():
17
+ """Clear the current line using ANSI escape codes."""
18
+ # Use carriage return and clear to end of line
19
+ sys.stdout.write("\r\033[K")
20
+ sys.stdout.flush()
21
+
22
+
23
+ def get_secure_password(prompt_message: str, min_length: int = 8) -> str:
24
+ """Get a secure password from the user."""
25
+ console = Console()
26
+ while True:
27
+ password = Prompt.ask(prompt_message, password=True)
28
+ # Clear the prompt line after input
29
+ _clear_line()
30
+ if (
31
+ len(password) >= min_length
32
+ and any(char.isdigit() for char in password)
33
+ and any(char.isalpha() for char in password)
34
+ ):
35
+ return password
36
+ else:
37
+ console.print(
38
+ f"[red]Password must be at least {min_length} characters long and contain both letters and numbers.[/red]"
39
+ )
40
+
41
+
42
+ def get_unlock_password(
43
+ wallet_name: str, prompt_message: str, max_attempts: int = 3, silent: bool = False
44
+ ) -> str:
45
+ """Get password to unlock a wallet with retry attempts."""
46
+ for attempt in range(max_attempts):
47
+ # Clear any previous output before showing prompt
48
+ _clear_line()
49
+ password = Prompt.ask(prompt_message, password=True)
50
+ # Clear the prompt line immediately after input
51
+ _clear_line()
52
+ if password:
53
+ return password
54
+ else:
55
+ if not silent:
56
+ _console.print(
57
+ f"[red]Password cannot be empty. {max_attempts - attempt - 1} attempts remaining.[/red]"
58
+ )
59
+ # Clear the error message after a brief moment
60
+ import time
61
+
62
+ time.sleep(0.1)
63
+ sys.stdout.write("\033[1A\033[K")
64
+ sys.stdout.flush()
65
+ raise ValueError("Failed to get password after multiple attempts.")
66
+
67
+
68
+ def get_wallet_with_retry(
69
+ name: str,
70
+ max_attempts: int = 3,
71
+ silent: bool = False,
72
+ is_hotkey: Optional[bool] = None,
73
+ owner_address: Optional[str] = None,
74
+ ) -> tuple[Keypair, dict]:
75
+ """Get wallet with password retry attempts.
76
+
77
+ Args:
78
+ name: Wallet name
79
+ max_attempts: Maximum password attempts
80
+ silent: If True, suppress intermediate error messages (only show final failure)
81
+ is_hotkey: Optional hint to specify if this is a hotkey (True) or coldkey (False)
82
+ owner_address: Optional coldkey address for hotkey disambiguation
83
+ """
84
+ wallet_info = get_wallet_info_by_name(
85
+ name, is_hotkey=is_hotkey, owner_address=owner_address
86
+ )
87
+ # Extract actual values from wallet_info
88
+ actual_is_hotkey = wallet_info.get("is_hotkey", False)
89
+ actual_owner_address = wallet_info.get("owner_address")
90
+
91
+ if not wallet_info.get("is_encrypted", True):
92
+ return (
93
+ load_keypair(
94
+ name,
95
+ None,
96
+ is_hotkey=actual_is_hotkey,
97
+ owner_address=actual_owner_address,
98
+ ),
99
+ wallet_info,
100
+ )
101
+ attempts = 0
102
+ while attempts < max_attempts:
103
+ try:
104
+ attempts += 1
105
+ password = get_unlock_password(
106
+ name, f"Enter password for wallet '{name}'", silent=silent
107
+ )
108
+ # Password line is already cleared by get_unlock_password
109
+ keypair = load_keypair(
110
+ name,
111
+ password,
112
+ is_hotkey=actual_is_hotkey,
113
+ owner_address=actual_owner_address,
114
+ )
115
+ # Clear line after successful unlock
116
+ _clear_line()
117
+ return keypair, wallet_info
118
+ except ValueError as e:
119
+ if "Invalid password" in str(e):
120
+ # Clear line after invalid password - don't show error messages if silent
121
+ _clear_line()
122
+ if not silent and attempts < max_attempts:
123
+ # Only show error if not silent and not on final attempt
124
+ remaining = max_attempts - attempts
125
+ _console.print(
126
+ f"[red]❌ Error: Invalid password. {remaining} attempts remaining.[/red]"
127
+ )
128
+ # Clear the error after a brief moment
129
+ import time
130
+
131
+ time.sleep(0.15)
132
+ sys.stdout.write("\033[1A\033[K") # Move up and clear error line
133
+ sys.stdout.flush()
134
+ else:
135
+ raise
136
+ except Exception as e:
137
+ # Clear line after error
138
+ _clear_line()
139
+ if not silent and attempts < max_attempts:
140
+ remaining = max_attempts - attempts
141
+ _console.print(
142
+ f"[red]❌ Error: {str(e)}. {remaining} attempts remaining.[/red]"
143
+ )
144
+ import time
145
+
146
+ time.sleep(0.15)
147
+ sys.stdout.write("\033[1A\033[K")
148
+ sys.stdout.flush()
149
+ elif attempts >= max_attempts:
150
+ raise Exception(f"Failed to load wallet '{name}': {str(e)}") from e
151
+ raise Exception(f"Failed to unlock wallet '{name}' after {max_attempts} attempts")