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,1615 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cryptographic utility functions for the Hypertensor CLI.
|
|
3
|
+
EVM-compatible wallet operations with Byte20 addresses.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from cryptography.fernet import Fernet
|
|
16
|
+
from cryptography.hazmat.primitives import hashes
|
|
17
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
18
|
+
from eth_utils import keccak
|
|
19
|
+
from substrateinterface import Keypair
|
|
20
|
+
|
|
21
|
+
HOTKEY_FILE_SUFFIX = ".hotkey.json"
|
|
22
|
+
COLDKEY_FILE_SUFFIX = ".coldkey.json"
|
|
23
|
+
LEGACY_FILE_SUFFIX = ".json"
|
|
24
|
+
HOTKEYS_SUBDIR = "hotkeys"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _wallet_layout_mode() -> str:
|
|
28
|
+
"""Return configured wallet layout preference."""
|
|
29
|
+
return os.getenv("HTCLI_WALLET_LAYOUT", "hierarchical").lower()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hierarchical_layout_enabled() -> bool:
|
|
33
|
+
"""Whether the hierarchical wallet layout should be used for new writes."""
|
|
34
|
+
return _wallet_layout_mode() not in {"legacy", "flat", "old", "0", "false"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _wallet_base_dir(wallet_dir: Optional[Path] = None) -> Path:
|
|
38
|
+
return (wallet_dir or get_wallet_directory()).expanduser()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _legacy_coldkey_path(name: str, wallet_dir: Optional[Path] = None) -> Path:
|
|
42
|
+
return _wallet_base_dir(wallet_dir) / f"{name}{COLDKEY_FILE_SUFFIX}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _legacy_hotkey_path(
|
|
46
|
+
name: str, owner_address: Optional[str] = None, wallet_dir: Optional[Path] = None
|
|
47
|
+
) -> Path:
|
|
48
|
+
base_dir = _wallet_base_dir(wallet_dir)
|
|
49
|
+
owner_fragment = ""
|
|
50
|
+
if owner_address:
|
|
51
|
+
owner_fragment = f".{owner_address.lower()[:8]}"
|
|
52
|
+
return base_dir / f"{name}{owner_fragment}{HOTKEY_FILE_SUFFIX}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _coldkey_dir(name: str, wallet_dir: Optional[Path] = None) -> Path:
|
|
56
|
+
return _wallet_base_dir(wallet_dir) / name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _hierarchical_coldkey_path(name: str, wallet_dir: Optional[Path] = None) -> Path:
|
|
60
|
+
return _coldkey_dir(name, wallet_dir) / f"{name}{COLDKEY_FILE_SUFFIX}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hotkeys_dir(coldkey_name: str, wallet_dir: Optional[Path] = None) -> Path:
|
|
64
|
+
return _coldkey_dir(coldkey_name, wallet_dir) / HOTKEYS_SUBDIR
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _hierarchical_hotkey_path(
|
|
68
|
+
coldkey_name: str,
|
|
69
|
+
hotkey_name: str,
|
|
70
|
+
wallet_dir: Optional[Path] = None,
|
|
71
|
+
) -> Path:
|
|
72
|
+
return _hotkeys_dir(coldkey_name, wallet_dir) / f"{hotkey_name}{HOTKEY_FILE_SUFFIX}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_hierarchical_wallet_entries(wallet_dir: Path, add_entry) -> None:
|
|
76
|
+
"""Load entries stored inside the hierarchical directory layout."""
|
|
77
|
+
for coldkey_dir in sorted(wallet_dir.iterdir()):
|
|
78
|
+
if not coldkey_dir.is_dir():
|
|
79
|
+
continue
|
|
80
|
+
coldkey_name = coldkey_dir.name
|
|
81
|
+
# Try to find coldkey file - check both new format (.coldkey.json) and legacy format (.json)
|
|
82
|
+
coldkey_file = next(coldkey_dir.glob(f"*{COLDKEY_FILE_SUFFIX}"), None)
|
|
83
|
+
# If not found, check for legacy format coldkey file
|
|
84
|
+
if not coldkey_file or not coldkey_file.is_file():
|
|
85
|
+
legacy_coldkey_file = coldkey_dir / f"{coldkey_name}{LEGACY_FILE_SUFFIX}"
|
|
86
|
+
if legacy_coldkey_file.exists() and legacy_coldkey_file.is_file():
|
|
87
|
+
coldkey_file = legacy_coldkey_file
|
|
88
|
+
else:
|
|
89
|
+
# Also check root directory for legacy coldkey file
|
|
90
|
+
root_legacy_file = wallet_dir / f"{coldkey_name}{LEGACY_FILE_SUFFIX}"
|
|
91
|
+
if root_legacy_file.exists() and root_legacy_file.is_file():
|
|
92
|
+
try:
|
|
93
|
+
with open(root_legacy_file) as f:
|
|
94
|
+
test_data = json.load(f)
|
|
95
|
+
# Only use if it's actually a coldkey (not a misclassified hotkey)
|
|
96
|
+
if not test_data.get("is_hotkey", False) and not test_data.get(
|
|
97
|
+
"owner_address"
|
|
98
|
+
):
|
|
99
|
+
coldkey_file = root_legacy_file
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
coldkey_data = None
|
|
104
|
+
coldkey_address = None
|
|
105
|
+
|
|
106
|
+
# Load coldkey data if file exists
|
|
107
|
+
if coldkey_file and coldkey_file.is_file():
|
|
108
|
+
try:
|
|
109
|
+
with open(coldkey_file) as f:
|
|
110
|
+
coldkey_data = json.load(f)
|
|
111
|
+
# Validate this is actually a coldkey (not a misclassified hotkey)
|
|
112
|
+
is_hotkey = coldkey_data.get("is_hotkey", False)
|
|
113
|
+
owner_address = coldkey_data.get("owner_address")
|
|
114
|
+
# Coldkeys should not have is_hotkey=True or owner_address set
|
|
115
|
+
if is_hotkey or owner_address:
|
|
116
|
+
# This is likely a misclassified hotkey file, skip it
|
|
117
|
+
coldkey_data = None
|
|
118
|
+
else:
|
|
119
|
+
coldkey_data["file_path"] = str(coldkey_file)
|
|
120
|
+
# For coldkeys, owner_coldkey_name should be None (coldkeys don't have owners)
|
|
121
|
+
# The directory name is just for organization, not ownership
|
|
122
|
+
coldkey_data["owner_coldkey_name"] = None
|
|
123
|
+
coldkey_data["is_hotkey"] = False
|
|
124
|
+
# Ensure owner_address is None for coldkeys
|
|
125
|
+
if "owner_address" in coldkey_data:
|
|
126
|
+
del coldkey_data["owner_address"]
|
|
127
|
+
add_entry(coldkey_data)
|
|
128
|
+
# Get coldkey address for matching hotkeys (use ss58_address, evm_address, or address in that order)
|
|
129
|
+
coldkey_address = (
|
|
130
|
+
coldkey_data.get("ss58_address")
|
|
131
|
+
or coldkey_data.get("evm_address")
|
|
132
|
+
or coldkey_data.get("address")
|
|
133
|
+
)
|
|
134
|
+
except Exception:
|
|
135
|
+
coldkey_data = None
|
|
136
|
+
|
|
137
|
+
# Scan hotkeys directory even if no coldkey file was found
|
|
138
|
+
# (hotkeys can exist in hierarchical structure even if coldkey is in legacy location)
|
|
139
|
+
hotkeys_dir = coldkey_dir / HOTKEYS_SUBDIR
|
|
140
|
+
if not hotkeys_dir.exists():
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
for hotkey_file in sorted(hotkeys_dir.glob(f"*{HOTKEY_FILE_SUFFIX}")):
|
|
144
|
+
if not hotkey_file.is_file():
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
with open(hotkey_file) as f:
|
|
148
|
+
hotkey_data = json.load(f)
|
|
149
|
+
hotkey_data["file_path"] = str(hotkey_file)
|
|
150
|
+
hotkey_data["owner_coldkey_name"] = coldkey_name
|
|
151
|
+
hotkey_data["is_hotkey"] = True
|
|
152
|
+
# Use coldkey address from coldkey file if available, otherwise use owner_address from hotkey file
|
|
153
|
+
if coldkey_address:
|
|
154
|
+
hotkey_data["owner_address"] = coldkey_address
|
|
155
|
+
else:
|
|
156
|
+
# Fallback to what's in the hotkey file if coldkey file wasn't found
|
|
157
|
+
hotkey_data.setdefault(
|
|
158
|
+
"owner_address", hotkey_data.get("owner_address")
|
|
159
|
+
)
|
|
160
|
+
add_entry(hotkey_data)
|
|
161
|
+
except Exception:
|
|
162
|
+
continue
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_wallet_directory() -> Path:
|
|
167
|
+
"""Get the wallet directory from config or use default."""
|
|
168
|
+
try:
|
|
169
|
+
from ...dependencies import get_config
|
|
170
|
+
|
|
171
|
+
config = get_config()
|
|
172
|
+
if config and config.wallet and config.wallet.path:
|
|
173
|
+
# Expand ~ and other path variables
|
|
174
|
+
wallet_path = Path(config.wallet.path).expanduser()
|
|
175
|
+
return wallet_path
|
|
176
|
+
except Exception:
|
|
177
|
+
# Fallback to default if config is not available
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
# Default fallback
|
|
181
|
+
return Path.home() / ".htcli" / "wallets"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_wallet_file_path(
|
|
185
|
+
name: str,
|
|
186
|
+
is_hotkey: bool,
|
|
187
|
+
wallet_dir: Optional[Path] = None,
|
|
188
|
+
owner_address: Optional[str] = None,
|
|
189
|
+
owner_coldkey_name: Optional[str] = None,
|
|
190
|
+
prefer_hierarchical: Optional[bool] = None,
|
|
191
|
+
) -> Path:
|
|
192
|
+
"""Return the canonical wallet file path for the given wallet name/type."""
|
|
193
|
+
use_hierarchical = (
|
|
194
|
+
prefer_hierarchical
|
|
195
|
+
if prefer_hierarchical is not None
|
|
196
|
+
else _hierarchical_layout_enabled()
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if is_hotkey:
|
|
200
|
+
if use_hierarchical and owner_coldkey_name:
|
|
201
|
+
return _hierarchical_hotkey_path(
|
|
202
|
+
owner_coldkey_name, name, wallet_dir=wallet_dir
|
|
203
|
+
)
|
|
204
|
+
return _legacy_hotkey_path(name, owner_address, wallet_dir=wallet_dir)
|
|
205
|
+
|
|
206
|
+
# coldkey path
|
|
207
|
+
if use_hierarchical:
|
|
208
|
+
return _hierarchical_coldkey_path(name, wallet_dir=wallet_dir)
|
|
209
|
+
return _legacy_coldkey_path(name, wallet_dir=wallet_dir)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _load_wallet_entries() -> list[dict]:
|
|
213
|
+
"""Load all wallet key metadata entries from disk.
|
|
214
|
+
|
|
215
|
+
Handles both new format (.hotkey.json, .coldkey.json) and legacy format (.json).
|
|
216
|
+
Prefers new format files when both exist for the same wallet.
|
|
217
|
+
"""
|
|
218
|
+
wallet_dir = get_wallet_directory()
|
|
219
|
+
if not wallet_dir.exists():
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
entries: list[dict] = []
|
|
223
|
+
seen_wallets: dict[
|
|
224
|
+
tuple, dict
|
|
225
|
+
] = {} # (name, is_hotkey, owner_address, owner_ck) -> entry
|
|
226
|
+
|
|
227
|
+
def _add_entry(entry: dict):
|
|
228
|
+
key = (
|
|
229
|
+
entry.get("name"),
|
|
230
|
+
entry.get("is_hotkey", False),
|
|
231
|
+
entry.get("owner_address"),
|
|
232
|
+
entry.get("owner_coldkey_name"),
|
|
233
|
+
)
|
|
234
|
+
if key not in seen_wallets:
|
|
235
|
+
seen_wallets[key] = entry
|
|
236
|
+
entries.append(entry)
|
|
237
|
+
|
|
238
|
+
# Load hierarchical layout first so it takes precedence.
|
|
239
|
+
_load_hierarchical_wallet_entries(wallet_dir, _add_entry)
|
|
240
|
+
|
|
241
|
+
# First pass: load all files
|
|
242
|
+
all_files = sorted(wallet_dir.glob("*.json"))
|
|
243
|
+
|
|
244
|
+
# Separate new format and legacy format files
|
|
245
|
+
new_format_files = []
|
|
246
|
+
legacy_files = []
|
|
247
|
+
|
|
248
|
+
for keypair_file in all_files:
|
|
249
|
+
filename = keypair_file.name
|
|
250
|
+
if filename.endswith(HOTKEY_FILE_SUFFIX) or filename.endswith(
|
|
251
|
+
COLDKEY_FILE_SUFFIX
|
|
252
|
+
):
|
|
253
|
+
new_format_files.append(keypair_file)
|
|
254
|
+
else:
|
|
255
|
+
legacy_files.append(keypair_file)
|
|
256
|
+
|
|
257
|
+
# Load new format files first (they take precedence)
|
|
258
|
+
for keypair_file in new_format_files:
|
|
259
|
+
try:
|
|
260
|
+
with open(keypair_file) as f:
|
|
261
|
+
keypair_data = json.load(f)
|
|
262
|
+
|
|
263
|
+
# Validate coldkey files don't have owner_address (coldkeys don't have owners)
|
|
264
|
+
is_hotkey = keypair_data.get("is_hotkey", False)
|
|
265
|
+
owner_address = keypair_data.get("owner_address")
|
|
266
|
+
if not is_hotkey and owner_address:
|
|
267
|
+
# This is a misclassified coldkey file, skip it
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
keypair_data["file_path"] = str(keypair_file)
|
|
271
|
+
|
|
272
|
+
# Create unique key for deduplication
|
|
273
|
+
name = keypair_data.get("name", "")
|
|
274
|
+
key = (name, is_hotkey, owner_address)
|
|
275
|
+
|
|
276
|
+
# Only add if we haven't seen this wallet yet
|
|
277
|
+
if key + (None,) not in seen_wallets:
|
|
278
|
+
keypair_data.setdefault("owner_coldkey_name", None)
|
|
279
|
+
_add_entry(keypair_data)
|
|
280
|
+
except Exception:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Load legacy files, but skip if we already have a new format version
|
|
284
|
+
for keypair_file in legacy_files:
|
|
285
|
+
try:
|
|
286
|
+
with open(keypair_file) as f:
|
|
287
|
+
keypair_data = json.load(f)
|
|
288
|
+
|
|
289
|
+
name = keypair_data.get("name", "")
|
|
290
|
+
is_hotkey = keypair_data.get("is_hotkey", False)
|
|
291
|
+
owner_address = keypair_data.get("owner_address")
|
|
292
|
+
|
|
293
|
+
# Validate coldkey files don't have owner_address (coldkeys don't have owners)
|
|
294
|
+
if not is_hotkey and owner_address:
|
|
295
|
+
# This is a misclassified coldkey file, skip it
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
key = (name, is_hotkey, owner_address)
|
|
299
|
+
|
|
300
|
+
# Skip if we already have this wallet in new format
|
|
301
|
+
if key + (None,) in seen_wallets:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
keypair_data["file_path"] = str(keypair_file)
|
|
305
|
+
keypair_data.setdefault("owner_coldkey_name", None)
|
|
306
|
+
_add_entry(keypair_data)
|
|
307
|
+
except Exception:
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
return entries
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _find_wallet_entries(
|
|
314
|
+
name: str,
|
|
315
|
+
is_hotkey: Optional[bool] = None,
|
|
316
|
+
owner_address: Optional[str] = None,
|
|
317
|
+
owner_coldkey_name: Optional[str] = None,
|
|
318
|
+
) -> list[dict]:
|
|
319
|
+
"""Find wallet metadata entries that match the provided criteria."""
|
|
320
|
+
|
|
321
|
+
def normalize_address(addr):
|
|
322
|
+
"""Normalize address for comparison (lowercase, remove 0x prefix differences)."""
|
|
323
|
+
if not addr:
|
|
324
|
+
return None
|
|
325
|
+
addr_str = str(addr).strip()
|
|
326
|
+
# Remove 0x prefix if present, then normalize
|
|
327
|
+
if addr_str.startswith("0x") or addr_str.startswith("0X"):
|
|
328
|
+
addr_str = addr_str[2:]
|
|
329
|
+
# Lowercase and ensure consistent format
|
|
330
|
+
addr_str = addr_str.lower()
|
|
331
|
+
# Add 0x prefix back for consistency (if it's a hex address)
|
|
332
|
+
if len(addr_str) == 40 and all(c in "0123456789abcdef" for c in addr_str):
|
|
333
|
+
return f"0x{addr_str}"
|
|
334
|
+
# Return as-is if not a standard hex address
|
|
335
|
+
return addr_str
|
|
336
|
+
|
|
337
|
+
matches = []
|
|
338
|
+
normalized_owner_address = (
|
|
339
|
+
normalize_address(owner_address) if owner_address else None
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
for entry in _load_wallet_entries():
|
|
343
|
+
if entry.get("name") != name:
|
|
344
|
+
continue
|
|
345
|
+
if is_hotkey is not None and entry.get("is_hotkey", False) != is_hotkey:
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
# If disambiguation parameters are provided, entry must match
|
|
349
|
+
# If owner_coldkey_name is provided, ONLY match on name (strict name-based disambiguation)
|
|
350
|
+
# If only owner_address is provided, match on address
|
|
351
|
+
# If both provided, use OR logic: match if EITHER name OR address matches
|
|
352
|
+
if normalized_owner_address or owner_coldkey_name:
|
|
353
|
+
matched = False
|
|
354
|
+
|
|
355
|
+
# If owner_coldkey_name is provided, check name match first
|
|
356
|
+
if owner_coldkey_name:
|
|
357
|
+
entry_owner_name = entry.get("owner_coldkey_name")
|
|
358
|
+
# Check if name matches (handle None/empty cases)
|
|
359
|
+
if entry_owner_name:
|
|
360
|
+
entry_owner_name_clean = str(entry_owner_name).strip()
|
|
361
|
+
owner_coldkey_name_clean = str(owner_coldkey_name).strip()
|
|
362
|
+
if entry_owner_name_clean == owner_coldkey_name_clean:
|
|
363
|
+
matched = True
|
|
364
|
+
|
|
365
|
+
# Check address if matched is not yet True
|
|
366
|
+
# This handles two cases:
|
|
367
|
+
# 1. owner_coldkey_name was NOT provided (so we must check address)
|
|
368
|
+
# 2. owner_coldkey_name WAS provided but didn't match (e.g. entry has None/stale name)
|
|
369
|
+
# In this case, we trust the address if it matches, as address is the source of truth
|
|
370
|
+
if normalized_owner_address and not matched:
|
|
371
|
+
entry_owner_addr_raw = entry.get("owner_address")
|
|
372
|
+
if entry_owner_addr_raw:
|
|
373
|
+
entry_owner_addr = normalize_address(entry_owner_addr_raw)
|
|
374
|
+
# Only compare the owner_address field, not the hotkey's own addresses
|
|
375
|
+
if (
|
|
376
|
+
entry_owner_addr
|
|
377
|
+
and entry_owner_addr == normalized_owner_address
|
|
378
|
+
):
|
|
379
|
+
matched = True
|
|
380
|
+
|
|
381
|
+
# If we have disambiguation params but no match, skip this entry
|
|
382
|
+
if not matched:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
matches.append(entry)
|
|
386
|
+
return matches
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def resolve_wallet_file_path(
|
|
390
|
+
name: str,
|
|
391
|
+
is_hotkey: Optional[bool] = None,
|
|
392
|
+
owner_address: Optional[str] = None,
|
|
393
|
+
owner_coldkey_name: Optional[str] = None,
|
|
394
|
+
) -> Path:
|
|
395
|
+
"""Resolve the on-disk file path for a wallet.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
name: Wallet name
|
|
399
|
+
is_hotkey: Specify True for hotkey, False for coldkey to disambiguate
|
|
400
|
+
owner_address: For hotkeys, specify the coldkey owner address to disambiguate
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
ValueError: If wallet name is ambiguous and disambiguation parameters are missing
|
|
404
|
+
"""
|
|
405
|
+
matches = _find_wallet_entries(
|
|
406
|
+
name,
|
|
407
|
+
is_hotkey=is_hotkey,
|
|
408
|
+
owner_address=owner_address,
|
|
409
|
+
owner_coldkey_name=owner_coldkey_name,
|
|
410
|
+
)
|
|
411
|
+
if not matches:
|
|
412
|
+
raise FileNotFoundError(f"Wallet '{name}' not found")
|
|
413
|
+
if len(matches) > 1:
|
|
414
|
+
# Provide helpful error message with suggestions
|
|
415
|
+
hotkey_matches = [m for m in matches if m.get("is_hotkey", False)]
|
|
416
|
+
coldkey_matches = [m for m in matches if not m.get("is_hotkey", False)]
|
|
417
|
+
|
|
418
|
+
error_msg = f"Wallet '{name}' is ambiguous. Found {len(matches)} wallet(s) with this name:\n"
|
|
419
|
+
if coldkey_matches:
|
|
420
|
+
error_msg += f" - {len(coldkey_matches)} coldkey wallet(s)\n"
|
|
421
|
+
if hotkey_matches:
|
|
422
|
+
error_msg += f" - {len(hotkey_matches)} hotkey wallet(s)\n"
|
|
423
|
+
error_msg += "\nTo resolve this, specify:\n"
|
|
424
|
+
error_msg += " - is_hotkey=True for hotkey, is_hotkey=False for coldkey\n"
|
|
425
|
+
if hotkey_matches:
|
|
426
|
+
error_msg += " - owner_address=<coldkey_address> or owner_coldkey_name=<coldkey_name> for hotkeys\n"
|
|
427
|
+
raise ValueError(error_msg)
|
|
428
|
+
file_path = matches[0].get("file_path")
|
|
429
|
+
if not file_path:
|
|
430
|
+
raise FileNotFoundError(f"Could not resolve file path for wallet '{name}'")
|
|
431
|
+
return Path(file_path)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def public_key_to_evm_address(public_key: bytes) -> str:
|
|
435
|
+
"""Convert a public key to EVM address (Byte20 format)."""
|
|
436
|
+
hash_bytes = keccak(public_key)
|
|
437
|
+
address_bytes = hash_bytes[-20:]
|
|
438
|
+
return "0x" + address_bytes.hex()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_network_appropriate_key_type() -> str:
|
|
442
|
+
"""For Hypertensor EVM chain, always return ECDSA."""
|
|
443
|
+
return "ecdsa"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def validate_key_type_for_network(key_type: str) -> tuple[bool, str]:
|
|
447
|
+
"""For Hypertensor EVM chain, only ECDSA is supported."""
|
|
448
|
+
if key_type != "ecdsa":
|
|
449
|
+
return (
|
|
450
|
+
False,
|
|
451
|
+
f"Hypertensor chain requires ECDSA keys for EVM compatibility. You specified '{key_type}'.",
|
|
452
|
+
)
|
|
453
|
+
return True, ""
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def detect_address_type(address: str) -> tuple[str, str, bool]:
|
|
457
|
+
"""Detect the type and format of an address."""
|
|
458
|
+
if not address:
|
|
459
|
+
return "unknown", "Invalid", False
|
|
460
|
+
if address.startswith("0x"):
|
|
461
|
+
if len(address) == 42:
|
|
462
|
+
return "ecdsa", "EVM/ECDSA (20-byte)", True
|
|
463
|
+
else:
|
|
464
|
+
return (
|
|
465
|
+
"invalid_evm",
|
|
466
|
+
f"Invalid EVM (expected 42 chars, got {len(address)})",
|
|
467
|
+
False,
|
|
468
|
+
)
|
|
469
|
+
elif len(address) > 40 and address[0].isdigit():
|
|
470
|
+
return "legacy_ss58", "Legacy SS58/Substrate (unsupported)", False
|
|
471
|
+
elif len(address) < 20:
|
|
472
|
+
return "unknown", "Too Short", False
|
|
473
|
+
else:
|
|
474
|
+
return "unknown", "Unknown Format", False
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def format_address_display(
|
|
478
|
+
address: str, key_type: str = None, max_length: int = None, truncate: bool = True
|
|
479
|
+
) -> str:
|
|
480
|
+
"""Format address display in MetaMask style (0x + first 5 + last 5 characters)."""
|
|
481
|
+
if not address or address == "N/A":
|
|
482
|
+
return "N/A"
|
|
483
|
+
|
|
484
|
+
from ..blockchain.formatting import to_checksum_address
|
|
485
|
+
# Apply EIP-55 checksum first
|
|
486
|
+
checksummed = to_checksum_address(address) or address
|
|
487
|
+
|
|
488
|
+
# MetaMask style: 0x + first 5 characters + ... + last 5 characters
|
|
489
|
+
if truncate and len(checksummed) > 10:
|
|
490
|
+
# Remove 0x prefix if present for calculation
|
|
491
|
+
clean_address = checksummed[2:] if checksummed.startswith("0x") else checksummed
|
|
492
|
+
|
|
493
|
+
if len(clean_address) > 10:
|
|
494
|
+
display_address = f"0x{clean_address[:5]}...{clean_address[-5:]}"
|
|
495
|
+
else:
|
|
496
|
+
display_address = checksummed
|
|
497
|
+
else:
|
|
498
|
+
display_address = checksummed
|
|
499
|
+
|
|
500
|
+
return display_address
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def generate_password_hash(password: str, salt: bytes) -> bytes:
|
|
504
|
+
"""Generate a secure password hash using PBKDF2."""
|
|
505
|
+
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000)
|
|
506
|
+
return kdf.derive(password.encode("utf-8"))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def verify_password(password: str, stored_hash: bytes, salt: bytes) -> bool:
|
|
510
|
+
"""Verify a password against a stored hash."""
|
|
511
|
+
try:
|
|
512
|
+
kdf = PBKDF2HMAC(
|
|
513
|
+
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000
|
|
514
|
+
)
|
|
515
|
+
computed_hash = kdf.derive(password.encode("utf-8"))
|
|
516
|
+
return hmac.compare_digest(computed_hash, stored_hash)
|
|
517
|
+
except Exception:
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def encrypt_private_key(
|
|
522
|
+
private_key: bytes, password: str
|
|
523
|
+
) -> tuple[bytes, bytes, bytes, bytes]:
|
|
524
|
+
"""Encrypt private key with password and generate validation hash."""
|
|
525
|
+
password_salt = secrets.token_bytes(16)
|
|
526
|
+
password_hash = generate_password_hash(password, password_salt)
|
|
527
|
+
encryption_salt = secrets.token_bytes(16)
|
|
528
|
+
encryption_key = generate_password_hash(password, encryption_salt)
|
|
529
|
+
cipher = Fernet(base64.urlsafe_b64encode(encryption_key))
|
|
530
|
+
encrypted_key = cipher.encrypt(private_key)
|
|
531
|
+
return encrypted_key, password_hash, password_salt, encryption_salt
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def decrypt_private_key(
|
|
535
|
+
encrypted_key: bytes,
|
|
536
|
+
password: str,
|
|
537
|
+
password_hash: bytes,
|
|
538
|
+
password_salt: bytes,
|
|
539
|
+
encryption_salt: bytes,
|
|
540
|
+
) -> bytes:
|
|
541
|
+
"""Decrypt private key and validate password."""
|
|
542
|
+
if not verify_password(password, password_hash, password_salt):
|
|
543
|
+
raise ValueError("Invalid password")
|
|
544
|
+
encryption_key = generate_password_hash(password, encryption_salt)
|
|
545
|
+
cipher = Fernet(base64.urlsafe_b64encode(encryption_key))
|
|
546
|
+
return cipher.decrypt(encrypted_key)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@dataclass
|
|
550
|
+
class KeypairInfo:
|
|
551
|
+
"""Information about a keypair."""
|
|
552
|
+
|
|
553
|
+
name: str
|
|
554
|
+
key_type: str
|
|
555
|
+
public_key: str
|
|
556
|
+
ss58_address: str
|
|
557
|
+
evm_address: str
|
|
558
|
+
mnemonic: Optional[str] = None
|
|
559
|
+
owner_address: Optional[str] = None
|
|
560
|
+
owner_coldkey_name: Optional[str] = None
|
|
561
|
+
|
|
562
|
+
def __post_init__(self):
|
|
563
|
+
if not self.ss58_address.startswith("0x"):
|
|
564
|
+
self.ss58_address = self.evm_address
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def generate_coldkey_pair(
|
|
568
|
+
name: str, key_type: str = "ecdsa", password: Optional[str] = None
|
|
569
|
+
) -> KeypairInfo:
|
|
570
|
+
"""Generate a new coldkey pair."""
|
|
571
|
+
try:
|
|
572
|
+
# Generate random keypair
|
|
573
|
+
if key_type == "ecdsa":
|
|
574
|
+
mnemonic = Keypair.generate_mnemonic()
|
|
575
|
+
keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
|
|
576
|
+
elif key_type == "sr25519":
|
|
577
|
+
mnemonic = Keypair.generate_mnemonic()
|
|
578
|
+
keypair = Keypair.create_from_uri(mnemonic)
|
|
579
|
+
elif key_type == "ed25519":
|
|
580
|
+
mnemonic = Keypair.generate_mnemonic()
|
|
581
|
+
keypair = Keypair.create_from_uri(
|
|
582
|
+
mnemonic, crypto_type=0
|
|
583
|
+
) # 0 for ed25519, 1 for sr25519
|
|
584
|
+
else:
|
|
585
|
+
raise ValueError(f"Unsupported key type: {key_type}")
|
|
586
|
+
|
|
587
|
+
# For ECDSA keys, keypair.ss58_address already contains the correct EVM address
|
|
588
|
+
if key_type == "ecdsa":
|
|
589
|
+
evm_address = keypair.ss58_address
|
|
590
|
+
ss58_address = evm_address
|
|
591
|
+
else:
|
|
592
|
+
# For other key types, use the keypair's ss58_address
|
|
593
|
+
ss58_address = keypair.ss58_address
|
|
594
|
+
evm_address = ss58_address
|
|
595
|
+
|
|
596
|
+
# Create keypair info
|
|
597
|
+
keypair_info = KeypairInfo(
|
|
598
|
+
name=name,
|
|
599
|
+
key_type=key_type,
|
|
600
|
+
public_key=keypair.public_key.hex(),
|
|
601
|
+
ss58_address=ss58_address,
|
|
602
|
+
evm_address=evm_address,
|
|
603
|
+
mnemonic=mnemonic, # Include the recovery phrase
|
|
604
|
+
owner_address=None, # Coldkeys don't have owners
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
# Use the password directly from the CLI function
|
|
608
|
+
# The CLI function already handles the password prompting
|
|
609
|
+
save_coldkey(name, keypair, password)
|
|
610
|
+
|
|
611
|
+
return keypair_info
|
|
612
|
+
|
|
613
|
+
except Exception as e:
|
|
614
|
+
raise Exception(f"Failed to generate coldkey: {str(e)}") from e
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def generate_hotkey_pair(
|
|
618
|
+
name: str,
|
|
619
|
+
owner_address: str,
|
|
620
|
+
key_type: str = "ecdsa",
|
|
621
|
+
password: Optional[str] = None,
|
|
622
|
+
owner_coldkey_name: Optional[str] = None,
|
|
623
|
+
) -> KeypairInfo:
|
|
624
|
+
"""Generate a new hotkey pair owned by a coldkey."""
|
|
625
|
+
try:
|
|
626
|
+
# Generate random keypair
|
|
627
|
+
if key_type == "ecdsa":
|
|
628
|
+
mnemonic = Keypair.generate_mnemonic()
|
|
629
|
+
keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
|
|
630
|
+
elif key_type == "sr25519":
|
|
631
|
+
mnemonic = Keypair.generate_mnemonic()
|
|
632
|
+
keypair = Keypair.create_from_uri(mnemonic)
|
|
633
|
+
elif key_type == "ed25519":
|
|
634
|
+
mnemonic = Keypair.generate_mnemonic()
|
|
635
|
+
keypair = Keypair.create_from_uri(
|
|
636
|
+
mnemonic, crypto_type=0
|
|
637
|
+
) # 0 for ed25519, 1 for sr25519
|
|
638
|
+
else:
|
|
639
|
+
raise ValueError(f"Unsupported key type: {key_type}")
|
|
640
|
+
|
|
641
|
+
# For ECDSA keys, compute EVM address from public key (same as save_hotkey does)
|
|
642
|
+
if key_type == "ecdsa":
|
|
643
|
+
evm_address = public_key_to_evm_address(keypair.public_key)
|
|
644
|
+
ss58_address = evm_address
|
|
645
|
+
else:
|
|
646
|
+
# For other key types, use the keypair's ss58_address
|
|
647
|
+
ss58_address = keypair.ss58_address
|
|
648
|
+
evm_address = ss58_address
|
|
649
|
+
|
|
650
|
+
# Create keypair info
|
|
651
|
+
keypair_info = KeypairInfo(
|
|
652
|
+
name=name,
|
|
653
|
+
key_type=key_type,
|
|
654
|
+
public_key=keypair.public_key.hex(),
|
|
655
|
+
ss58_address=ss58_address,
|
|
656
|
+
evm_address=evm_address,
|
|
657
|
+
mnemonic=mnemonic, # Include the recovery phrase
|
|
658
|
+
owner_address=owner_address, # Hotkeys have owners
|
|
659
|
+
owner_coldkey_name=owner_coldkey_name,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Use the password directly from the CLI function
|
|
663
|
+
# The CLI function already handles the password prompting
|
|
664
|
+
save_hotkey(
|
|
665
|
+
name,
|
|
666
|
+
keypair,
|
|
667
|
+
owner_address,
|
|
668
|
+
password,
|
|
669
|
+
owner_coldkey_name=owner_coldkey_name,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
return keypair_info
|
|
673
|
+
|
|
674
|
+
except Exception as e:
|
|
675
|
+
raise Exception(f"Failed to generate hotkey: {str(e)}") from e
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def import_keypair(
|
|
679
|
+
name: str, private_key: str, key_type: str = "ecdsa", password: Optional[str] = None
|
|
680
|
+
) -> KeypairInfo:
|
|
681
|
+
"""Import a keypair from private key."""
|
|
682
|
+
if key_type != "ecdsa":
|
|
683
|
+
raise ValueError("Hypertensor chain requires ECDSA keys for EVM compatibility")
|
|
684
|
+
if private_key.startswith("0x"):
|
|
685
|
+
private_key = private_key[2:]
|
|
686
|
+
if len(private_key) != 64:
|
|
687
|
+
raise ValueError("ECDSA private key must be 64 characters (32 bytes)")
|
|
688
|
+
keypair = Keypair.create_from_private_key(private_key, crypto_type=2)
|
|
689
|
+
# For ECDSA keys, keypair.ss58_address already contains the correct EVM address
|
|
690
|
+
evm_address = keypair.ss58_address
|
|
691
|
+
keypair_info = KeypairInfo(
|
|
692
|
+
name=name,
|
|
693
|
+
key_type="ecdsa",
|
|
694
|
+
public_key=keypair.public_key.hex(),
|
|
695
|
+
ss58_address=evm_address,
|
|
696
|
+
evm_address=evm_address,
|
|
697
|
+
)
|
|
698
|
+
save_coldkey(name, keypair, password)
|
|
699
|
+
return keypair_info
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def import_keypair_from_mnemonic(
|
|
703
|
+
name: str, mnemonic: str, key_type: str = "ecdsa", password: Optional[str] = None
|
|
704
|
+
) -> KeypairInfo:
|
|
705
|
+
"""Import an existing keypair from mnemonic phrase."""
|
|
706
|
+
if key_type != "ecdsa":
|
|
707
|
+
raise ValueError("Hypertensor chain requires ECDSA keys for EVM compatibility")
|
|
708
|
+
keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
|
|
709
|
+
# For ECDSA keys, keypair.ss58_address already contains the correct EVM address
|
|
710
|
+
evm_address = keypair.ss58_address
|
|
711
|
+
keypair_info = KeypairInfo(
|
|
712
|
+
name=name,
|
|
713
|
+
key_type="ecdsa",
|
|
714
|
+
public_key=keypair.public_key.hex(),
|
|
715
|
+
ss58_address=evm_address,
|
|
716
|
+
evm_address=evm_address,
|
|
717
|
+
)
|
|
718
|
+
save_coldkey(name, keypair, password)
|
|
719
|
+
return keypair_info
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def import_hotkey_from_private_key(
|
|
723
|
+
name: str,
|
|
724
|
+
private_key: str,
|
|
725
|
+
owner_address: str,
|
|
726
|
+
key_type: str = "ecdsa",
|
|
727
|
+
password: Optional[str] = None,
|
|
728
|
+
) -> KeypairInfo:
|
|
729
|
+
"""Import an existing hotkey from private key."""
|
|
730
|
+
if key_type != "ecdsa":
|
|
731
|
+
raise ValueError("Hypertensor chain requires ECDSA keys for EVM compatibility")
|
|
732
|
+
keypair = Keypair.create_from_private_key(private_key, crypto_type=2)
|
|
733
|
+
# For ECDSA keys, compute EVM address from public key
|
|
734
|
+
evm_address = public_key_to_evm_address(keypair.public_key)
|
|
735
|
+
keypair_info = KeypairInfo(
|
|
736
|
+
name=name,
|
|
737
|
+
key_type="ecdsa",
|
|
738
|
+
public_key=keypair.public_key.hex(),
|
|
739
|
+
ss58_address=evm_address,
|
|
740
|
+
evm_address=evm_address,
|
|
741
|
+
owner_address=owner_address,
|
|
742
|
+
owner_coldkey_name=get_coldkey_name_for_address(owner_address),
|
|
743
|
+
)
|
|
744
|
+
save_hotkey(name, keypair, owner_address, password)
|
|
745
|
+
return keypair_info
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def import_hotkey_from_mnemonic(
|
|
749
|
+
name: str,
|
|
750
|
+
mnemonic: str,
|
|
751
|
+
owner_address: str,
|
|
752
|
+
key_type: str = "ecdsa",
|
|
753
|
+
password: Optional[str] = None,
|
|
754
|
+
) -> KeypairInfo:
|
|
755
|
+
"""Import an existing hotkey from mnemonic phrase."""
|
|
756
|
+
if key_type != "ecdsa":
|
|
757
|
+
raise ValueError("Hypertensor chain requires ECDSA keys for EVM compatibility")
|
|
758
|
+
keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
|
|
759
|
+
evm_address = public_key_to_evm_address(keypair.public_key)
|
|
760
|
+
keypair_info = KeypairInfo(
|
|
761
|
+
name=name,
|
|
762
|
+
key_type="ecdsa",
|
|
763
|
+
public_key=keypair.public_key.hex(),
|
|
764
|
+
ss58_address=evm_address,
|
|
765
|
+
evm_address=evm_address,
|
|
766
|
+
owner_address=owner_address,
|
|
767
|
+
owner_coldkey_name=get_coldkey_name_for_address(owner_address),
|
|
768
|
+
)
|
|
769
|
+
save_hotkey(name, keypair, owner_address, password)
|
|
770
|
+
return keypair_info
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
PRESEEDED_WALLETS = {
|
|
774
|
+
"alith": "0x5fb92d6e98884f76de468fa3f6278f8807c48bebc13595d45af5bdc4da702133",
|
|
775
|
+
"baltathar": "0x8075991ce870b93a8870eca0c0f91913d12f47948ca0fd25b49c6fa7cdbeee8b",
|
|
776
|
+
"charleth": "0x0b6e18cafb6ed99687ec547bd28139cafdd2bffe70e6b688025de6b445aa5c5b",
|
|
777
|
+
"dorothy": "0x39539ab1876910bbf3a223d84a29e28f1cb4e2e456503e7e91ed39b2e7223d68",
|
|
778
|
+
"ethan": "0x7dce9bc8babb68fec1409be38c8e1a52650206a7ed90ff956ae8a6d15eeaaef4",
|
|
779
|
+
"faith": "0xb9d2ea9a615f3165812e8d44de0d24da9bbd164b65c4f0573e1ce2c8dbd9c8df",
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def get_preseeded_wallet_info():
|
|
784
|
+
"""Get information about available preseeded wallets."""
|
|
785
|
+
return {
|
|
786
|
+
"alith": {
|
|
787
|
+
"name": "Alith",
|
|
788
|
+
"private_key": PRESEEDED_WALLETS["alith"],
|
|
789
|
+
"address": "0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac",
|
|
790
|
+
"description": "Default development account",
|
|
791
|
+
},
|
|
792
|
+
"baltathar": {
|
|
793
|
+
"name": "Baltathar",
|
|
794
|
+
"private_key": PRESEEDED_WALLETS["baltathar"],
|
|
795
|
+
"address": "0x3Cd0A705a2DC65e5b1E1205896BaA2be8A07c6e0",
|
|
796
|
+
"description": "Second development account",
|
|
797
|
+
},
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def import_preseeded_wallet(
|
|
802
|
+
preseeded_name: str,
|
|
803
|
+
wallet_name: Optional[str] = None,
|
|
804
|
+
password: Optional[str] = None,
|
|
805
|
+
) -> KeypairInfo:
|
|
806
|
+
"""Import a preseeded development wallet."""
|
|
807
|
+
preseeded_name = preseeded_name.lower()
|
|
808
|
+
if preseeded_name not in PRESEEDED_WALLETS:
|
|
809
|
+
available = ", ".join(PRESEEDED_WALLETS.keys())
|
|
810
|
+
raise ValueError(
|
|
811
|
+
f"Unknown preseeded wallet '{preseeded_name}'. Available: {available}"
|
|
812
|
+
)
|
|
813
|
+
private_key = PRESEEDED_WALLETS[preseeded_name]
|
|
814
|
+
wallet_name = wallet_name or preseeded_name
|
|
815
|
+
return import_keypair(
|
|
816
|
+
name=wallet_name, private_key=private_key, key_type="ecdsa", password=password
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def save_coldkey(name: str, keypair: Keypair, password: Optional[str]):
|
|
821
|
+
"""Save a coldkey to disk with encryption and EVM address."""
|
|
822
|
+
wallet_dir = get_wallet_directory()
|
|
823
|
+
wallet_dir.mkdir(parents=True, exist_ok=True)
|
|
824
|
+
evm_address = keypair.ss58_address
|
|
825
|
+
wallet_data = {
|
|
826
|
+
"name": name,
|
|
827
|
+
"key_type": "ecdsa",
|
|
828
|
+
"public_key": keypair.public_key.hex(),
|
|
829
|
+
"ss58_address": evm_address,
|
|
830
|
+
"evm_address": evm_address,
|
|
831
|
+
"is_hotkey": False,
|
|
832
|
+
"owner_address": None,
|
|
833
|
+
"is_encrypted": password is not None,
|
|
834
|
+
"owner_coldkey_name": name,
|
|
835
|
+
}
|
|
836
|
+
if password:
|
|
837
|
+
encrypted_key, password_hash, password_salt, encryption_salt = (
|
|
838
|
+
encrypt_private_key(keypair.private_key, password)
|
|
839
|
+
)
|
|
840
|
+
wallet_data.update(
|
|
841
|
+
{
|
|
842
|
+
"encrypted_private_key": base64.b64encode(encrypted_key).decode(),
|
|
843
|
+
"password_hash": base64.b64encode(password_hash).decode(),
|
|
844
|
+
"password_salt": base64.b64encode(password_salt).decode(),
|
|
845
|
+
"encryption_salt": base64.b64encode(encryption_salt).decode(),
|
|
846
|
+
}
|
|
847
|
+
)
|
|
848
|
+
else:
|
|
849
|
+
wallet_data["private_key"] = keypair.private_key.hex()
|
|
850
|
+
|
|
851
|
+
target_file = build_wallet_file_path(
|
|
852
|
+
name,
|
|
853
|
+
is_hotkey=False,
|
|
854
|
+
wallet_dir=wallet_dir,
|
|
855
|
+
prefer_hierarchical=True,
|
|
856
|
+
)
|
|
857
|
+
legacy_file = _legacy_coldkey_path(name, wallet_dir=wallet_dir)
|
|
858
|
+
if target_file.exists() or legacy_file.exists():
|
|
859
|
+
raise ValueError(
|
|
860
|
+
f"Wallet '{name}' already exists. Please choose another name or delete the existing coldkey."
|
|
861
|
+
)
|
|
862
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
863
|
+
with open(target_file, "w") as f:
|
|
864
|
+
json.dump(wallet_data, f, indent=2)
|
|
865
|
+
target_file.chmod(0o600)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def save_hotkey(
|
|
869
|
+
name: str,
|
|
870
|
+
keypair: Keypair,
|
|
871
|
+
owner_address: str,
|
|
872
|
+
password: Optional[str],
|
|
873
|
+
owner_coldkey_name: Optional[str] = None,
|
|
874
|
+
):
|
|
875
|
+
"""Save a hotkey to disk with encryption and EVM address."""
|
|
876
|
+
wallet_dir = get_wallet_directory()
|
|
877
|
+
wallet_dir.mkdir(parents=True, exist_ok=True)
|
|
878
|
+
# For ECDSA keys, compute EVM address from public key
|
|
879
|
+
if keypair.crypto_type == 2: # ECDSA
|
|
880
|
+
evm_address = public_key_to_evm_address(keypair.public_key)
|
|
881
|
+
else:
|
|
882
|
+
# For other key types, use ss58_address
|
|
883
|
+
evm_address = keypair.ss58_address
|
|
884
|
+
resolved_coldkey_name = owner_coldkey_name or get_coldkey_name_for_address(
|
|
885
|
+
owner_address
|
|
886
|
+
)
|
|
887
|
+
wallet_data = {
|
|
888
|
+
"name": name,
|
|
889
|
+
"key_type": "ecdsa",
|
|
890
|
+
"public_key": keypair.public_key.hex(),
|
|
891
|
+
"ss58_address": evm_address,
|
|
892
|
+
"evm_address": evm_address,
|
|
893
|
+
"is_hotkey": True,
|
|
894
|
+
"owner_address": owner_address,
|
|
895
|
+
"is_encrypted": password is not None,
|
|
896
|
+
"owner_coldkey_name": resolved_coldkey_name,
|
|
897
|
+
}
|
|
898
|
+
if password:
|
|
899
|
+
encrypted_key, password_hash, password_salt, encryption_salt = (
|
|
900
|
+
encrypt_private_key(keypair.private_key, password)
|
|
901
|
+
)
|
|
902
|
+
wallet_data.update(
|
|
903
|
+
{
|
|
904
|
+
"encrypted_private_key": base64.b64encode(encrypted_key).decode(),
|
|
905
|
+
"password_hash": base64.b64encode(password_hash).decode(),
|
|
906
|
+
"password_salt": base64.b64encode(password_salt).decode(),
|
|
907
|
+
"encryption_salt": base64.b64encode(encryption_salt).decode(),
|
|
908
|
+
}
|
|
909
|
+
)
|
|
910
|
+
else:
|
|
911
|
+
wallet_data["private_key"] = keypair.private_key.hex()
|
|
912
|
+
|
|
913
|
+
prefer_hierarchical = bool(resolved_coldkey_name) and _hierarchical_layout_enabled()
|
|
914
|
+
wallet_file = build_wallet_file_path(
|
|
915
|
+
name,
|
|
916
|
+
is_hotkey=True,
|
|
917
|
+
owner_address=owner_address,
|
|
918
|
+
owner_coldkey_name=resolved_coldkey_name,
|
|
919
|
+
prefer_hierarchical=prefer_hierarchical,
|
|
920
|
+
)
|
|
921
|
+
legacy_file = _legacy_hotkey_path(name, owner_address, wallet_dir=wallet_dir)
|
|
922
|
+
if wallet_file.exists() or legacy_file.exists():
|
|
923
|
+
raise ValueError(
|
|
924
|
+
f"Hotkey '{name}' already exists for this coldkey. Choose another name."
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
wallet_file.parent.mkdir(parents=True, exist_ok=True)
|
|
928
|
+
with open(wallet_file, "w") as f:
|
|
929
|
+
json.dump(wallet_data, f, indent=2)
|
|
930
|
+
wallet_file.chmod(0o600)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def load_keypair(
|
|
934
|
+
name: str,
|
|
935
|
+
password: Optional[str] = None,
|
|
936
|
+
file_path: Optional[Path] = None,
|
|
937
|
+
is_hotkey: Optional[bool] = None,
|
|
938
|
+
owner_address: Optional[str] = None,
|
|
939
|
+
) -> Keypair:
|
|
940
|
+
"""Load a keypair from disk.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
name: Wallet name
|
|
944
|
+
password: Optional password for encrypted wallets
|
|
945
|
+
file_path: Optional direct file path (bypasses name resolution)
|
|
946
|
+
is_hotkey: Optional hint to disambiguate when name resolution is needed
|
|
947
|
+
owner_address: Optional owner address for hotkey disambiguation
|
|
948
|
+
"""
|
|
949
|
+
if file_path:
|
|
950
|
+
keypair_file = Path(file_path)
|
|
951
|
+
else:
|
|
952
|
+
keypair_file = resolve_wallet_file_path(
|
|
953
|
+
name, is_hotkey=is_hotkey, owner_address=owner_address
|
|
954
|
+
)
|
|
955
|
+
with open(keypair_file) as f:
|
|
956
|
+
keypair_data = json.load(f)
|
|
957
|
+
is_encrypted = keypair_data.get("is_encrypted", True)
|
|
958
|
+
if is_encrypted:
|
|
959
|
+
from ...errors.base import InvalidPasswordError
|
|
960
|
+
from .auth import get_unlock_password
|
|
961
|
+
|
|
962
|
+
decrypt_password = password or get_unlock_password(
|
|
963
|
+
name, "Enter password to unlock this keypair"
|
|
964
|
+
)
|
|
965
|
+
if "password_hash" in keypair_data and "password_salt" in keypair_data:
|
|
966
|
+
encrypted_private_key = base64.b64decode(
|
|
967
|
+
keypair_data["encrypted_private_key"]
|
|
968
|
+
)
|
|
969
|
+
password_hash = base64.b64decode(keypair_data["password_hash"])
|
|
970
|
+
password_salt = base64.b64decode(keypair_data["password_salt"])
|
|
971
|
+
encryption_salt = base64.b64decode(keypair_data["encryption_salt"])
|
|
972
|
+
try:
|
|
973
|
+
private_key_bytes = decrypt_private_key(
|
|
974
|
+
encrypted_private_key,
|
|
975
|
+
decrypt_password,
|
|
976
|
+
password_hash,
|
|
977
|
+
password_salt,
|
|
978
|
+
encryption_salt,
|
|
979
|
+
)
|
|
980
|
+
except ValueError as e:
|
|
981
|
+
raise InvalidPasswordError("Invalid password") from e
|
|
982
|
+
else:
|
|
983
|
+
salt = base64.b64decode(keypair_data["salt"])
|
|
984
|
+
encrypted_private_key = base64.b64decode(
|
|
985
|
+
keypair_data["encrypted_private_key"]
|
|
986
|
+
)
|
|
987
|
+
try:
|
|
988
|
+
cipher = Fernet(salt)
|
|
989
|
+
private_key_bytes = cipher.decrypt(encrypted_private_key)
|
|
990
|
+
except Exception as e:
|
|
991
|
+
raise InvalidPasswordError(
|
|
992
|
+
"Invalid password or corrupted wallet file"
|
|
993
|
+
) from e
|
|
994
|
+
else:
|
|
995
|
+
if "private_key" in keypair_data:
|
|
996
|
+
private_key_bytes = bytes.fromhex(keypair_data["private_key"])
|
|
997
|
+
else:
|
|
998
|
+
raise ValueError("Key file is corrupted: missing private key")
|
|
999
|
+
key_type = keypair_data["key_type"].lower()
|
|
1000
|
+
if key_type == "sr25519":
|
|
1001
|
+
keypair = Keypair.create_from_private_key(private_key_bytes.hex())
|
|
1002
|
+
elif key_type == "ecdsa":
|
|
1003
|
+
# Match mesh-template pattern - no ss58_format specified for ECDSA
|
|
1004
|
+
keypair = Keypair.create_from_private_key(
|
|
1005
|
+
private_key_bytes.hex(), crypto_type=2
|
|
1006
|
+
)
|
|
1007
|
+
else:
|
|
1008
|
+
keypair = Keypair.create_from_private_key(
|
|
1009
|
+
private_key_bytes.hex(), crypto_type=0
|
|
1010
|
+
)
|
|
1011
|
+
return keypair
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def list_keys() -> list[dict]:
|
|
1015
|
+
"""List all available keys."""
|
|
1016
|
+
entries = _load_wallet_entries()
|
|
1017
|
+
keys: list[dict] = []
|
|
1018
|
+
for entry in entries:
|
|
1019
|
+
public_key_hex = entry.get("public_key")
|
|
1020
|
+
evm_address = entry.get("evm_address")
|
|
1021
|
+
if not evm_address and public_key_hex:
|
|
1022
|
+
try:
|
|
1023
|
+
evm_address = public_key_to_evm_address(bytes.fromhex(public_key_hex))
|
|
1024
|
+
except Exception:
|
|
1025
|
+
evm_address = None
|
|
1026
|
+
|
|
1027
|
+
ss58_address = entry.get("ss58_address")
|
|
1028
|
+
preferred_address = evm_address or ss58_address
|
|
1029
|
+
|
|
1030
|
+
key_info = {
|
|
1031
|
+
"name": entry.get("name"),
|
|
1032
|
+
"key_type": entry.get("key_type"),
|
|
1033
|
+
"public_key": public_key_hex,
|
|
1034
|
+
"ss58_address": ss58_address,
|
|
1035
|
+
"evm_address": evm_address,
|
|
1036
|
+
"address": preferred_address,
|
|
1037
|
+
"is_hotkey": entry.get("is_hotkey", False),
|
|
1038
|
+
"owner_address": entry.get("owner_address"),
|
|
1039
|
+
"is_encrypted": entry.get("is_encrypted", True),
|
|
1040
|
+
"file_path": entry.get("file_path"),
|
|
1041
|
+
"owner_coldkey_name": entry.get("owner_coldkey_name"),
|
|
1042
|
+
}
|
|
1043
|
+
keys.append(key_info)
|
|
1044
|
+
return keys
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def get_wallet_info_by_name(
|
|
1048
|
+
name: str,
|
|
1049
|
+
is_hotkey: Optional[bool] = None,
|
|
1050
|
+
owner_address: Optional[str] = None,
|
|
1051
|
+
owner_coldkey_name: Optional[str] = None,
|
|
1052
|
+
) -> dict:
|
|
1053
|
+
"""Get wallet information by name without loading the private key.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
name: Wallet display name
|
|
1057
|
+
is_hotkey: Optional filter to require hotkey/coldkey
|
|
1058
|
+
owner_address: Optional coldkey address for hotkey disambiguation
|
|
1059
|
+
owner_coldkey_name: Optional coldkey name for hotkey disambiguation
|
|
1060
|
+
"""
|
|
1061
|
+
matches = _find_wallet_entries(
|
|
1062
|
+
name,
|
|
1063
|
+
is_hotkey=is_hotkey,
|
|
1064
|
+
owner_address=owner_address,
|
|
1065
|
+
owner_coldkey_name=owner_coldkey_name,
|
|
1066
|
+
)
|
|
1067
|
+
if not matches:
|
|
1068
|
+
raise FileNotFoundError(f"Wallet '{name}' not found")
|
|
1069
|
+
if len(matches) > 1:
|
|
1070
|
+
if is_hotkey is None:
|
|
1071
|
+
# Prefer coldkey if ambiguous, but warn user
|
|
1072
|
+
preferred = next(
|
|
1073
|
+
(entry for entry in matches if not entry.get("is_hotkey", False)),
|
|
1074
|
+
matches[0],
|
|
1075
|
+
)
|
|
1076
|
+
matches = [preferred]
|
|
1077
|
+
else:
|
|
1078
|
+
# is_hotkey specified but still ambiguous - need owner_address for hotkeys
|
|
1079
|
+
hotkey_matches = [
|
|
1080
|
+
m for m in matches if m.get("is_hotkey", False) == is_hotkey
|
|
1081
|
+
]
|
|
1082
|
+
if len(hotkey_matches) > 1 and is_hotkey:
|
|
1083
|
+
# FIX: Raise custom exception with matches for better handling
|
|
1084
|
+
from ...errors.base import AmbiguousWalletError
|
|
1085
|
+
raise AmbiguousWalletError(name, hotkey_matches, is_hotkey=True)
|
|
1086
|
+
elif len(hotkey_matches) > 0:
|
|
1087
|
+
matches = hotkey_matches
|
|
1088
|
+
else:
|
|
1089
|
+
# Wrong type specified
|
|
1090
|
+
raise ValueError(
|
|
1091
|
+
f"Wallet '{name}' found but doesn't match specified type "
|
|
1092
|
+
f"(is_hotkey={is_hotkey}). Found: {[m.get('is_hotkey') for m in matches]}"
|
|
1093
|
+
)
|
|
1094
|
+
keypair_data = matches[0]
|
|
1095
|
+
|
|
1096
|
+
public_key_hex = keypair_data.get("public_key")
|
|
1097
|
+
evm_address = keypair_data.get("evm_address")
|
|
1098
|
+
if not evm_address and public_key_hex:
|
|
1099
|
+
try:
|
|
1100
|
+
evm_address = public_key_to_evm_address(bytes.fromhex(public_key_hex))
|
|
1101
|
+
except Exception:
|
|
1102
|
+
evm_address = None
|
|
1103
|
+
|
|
1104
|
+
ss58_address = keypair_data.get("ss58_address")
|
|
1105
|
+
preferred_address = evm_address or ss58_address
|
|
1106
|
+
|
|
1107
|
+
wallet_info = {
|
|
1108
|
+
"name": keypair_data.get("name"),
|
|
1109
|
+
"key_type": keypair_data.get("key_type"),
|
|
1110
|
+
"public_key": public_key_hex,
|
|
1111
|
+
"ss58_address": ss58_address,
|
|
1112
|
+
"evm_address": evm_address,
|
|
1113
|
+
"address": preferred_address,
|
|
1114
|
+
"is_hotkey": keypair_data.get("is_hotkey", False),
|
|
1115
|
+
"owner_address": keypair_data.get("owner_address"),
|
|
1116
|
+
"owner_coldkey_name": keypair_data.get("owner_coldkey_name"),
|
|
1117
|
+
"is_encrypted": keypair_data.get("is_encrypted", True),
|
|
1118
|
+
"file_path": keypair_data.get("file_path"),
|
|
1119
|
+
}
|
|
1120
|
+
return wallet_info
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def wallet_name_exists(name: str) -> bool:
|
|
1124
|
+
"""Check if a wallet name already exists."""
|
|
1125
|
+
return bool(_find_wallet_entries(name))
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def get_coldkey_name_for_address(address: str) -> Optional[str]:
|
|
1129
|
+
"""Resolve the coldkey name that owns a given address."""
|
|
1130
|
+
if not address:
|
|
1131
|
+
return None
|
|
1132
|
+
for entry in _load_wallet_entries():
|
|
1133
|
+
if entry.get("is_hotkey", False):
|
|
1134
|
+
continue
|
|
1135
|
+
if entry.get("ss58_address") == address or entry.get("evm_address") == address:
|
|
1136
|
+
return entry.get("name")
|
|
1137
|
+
return None
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def coldkey_has_hotkey(coldkey_address: str, hotkey_name: str) -> bool:
|
|
1141
|
+
"""Check if a specific coldkey already has a hotkey with the given name.
|
|
1142
|
+
|
|
1143
|
+
Args:
|
|
1144
|
+
coldkey_address: The SS58 or EVM address of the coldkey
|
|
1145
|
+
hotkey_name: The name of the hotkey to check
|
|
1146
|
+
|
|
1147
|
+
Returns:
|
|
1148
|
+
True if the coldkey already has a hotkey with this name, False otherwise
|
|
1149
|
+
"""
|
|
1150
|
+
all_keys = list_keys()
|
|
1151
|
+
for key in all_keys:
|
|
1152
|
+
if (
|
|
1153
|
+
key.get("is_hotkey", False)
|
|
1154
|
+
and key.get("name") == hotkey_name
|
|
1155
|
+
and key.get("owner_address") == coldkey_address
|
|
1156
|
+
):
|
|
1157
|
+
return True
|
|
1158
|
+
return False
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def delete_keypair(
|
|
1162
|
+
name: str,
|
|
1163
|
+
is_hotkey: Optional[bool] = None,
|
|
1164
|
+
owner_address: Optional[str] = None,
|
|
1165
|
+
owner_coldkey_name: Optional[str] = None,
|
|
1166
|
+
) -> bool:
|
|
1167
|
+
"""Delete a keypair from disk.
|
|
1168
|
+
|
|
1169
|
+
This function deletes ALL matching files (both legacy and hierarchical formats)
|
|
1170
|
+
to ensure complete deletion.
|
|
1171
|
+
|
|
1172
|
+
Args:
|
|
1173
|
+
name: Wallet name to delete
|
|
1174
|
+
is_hotkey: Optional hint to specify if this is a hotkey (True) or coldkey (False)
|
|
1175
|
+
owner_address: Optional coldkey address for hotkey disambiguation
|
|
1176
|
+
"""
|
|
1177
|
+
# Find ALL matching files (may exist in both legacy and hierarchical locations)
|
|
1178
|
+
try:
|
|
1179
|
+
matches = _find_wallet_entries(
|
|
1180
|
+
name,
|
|
1181
|
+
is_hotkey=is_hotkey,
|
|
1182
|
+
owner_address=owner_address,
|
|
1183
|
+
owner_coldkey_name=owner_coldkey_name,
|
|
1184
|
+
)
|
|
1185
|
+
except Exception:
|
|
1186
|
+
return False
|
|
1187
|
+
|
|
1188
|
+
if not matches:
|
|
1189
|
+
return False
|
|
1190
|
+
|
|
1191
|
+
deleted_any = False
|
|
1192
|
+
deleted_paths = []
|
|
1193
|
+
|
|
1194
|
+
# Delete all matching files
|
|
1195
|
+
for match in matches:
|
|
1196
|
+
file_path = match.get("file_path")
|
|
1197
|
+
if not file_path:
|
|
1198
|
+
continue
|
|
1199
|
+
try:
|
|
1200
|
+
keypair_file = Path(file_path)
|
|
1201
|
+
if keypair_file.exists():
|
|
1202
|
+
keypair_file.unlink()
|
|
1203
|
+
deleted_any = True
|
|
1204
|
+
deleted_paths.append(keypair_file)
|
|
1205
|
+
except Exception:
|
|
1206
|
+
continue
|
|
1207
|
+
|
|
1208
|
+
# Clean up empty directories for hierarchical layout
|
|
1209
|
+
for deleted_path in deleted_paths:
|
|
1210
|
+
try:
|
|
1211
|
+
parent_dir = deleted_path.parent
|
|
1212
|
+
if parent_dir.name == HOTKEYS_SUBDIR and not any(parent_dir.iterdir()):
|
|
1213
|
+
parent_dir.rmdir()
|
|
1214
|
+
coldkey_dir = parent_dir.parent
|
|
1215
|
+
if not any(coldkey_dir.iterdir()):
|
|
1216
|
+
coldkey_dir.rmdir()
|
|
1217
|
+
except Exception:
|
|
1218
|
+
pass
|
|
1219
|
+
|
|
1220
|
+
return deleted_any
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def delete_coldkey_and_hotkeys(coldkey_name: str) -> dict:
|
|
1224
|
+
"""Delete a coldkey and all its associated hotkeys.
|
|
1225
|
+
|
|
1226
|
+
Finds hotkeys by both owner_address (in all formats) and owner_coldkey_name to ensure
|
|
1227
|
+
all associated hotkeys are found and deleted, even if metadata is inconsistent.
|
|
1228
|
+
"""
|
|
1229
|
+
coldkey_info = get_wallet_info_by_name(coldkey_name, is_hotkey=False)
|
|
1230
|
+
if coldkey_info.get("is_hotkey", False):
|
|
1231
|
+
raise Exception(f"'{coldkey_name}' is a hotkey, not a coldkey")
|
|
1232
|
+
|
|
1233
|
+
# Get all possible address formats from coldkey info
|
|
1234
|
+
coldkey_addresses = set()
|
|
1235
|
+
if coldkey_info.get("address"):
|
|
1236
|
+
coldkey_addresses.add(coldkey_info["address"])
|
|
1237
|
+
if coldkey_info.get("ss58_address"):
|
|
1238
|
+
coldkey_addresses.add(coldkey_info["ss58_address"])
|
|
1239
|
+
if coldkey_info.get("evm_address"):
|
|
1240
|
+
coldkey_addresses.add(coldkey_info["evm_address"])
|
|
1241
|
+
|
|
1242
|
+
all_keys = list_keys()
|
|
1243
|
+
# Find hotkeys by matching any address format OR owner_coldkey_name
|
|
1244
|
+
associated_hotkeys = []
|
|
1245
|
+
for k in all_keys:
|
|
1246
|
+
if not k.get("is_hotkey", False):
|
|
1247
|
+
continue
|
|
1248
|
+
|
|
1249
|
+
# Check if owner_address matches any of the coldkey's address formats
|
|
1250
|
+
owner_address = k.get("owner_address")
|
|
1251
|
+
owner_matches = owner_address and owner_address in coldkey_addresses
|
|
1252
|
+
|
|
1253
|
+
# Check if owner_coldkey_name matches
|
|
1254
|
+
owner_name_matches = k.get("owner_coldkey_name") == coldkey_name
|
|
1255
|
+
|
|
1256
|
+
if owner_matches or owner_name_matches:
|
|
1257
|
+
associated_hotkeys.append(k)
|
|
1258
|
+
|
|
1259
|
+
if not delete_keypair(coldkey_name, is_hotkey=False):
|
|
1260
|
+
raise Exception(f"Failed to delete coldkey '{coldkey_name}'")
|
|
1261
|
+
|
|
1262
|
+
# Delete hotkeys - delete_keypair will now delete ALL matching files (legacy + hierarchical)
|
|
1263
|
+
hotkeys_deleted = []
|
|
1264
|
+
for hotkey in associated_hotkeys:
|
|
1265
|
+
hotkey_name = hotkey["name"]
|
|
1266
|
+
# Try multiple deletion strategies to ensure we catch all files
|
|
1267
|
+
deleted = False
|
|
1268
|
+
|
|
1269
|
+
# Strategy 1: Delete with owner_address
|
|
1270
|
+
if hotkey.get("owner_address"):
|
|
1271
|
+
deleted = delete_keypair(
|
|
1272
|
+
hotkey_name,
|
|
1273
|
+
is_hotkey=True,
|
|
1274
|
+
owner_address=hotkey.get("owner_address"),
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
# Strategy 2: Delete with owner_coldkey_name (if not already deleted)
|
|
1278
|
+
if not deleted and (hotkey.get("owner_coldkey_name") or coldkey_name):
|
|
1279
|
+
deleted = delete_keypair(
|
|
1280
|
+
hotkey_name,
|
|
1281
|
+
is_hotkey=True,
|
|
1282
|
+
owner_coldkey_name=hotkey.get("owner_coldkey_name") or coldkey_name,
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
# Strategy 3: Try deleting without owner context (as fallback)
|
|
1286
|
+
if not deleted:
|
|
1287
|
+
deleted = delete_keypair(
|
|
1288
|
+
hotkey_name,
|
|
1289
|
+
is_hotkey=True,
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
if deleted:
|
|
1293
|
+
hotkeys_deleted.append(hotkey_name)
|
|
1294
|
+
|
|
1295
|
+
return {
|
|
1296
|
+
"coldkey_deleted": coldkey_name,
|
|
1297
|
+
"hotkeys_deleted": hotkeys_deleted,
|
|
1298
|
+
"total_hotkeys_deleted": len(hotkeys_deleted),
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def update_coldkey(
|
|
1303
|
+
current_name: str,
|
|
1304
|
+
new_name: Optional[str] = None,
|
|
1305
|
+
new_password: Optional[str] = None,
|
|
1306
|
+
remove_password: bool = False,
|
|
1307
|
+
current_password: Optional[str] = None,
|
|
1308
|
+
) -> dict:
|
|
1309
|
+
"""Update a coldkey's properties.
|
|
1310
|
+
|
|
1311
|
+
Flow:
|
|
1312
|
+
- If keeping password and only changing name: copy encrypted file directly (no password needed)
|
|
1313
|
+
- If keeping password and no changes: do nothing
|
|
1314
|
+
- If changing password: decrypt with current_password, encrypt with new_password
|
|
1315
|
+
- If removing password: decrypt with current_password, save unencrypted
|
|
1316
|
+
- If adding password: load unencrypted, encrypt with new_password
|
|
1317
|
+
"""
|
|
1318
|
+
import json
|
|
1319
|
+
from pathlib import Path
|
|
1320
|
+
|
|
1321
|
+
wallet_info = get_wallet_info_by_name(current_name, is_hotkey=False)
|
|
1322
|
+
if wallet_info.get("is_hotkey", False):
|
|
1323
|
+
raise Exception(f"'{current_name}' is a hotkey, not a coldkey")
|
|
1324
|
+
|
|
1325
|
+
is_encrypted = wallet_info.get("is_encrypted", False)
|
|
1326
|
+
final_name = new_name or current_name
|
|
1327
|
+
name_changed = bool(new_name and new_name != current_name)
|
|
1328
|
+
|
|
1329
|
+
# Check if new name already exists (only check for coldkeys, not hotkeys)
|
|
1330
|
+
if name_changed:
|
|
1331
|
+
try:
|
|
1332
|
+
get_wallet_info_by_name(final_name, is_hotkey=False)
|
|
1333
|
+
# If we found a coldkey with this name, it's a conflict
|
|
1334
|
+
raise Exception(f"Wallet name '{final_name}' already exists")
|
|
1335
|
+
except FileNotFoundError:
|
|
1336
|
+
# Good - no coldkey with this name exists
|
|
1337
|
+
pass
|
|
1338
|
+
|
|
1339
|
+
# Special case: only changing name (no password changes) - just copy the file
|
|
1340
|
+
if new_password is None and not remove_password and name_changed:
|
|
1341
|
+
wallet_file = Path.home() / ".htcli" / "wallets" / f"{current_name}.json"
|
|
1342
|
+
new_wallet_file = Path.home() / ".htcli" / "wallets" / f"{final_name}.json"
|
|
1343
|
+
if wallet_file.exists():
|
|
1344
|
+
with open(wallet_file) as f:
|
|
1345
|
+
wallet_data = json.load(f)
|
|
1346
|
+
# Update the name in the file if it's stored there
|
|
1347
|
+
if "name" in wallet_data:
|
|
1348
|
+
wallet_data["name"] = final_name
|
|
1349
|
+
with open(new_wallet_file, "w") as f:
|
|
1350
|
+
json.dump(wallet_data, f, indent=2)
|
|
1351
|
+
new_wallet_file.chmod(0o600)
|
|
1352
|
+
wallet_file.unlink()
|
|
1353
|
+
# Use the original wallet_info since we just copied the file (same address)
|
|
1354
|
+
return {
|
|
1355
|
+
"old_name": current_name,
|
|
1356
|
+
"new_name": final_name,
|
|
1357
|
+
"key_type": wallet_info["key_type"],
|
|
1358
|
+
"ss58_address": wallet_info["evm_address"],
|
|
1359
|
+
"password_updated": False,
|
|
1360
|
+
"name_updated": True,
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
# If nothing to change, return early
|
|
1364
|
+
if not name_changed and new_password is None and not remove_password:
|
|
1365
|
+
return {
|
|
1366
|
+
"old_name": current_name,
|
|
1367
|
+
"new_name": final_name,
|
|
1368
|
+
"key_type": wallet_info["key_type"],
|
|
1369
|
+
"ss58_address": wallet_info["evm_address"],
|
|
1370
|
+
"password_updated": False,
|
|
1371
|
+
"name_updated": False,
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
# Need to load keypair - handle different cases:
|
|
1375
|
+
# 1. Encrypted wallet + changing/removing password: need current password
|
|
1376
|
+
# 2. Encrypted wallet + keeping password + changing name: can copy file (handled above)
|
|
1377
|
+
# 3. Unencrypted wallet + adding password: load directly without password prompt
|
|
1378
|
+
# 4. Unencrypted wallet + keeping unencrypted + changing name: can copy file (handled above)
|
|
1379
|
+
need_password = is_encrypted and (new_password is not None or remove_password)
|
|
1380
|
+
if need_password:
|
|
1381
|
+
# Wallet is encrypted and we're changing/removing password - need current password
|
|
1382
|
+
keypair = load_keypair(current_name, password=current_password, is_hotkey=False)
|
|
1383
|
+
elif not is_encrypted:
|
|
1384
|
+
# Wallet is NOT encrypted - load directly from file to avoid any password prompts
|
|
1385
|
+
# This handles the case where we're adding a password to an unencrypted wallet
|
|
1386
|
+
wallet_file = resolve_wallet_file_path(current_name, is_hotkey=False)
|
|
1387
|
+
with open(wallet_file) as f:
|
|
1388
|
+
keypair_data = json.load(f)
|
|
1389
|
+
# Verify it's actually not encrypted
|
|
1390
|
+
if keypair_data.get("is_encrypted", False):
|
|
1391
|
+
# File says it's encrypted but wallet_info says it's not - use load_keypair
|
|
1392
|
+
keypair = load_keypair(current_name, password=current_password)
|
|
1393
|
+
else:
|
|
1394
|
+
# Load private key directly without any password prompts
|
|
1395
|
+
private_key_hex = keypair_data.get("private_key")
|
|
1396
|
+
if not private_key_hex:
|
|
1397
|
+
raise ValueError("Key file is corrupted: missing private key")
|
|
1398
|
+
private_key_bytes = bytes.fromhex(private_key_hex)
|
|
1399
|
+
key_type = keypair_data.get("key_type", "ecdsa").lower()
|
|
1400
|
+
if key_type == "sr25519":
|
|
1401
|
+
keypair = Keypair.create_from_private_key(private_key_bytes.hex())
|
|
1402
|
+
elif key_type == "ecdsa":
|
|
1403
|
+
keypair = Keypair.create_from_private_key(
|
|
1404
|
+
private_key_bytes.hex(), crypto_type=2
|
|
1405
|
+
)
|
|
1406
|
+
else:
|
|
1407
|
+
keypair = Keypair.create_from_private_key(
|
|
1408
|
+
private_key_bytes.hex(), crypto_type=0
|
|
1409
|
+
)
|
|
1410
|
+
else:
|
|
1411
|
+
# Encrypted wallet but keeping password and changing name - should have been handled above
|
|
1412
|
+
# This shouldn't happen, but fall back to load_keypair
|
|
1413
|
+
keypair = load_keypair(current_name, password=current_password, is_hotkey=False)
|
|
1414
|
+
|
|
1415
|
+
# Determine final password for saving
|
|
1416
|
+
if remove_password:
|
|
1417
|
+
final_password = None
|
|
1418
|
+
elif new_password is not None:
|
|
1419
|
+
final_password = new_password
|
|
1420
|
+
else:
|
|
1421
|
+
# Keeping password - but we can't preserve it through save_coldkey
|
|
1422
|
+
# So if we're here and keeping password, we should have handled it above
|
|
1423
|
+
# This means we're keeping password but changing name, which was handled
|
|
1424
|
+
# Or we're adding password to unencrypted wallet
|
|
1425
|
+
final_password = (
|
|
1426
|
+
new_password # Will be None for keeping, but we shouldn't reach here
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
# Save wallet with new properties
|
|
1430
|
+
save_coldkey(final_name, keypair, final_password)
|
|
1431
|
+
|
|
1432
|
+
if name_changed:
|
|
1433
|
+
delete_keypair(current_name, is_hotkey=False)
|
|
1434
|
+
|
|
1435
|
+
return {
|
|
1436
|
+
"old_name": current_name,
|
|
1437
|
+
"new_name": final_name,
|
|
1438
|
+
"key_type": wallet_info["key_type"],
|
|
1439
|
+
"ss58_address": wallet_info["evm_address"],
|
|
1440
|
+
"password_updated": new_password is not None or remove_password,
|
|
1441
|
+
"name_updated": name_changed,
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def update_hotkey(
|
|
1446
|
+
current_name: str,
|
|
1447
|
+
new_name: Optional[str] = None,
|
|
1448
|
+
new_password: Optional[str] = None,
|
|
1449
|
+
remove_password: bool = False,
|
|
1450
|
+
new_owner_name: Optional[str] = None,
|
|
1451
|
+
current_password: Optional[str] = None,
|
|
1452
|
+
owner_address: Optional[str] = None,
|
|
1453
|
+
) -> dict:
|
|
1454
|
+
"""Update a hotkey's properties.
|
|
1455
|
+
|
|
1456
|
+
Flow:
|
|
1457
|
+
- If changing password: decrypt with current_password, encrypt with new_password
|
|
1458
|
+
- If removing password: decrypt with current_password, save unencrypted
|
|
1459
|
+
- If adding password: load unencrypted, encrypt with new_password
|
|
1460
|
+
|
|
1461
|
+
Args:
|
|
1462
|
+
owner_address: Optional owner address to disambiguate when hotkey name is ambiguous
|
|
1463
|
+
"""
|
|
1464
|
+
# Load keypair with current password (if encrypted and changing/removing password)
|
|
1465
|
+
wallet_info = get_wallet_info_by_name(
|
|
1466
|
+
current_name, is_hotkey=True, owner_address=owner_address
|
|
1467
|
+
)
|
|
1468
|
+
if not wallet_info.get("is_hotkey", False):
|
|
1469
|
+
raise Exception(f"'{current_name}' is a coldkey, not a hotkey")
|
|
1470
|
+
|
|
1471
|
+
is_encrypted = wallet_info.get("is_encrypted", False)
|
|
1472
|
+
# Use the file path from wallet_info to avoid ambiguity
|
|
1473
|
+
# (since we already know which wallet we're working with)
|
|
1474
|
+
wallet_file_path = wallet_info.get("file_path")
|
|
1475
|
+
if wallet_file_path:
|
|
1476
|
+
from pathlib import Path
|
|
1477
|
+
|
|
1478
|
+
file_path = Path(wallet_file_path)
|
|
1479
|
+
else:
|
|
1480
|
+
file_path = None
|
|
1481
|
+
|
|
1482
|
+
# Use current_password if provided (for changing/removing), otherwise let load_keypair prompt
|
|
1483
|
+
keypair = load_keypair(
|
|
1484
|
+
current_name,
|
|
1485
|
+
password=current_password
|
|
1486
|
+
if (is_encrypted and (new_password or remove_password))
|
|
1487
|
+
else None,
|
|
1488
|
+
file_path=file_path,
|
|
1489
|
+
is_hotkey=True,
|
|
1490
|
+
owner_address=wallet_info.get("owner_address"),
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
# CRITICAL: Verify that the loaded keypair matches the expected address
|
|
1494
|
+
# This prevents accidentally using a different hotkey's keypair
|
|
1495
|
+
expected_address = wallet_info.get("evm_address") or wallet_info.get("ss58_address")
|
|
1496
|
+
if expected_address:
|
|
1497
|
+
# For ECDSA keys, compute EVM address from public key
|
|
1498
|
+
if keypair.crypto_type == 2: # ECDSA
|
|
1499
|
+
actual_address = public_key_to_evm_address(keypair.public_key)
|
|
1500
|
+
else:
|
|
1501
|
+
actual_address = keypair.ss58_address
|
|
1502
|
+
|
|
1503
|
+
# Normalize addresses for comparison (remove 0x prefix and convert to lowercase)
|
|
1504
|
+
def normalize_address(addr):
|
|
1505
|
+
if not addr:
|
|
1506
|
+
return None
|
|
1507
|
+
addr_str = str(addr).lower().replace("0x", "")
|
|
1508
|
+
return addr_str
|
|
1509
|
+
|
|
1510
|
+
expected_normalized = normalize_address(expected_address)
|
|
1511
|
+
actual_normalized = normalize_address(actual_address)
|
|
1512
|
+
|
|
1513
|
+
if expected_normalized != actual_normalized:
|
|
1514
|
+
raise Exception(
|
|
1515
|
+
f"CRITICAL ERROR: Loaded keypair address ({actual_address}) does not match "
|
|
1516
|
+
f"expected address ({expected_address}). This indicates the wrong wallet file "
|
|
1517
|
+
f"was loaded or the keypair was corrupted. Aborting update to prevent data loss."
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
final_name = new_name or current_name
|
|
1521
|
+
name_changed = bool(new_name and new_name != current_name)
|
|
1522
|
+
|
|
1523
|
+
owner_address = wallet_info["owner_address"]
|
|
1524
|
+
if new_owner_name:
|
|
1525
|
+
new_owner_info = get_wallet_info_by_name(new_owner_name, is_hotkey=False)
|
|
1526
|
+
if new_owner_info.get("is_hotkey", False):
|
|
1527
|
+
raise Exception(
|
|
1528
|
+
f"'{new_owner_name}' is a hotkey. Please provide a coldkey wallet name as the owner."
|
|
1529
|
+
)
|
|
1530
|
+
owner_address = new_owner_info["evm_address"]
|
|
1531
|
+
|
|
1532
|
+
# Check if the new name already exists for this coldkey (but not if it's the same hotkey we're updating)
|
|
1533
|
+
if name_changed:
|
|
1534
|
+
# Check if another hotkey with this name already exists for the same coldkey
|
|
1535
|
+
# We need to check all hotkeys to see if there's a different one with the same name
|
|
1536
|
+
all_keys = list_keys()
|
|
1537
|
+
current_hotkey_address = wallet_info.get("ss58_address") or wallet_info.get(
|
|
1538
|
+
"evm_address"
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
for key in all_keys:
|
|
1542
|
+
if (
|
|
1543
|
+
key.get("is_hotkey", False)
|
|
1544
|
+
and key.get("name") == final_name
|
|
1545
|
+
and key.get("owner_address") == owner_address
|
|
1546
|
+
and (
|
|
1547
|
+
key.get("ss58_address")
|
|
1548
|
+
or key.get("evm_address")
|
|
1549
|
+
or key.get("address")
|
|
1550
|
+
)
|
|
1551
|
+
!= current_hotkey_address
|
|
1552
|
+
):
|
|
1553
|
+
# Found a different hotkey with the same name for the same coldkey - conflict!
|
|
1554
|
+
raise Exception(
|
|
1555
|
+
f"Coldkey already has a hotkey named '{final_name}'. "
|
|
1556
|
+
"Please choose a different name for this hotkey."
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
# Determine final password: None if removing, new_password if changing/adding, None if keeping
|
|
1560
|
+
final_password = None if remove_password else new_password
|
|
1561
|
+
|
|
1562
|
+
# Get owner_coldkey_name for proper hierarchical layout
|
|
1563
|
+
owner_coldkey_name = wallet_info.get("owner_coldkey_name")
|
|
1564
|
+
if new_owner_name:
|
|
1565
|
+
# Owner is being changed - get the new owner's name
|
|
1566
|
+
new_owner_info = get_wallet_info_by_name(new_owner_name, is_hotkey=False)
|
|
1567
|
+
owner_coldkey_name = new_owner_info.get("name")
|
|
1568
|
+
|
|
1569
|
+
# Check if we're updating the same file (same name and same owner)
|
|
1570
|
+
# If so, we need to delete the old file first before saving the new one
|
|
1571
|
+
final_file_path = build_wallet_file_path(
|
|
1572
|
+
final_name, is_hotkey=True, owner_address=owner_address, owner_coldkey_name=owner_coldkey_name
|
|
1573
|
+
)
|
|
1574
|
+
current_file_path = Path(wallet_info.get("file_path", ""))
|
|
1575
|
+
old_owner_address = wallet_info["owner_address"]
|
|
1576
|
+
owner_changed = new_owner_name is not None and old_owner_address != owner_address
|
|
1577
|
+
|
|
1578
|
+
# If the final file path is the same as the current file path, delete it first
|
|
1579
|
+
# This handles the case where we're updating password/encryption without changing the name or owner
|
|
1580
|
+
if final_file_path == current_file_path and current_file_path.exists():
|
|
1581
|
+
current_file_path.unlink()
|
|
1582
|
+
# If name or owner changed, delete the old file (different path)
|
|
1583
|
+
elif (name_changed or owner_changed) and current_file_path.exists():
|
|
1584
|
+
current_file_path.unlink()
|
|
1585
|
+
# Also check for legacy file path if owner changed
|
|
1586
|
+
if owner_changed:
|
|
1587
|
+
legacy_file = _legacy_hotkey_path(
|
|
1588
|
+
current_name, old_owner_address, wallet_dir=get_wallet_directory()
|
|
1589
|
+
)
|
|
1590
|
+
if legacy_file.exists():
|
|
1591
|
+
legacy_file.unlink()
|
|
1592
|
+
|
|
1593
|
+
# Save wallet with new properties (will encrypt if final_password is provided, otherwise unencrypted)
|
|
1594
|
+
# Pass owner_coldkey_name to ensure proper hierarchical layout
|
|
1595
|
+
save_hotkey(final_name, keypair, owner_address, final_password, owner_coldkey_name=owner_coldkey_name)
|
|
1596
|
+
|
|
1597
|
+
# Get the actual address from the keypair (not from wallet_info which might be stale)
|
|
1598
|
+
# For ECDSA keys, compute EVM address from public key
|
|
1599
|
+
if keypair.crypto_type == 2: # ECDSA
|
|
1600
|
+
actual_evm_address = public_key_to_evm_address(keypair.public_key)
|
|
1601
|
+
else:
|
|
1602
|
+
actual_evm_address = keypair.ss58_address
|
|
1603
|
+
|
|
1604
|
+
return {
|
|
1605
|
+
"old_name": current_name,
|
|
1606
|
+
"new_name": final_name,
|
|
1607
|
+
"key_type": wallet_info["key_type"],
|
|
1608
|
+
"ss58_address": actual_evm_address, # Use address from keypair, not wallet_info
|
|
1609
|
+
"evm_address": actual_evm_address, # Also include evm_address for consistency
|
|
1610
|
+
"old_owner_address": wallet_info["owner_address"],
|
|
1611
|
+
"new_owner_address": owner_address,
|
|
1612
|
+
"password_updated": new_password is not None or remove_password,
|
|
1613
|
+
"name_updated": bool(new_name and new_name != current_name),
|
|
1614
|
+
"owner_updated": new_owner_name is not None,
|
|
1615
|
+
}
|