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.
- htcli-1.1.0.dist-info/METADATA +509 -0
- htcli-1.1.0.dist-info/RECORD +140 -0
- htcli-1.1.0.dist-info/WHEEL +4 -0
- htcli-1.1.0.dist-info/entry_points.txt +2 -0
- htcli-1.1.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +0 -0
- src/htcli/__init__.py +5 -0
- src/htcli/client/__init__.py +338 -0
- src/htcli/client/extrinsics/__init__.py +26 -0
- src/htcli/client/extrinsics/base.py +487 -0
- src/htcli/client/extrinsics/consensus.py +79 -0
- src/htcli/client/extrinsics/governance.py +714 -0
- src/htcli/client/extrinsics/identity.py +490 -0
- src/htcli/client/extrinsics/node.py +1054 -0
- src/htcli/client/extrinsics/overwatch.py +401 -0
- src/htcli/client/extrinsics/staking.py +1504 -0
- src/htcli/client/extrinsics/subnet.py +2218 -0
- src/htcli/client/extrinsics/validator.py +203 -0
- src/htcli/client/extrinsics/wallet.py +323 -0
- src/htcli/client/offchain/__init__.py +10 -0
- src/htcli/client/offchain/backup.py +385 -0
- src/htcli/client/offchain/config.py +541 -0
- src/htcli/client/offchain/wallet.py +839 -0
- src/htcli/client/rpc/__init__.py +20 -0
- src/htcli/client/rpc/chain.py +568 -0
- src/htcli/client/rpc/node.py +783 -0
- src/htcli/client/rpc/overwatch.py +680 -0
- src/htcli/client/rpc/staking.py +216 -0
- src/htcli/client/rpc/subnet.py +2104 -0
- src/htcli/client/rpc/wallet.py +912 -0
- src/htcli/commands/__init__.py +31 -0
- src/htcli/commands/chain/__init__.py +66 -0
- src/htcli/commands/chain/display.py +204 -0
- src/htcli/commands/chain/handlers.py +260 -0
- src/htcli/commands/config/__init__.py +158 -0
- src/htcli/commands/config/display.py +353 -0
- src/htcli/commands/config/handlers.py +347 -0
- src/htcli/commands/config/prompts.py +357 -0
- src/htcli/commands/consensus/__init__.py +61 -0
- src/htcli/commands/consensus/handlers.py +100 -0
- src/htcli/commands/governance/__init__.py +49 -0
- src/htcli/commands/governance/handlers.py +81 -0
- src/htcli/commands/node/__init__.py +304 -0
- src/htcli/commands/node/display.py +749 -0
- src/htcli/commands/node/error_handling.py +470 -0
- src/htcli/commands/node/handlers.py +844 -0
- src/htcli/commands/node/prompts.py +346 -0
- src/htcli/commands/overwatch/__init__.py +219 -0
- src/htcli/commands/overwatch/display.py +396 -0
- src/htcli/commands/overwatch/error_handling.py +276 -0
- src/htcli/commands/overwatch/handlers.py +443 -0
- src/htcli/commands/overwatch/prompts.py +359 -0
- src/htcli/commands/stake/__init__.py +736 -0
- src/htcli/commands/stake/display.py +1103 -0
- src/htcli/commands/stake/error_handling.py +425 -0
- src/htcli/commands/stake/handlers.py +1902 -0
- src/htcli/commands/stake/prompts.py +1080 -0
- src/htcli/commands/subnet/__init__.py +639 -0
- src/htcli/commands/subnet/display.py +801 -0
- src/htcli/commands/subnet/error_handling.py +524 -0
- src/htcli/commands/subnet/handlers.py +2855 -0
- src/htcli/commands/subnet/prompts.py +1225 -0
- src/htcli/commands/validator/__init__.py +192 -0
- src/htcli/commands/validator/display.py +54 -0
- src/htcli/commands/validator/handlers.py +340 -0
- src/htcli/commands/wallet/__init__.py +546 -0
- src/htcli/commands/wallet/display.py +806 -0
- src/htcli/commands/wallet/error_handling.py +210 -0
- src/htcli/commands/wallet/handlers.py +3040 -0
- src/htcli/commands/wallet/prompts.py +1518 -0
- src/htcli/config.py +184 -0
- src/htcli/dependencies.py +186 -0
- src/htcli/errors/__init__.py +63 -0
- src/htcli/errors/base.py +141 -0
- src/htcli/errors/display.py +20 -0
- src/htcli/errors/handlers.py +710 -0
- src/htcli/main.py +343 -0
- src/htcli/models/__init__.py +21 -0
- src/htcli/models/enums/enum_types.py +35 -0
- src/htcli/models/errors.py +103 -0
- src/htcli/models/requests/__init__.py +197 -0
- src/htcli/models/requests/config.py +70 -0
- src/htcli/models/requests/consensus.py +19 -0
- src/htcli/models/requests/governance.py +38 -0
- src/htcli/models/requests/identity.py +51 -0
- src/htcli/models/requests/key.py +22 -0
- src/htcli/models/requests/node.py +91 -0
- src/htcli/models/requests/overwatch.py +64 -0
- src/htcli/models/requests/staking.py +580 -0
- src/htcli/models/requests/subnet.py +195 -0
- src/htcli/models/requests/validator.py +139 -0
- src/htcli/models/requests/wallet.py +118 -0
- src/htcli/models/responses/__init__.py +147 -0
- src/htcli/models/responses/base.py +18 -0
- src/htcli/models/responses/chain.py +39 -0
- src/htcli/models/responses/config.py +58 -0
- src/htcli/models/responses/identity.py +102 -0
- src/htcli/models/responses/overwatch.py +51 -0
- src/htcli/models/responses/staking.py +502 -0
- src/htcli/models/responses/subnet.py +856 -0
- src/htcli/models/responses/wallet.py +185 -0
- src/htcli/ui/__init__.py +87 -0
- src/htcli/ui/colors.py +309 -0
- src/htcli/ui/components/__init__.py +60 -0
- src/htcli/ui/components/panels.py +174 -0
- src/htcli/ui/components/progress.py +166 -0
- src/htcli/ui/components/spinners.py +92 -0
- src/htcli/ui/components/tables.py +809 -0
- src/htcli/ui/components/trees.py +721 -0
- src/htcli/ui/display.py +336 -0
- src/htcli/ui/prompts.py +870 -0
- src/htcli/utils/__init__.py +76 -0
- src/htcli/utils/blockchain/__init__.py +75 -0
- src/htcli/utils/blockchain/formatting.py +368 -0
- src/htcli/utils/blockchain/patches.py +286 -0
- src/htcli/utils/blockchain/peer_id.py +186 -0
- src/htcli/utils/blockchain/staking.py +448 -0
- src/htcli/utils/blockchain/type_registry.py +1373 -0
- src/htcli/utils/blockchain/validation.py +179 -0
- src/htcli/utils/cache.py +613 -0
- src/htcli/utils/constants.py +38 -0
- src/htcli/utils/legacy/__init__.py +12 -0
- src/htcli/utils/legacy/colors.py +311 -0
- src/htcli/utils/legacy/crypto.py +1176 -0
- src/htcli/utils/legacy/formatting.py +452 -0
- src/htcli/utils/legacy/interactive.py +306 -0
- src/htcli/utils/legacy/subnet_manifest.py +265 -0
- src/htcli/utils/legacy/validation.py +488 -0
- src/htcli/utils/logging.py +183 -0
- src/htcli/utils/network/__init__.py +20 -0
- src/htcli/utils/network/subnet.py +344 -0
- src/htcli/utils/prompts.py +27 -0
- src/htcli/utils/scale_codec.py +155 -0
- src/htcli/utils/validation/__init__.py +57 -0
- src/htcli/utils/validation/prompt_validators.py +267 -0
- src/htcli/utils/wallet/__init__.py +65 -0
- src/htcli/utils/wallet/auth.py +151 -0
- src/htcli/utils/wallet/core.py +1069 -0
- src/htcli/utils/wallet/crypto.py +1615 -0
- 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")
|