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