htcli 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. htcli-1.1.0.dist-info/METADATA +509 -0
  2. htcli-1.1.0.dist-info/RECORD +140 -0
  3. htcli-1.1.0.dist-info/WHEEL +4 -0
  4. htcli-1.1.0.dist-info/entry_points.txt +2 -0
  5. htcli-1.1.0.dist-info/licenses/LICENSE +21 -0
  6. src/__init__.py +0 -0
  7. src/htcli/__init__.py +5 -0
  8. src/htcli/client/__init__.py +338 -0
  9. src/htcli/client/extrinsics/__init__.py +26 -0
  10. src/htcli/client/extrinsics/base.py +487 -0
  11. src/htcli/client/extrinsics/consensus.py +79 -0
  12. src/htcli/client/extrinsics/governance.py +714 -0
  13. src/htcli/client/extrinsics/identity.py +490 -0
  14. src/htcli/client/extrinsics/node.py +1054 -0
  15. src/htcli/client/extrinsics/overwatch.py +401 -0
  16. src/htcli/client/extrinsics/staking.py +1504 -0
  17. src/htcli/client/extrinsics/subnet.py +2218 -0
  18. src/htcli/client/extrinsics/validator.py +203 -0
  19. src/htcli/client/extrinsics/wallet.py +323 -0
  20. src/htcli/client/offchain/__init__.py +10 -0
  21. src/htcli/client/offchain/backup.py +385 -0
  22. src/htcli/client/offchain/config.py +541 -0
  23. src/htcli/client/offchain/wallet.py +839 -0
  24. src/htcli/client/rpc/__init__.py +20 -0
  25. src/htcli/client/rpc/chain.py +568 -0
  26. src/htcli/client/rpc/node.py +783 -0
  27. src/htcli/client/rpc/overwatch.py +680 -0
  28. src/htcli/client/rpc/staking.py +216 -0
  29. src/htcli/client/rpc/subnet.py +2104 -0
  30. src/htcli/client/rpc/wallet.py +912 -0
  31. src/htcli/commands/__init__.py +31 -0
  32. src/htcli/commands/chain/__init__.py +66 -0
  33. src/htcli/commands/chain/display.py +204 -0
  34. src/htcli/commands/chain/handlers.py +260 -0
  35. src/htcli/commands/config/__init__.py +158 -0
  36. src/htcli/commands/config/display.py +353 -0
  37. src/htcli/commands/config/handlers.py +347 -0
  38. src/htcli/commands/config/prompts.py +357 -0
  39. src/htcli/commands/consensus/__init__.py +61 -0
  40. src/htcli/commands/consensus/handlers.py +100 -0
  41. src/htcli/commands/governance/__init__.py +49 -0
  42. src/htcli/commands/governance/handlers.py +81 -0
  43. src/htcli/commands/node/__init__.py +304 -0
  44. src/htcli/commands/node/display.py +749 -0
  45. src/htcli/commands/node/error_handling.py +470 -0
  46. src/htcli/commands/node/handlers.py +844 -0
  47. src/htcli/commands/node/prompts.py +346 -0
  48. src/htcli/commands/overwatch/__init__.py +219 -0
  49. src/htcli/commands/overwatch/display.py +396 -0
  50. src/htcli/commands/overwatch/error_handling.py +276 -0
  51. src/htcli/commands/overwatch/handlers.py +443 -0
  52. src/htcli/commands/overwatch/prompts.py +359 -0
  53. src/htcli/commands/stake/__init__.py +736 -0
  54. src/htcli/commands/stake/display.py +1103 -0
  55. src/htcli/commands/stake/error_handling.py +425 -0
  56. src/htcli/commands/stake/handlers.py +1902 -0
  57. src/htcli/commands/stake/prompts.py +1080 -0
  58. src/htcli/commands/subnet/__init__.py +639 -0
  59. src/htcli/commands/subnet/display.py +801 -0
  60. src/htcli/commands/subnet/error_handling.py +524 -0
  61. src/htcli/commands/subnet/handlers.py +2855 -0
  62. src/htcli/commands/subnet/prompts.py +1225 -0
  63. src/htcli/commands/validator/__init__.py +192 -0
  64. src/htcli/commands/validator/display.py +54 -0
  65. src/htcli/commands/validator/handlers.py +340 -0
  66. src/htcli/commands/wallet/__init__.py +546 -0
  67. src/htcli/commands/wallet/display.py +806 -0
  68. src/htcli/commands/wallet/error_handling.py +210 -0
  69. src/htcli/commands/wallet/handlers.py +3040 -0
  70. src/htcli/commands/wallet/prompts.py +1518 -0
  71. src/htcli/config.py +184 -0
  72. src/htcli/dependencies.py +186 -0
  73. src/htcli/errors/__init__.py +63 -0
  74. src/htcli/errors/base.py +141 -0
  75. src/htcli/errors/display.py +20 -0
  76. src/htcli/errors/handlers.py +710 -0
  77. src/htcli/main.py +343 -0
  78. src/htcli/models/__init__.py +21 -0
  79. src/htcli/models/enums/enum_types.py +35 -0
  80. src/htcli/models/errors.py +103 -0
  81. src/htcli/models/requests/__init__.py +197 -0
  82. src/htcli/models/requests/config.py +70 -0
  83. src/htcli/models/requests/consensus.py +19 -0
  84. src/htcli/models/requests/governance.py +38 -0
  85. src/htcli/models/requests/identity.py +51 -0
  86. src/htcli/models/requests/key.py +22 -0
  87. src/htcli/models/requests/node.py +91 -0
  88. src/htcli/models/requests/overwatch.py +64 -0
  89. src/htcli/models/requests/staking.py +580 -0
  90. src/htcli/models/requests/subnet.py +195 -0
  91. src/htcli/models/requests/validator.py +139 -0
  92. src/htcli/models/requests/wallet.py +118 -0
  93. src/htcli/models/responses/__init__.py +147 -0
  94. src/htcli/models/responses/base.py +18 -0
  95. src/htcli/models/responses/chain.py +39 -0
  96. src/htcli/models/responses/config.py +58 -0
  97. src/htcli/models/responses/identity.py +102 -0
  98. src/htcli/models/responses/overwatch.py +51 -0
  99. src/htcli/models/responses/staking.py +502 -0
  100. src/htcli/models/responses/subnet.py +856 -0
  101. src/htcli/models/responses/wallet.py +185 -0
  102. src/htcli/ui/__init__.py +87 -0
  103. src/htcli/ui/colors.py +309 -0
  104. src/htcli/ui/components/__init__.py +60 -0
  105. src/htcli/ui/components/panels.py +174 -0
  106. src/htcli/ui/components/progress.py +166 -0
  107. src/htcli/ui/components/spinners.py +92 -0
  108. src/htcli/ui/components/tables.py +809 -0
  109. src/htcli/ui/components/trees.py +721 -0
  110. src/htcli/ui/display.py +336 -0
  111. src/htcli/ui/prompts.py +870 -0
  112. src/htcli/utils/__init__.py +76 -0
  113. src/htcli/utils/blockchain/__init__.py +75 -0
  114. src/htcli/utils/blockchain/formatting.py +368 -0
  115. src/htcli/utils/blockchain/patches.py +286 -0
  116. src/htcli/utils/blockchain/peer_id.py +186 -0
  117. src/htcli/utils/blockchain/staking.py +448 -0
  118. src/htcli/utils/blockchain/type_registry.py +1373 -0
  119. src/htcli/utils/blockchain/validation.py +179 -0
  120. src/htcli/utils/cache.py +613 -0
  121. src/htcli/utils/constants.py +38 -0
  122. src/htcli/utils/legacy/__init__.py +12 -0
  123. src/htcli/utils/legacy/colors.py +311 -0
  124. src/htcli/utils/legacy/crypto.py +1176 -0
  125. src/htcli/utils/legacy/formatting.py +452 -0
  126. src/htcli/utils/legacy/interactive.py +306 -0
  127. src/htcli/utils/legacy/subnet_manifest.py +265 -0
  128. src/htcli/utils/legacy/validation.py +488 -0
  129. src/htcli/utils/logging.py +183 -0
  130. src/htcli/utils/network/__init__.py +20 -0
  131. src/htcli/utils/network/subnet.py +344 -0
  132. src/htcli/utils/prompts.py +27 -0
  133. src/htcli/utils/scale_codec.py +155 -0
  134. src/htcli/utils/validation/__init__.py +57 -0
  135. src/htcli/utils/validation/prompt_validators.py +267 -0
  136. src/htcli/utils/wallet/__init__.py +65 -0
  137. src/htcli/utils/wallet/auth.py +151 -0
  138. src/htcli/utils/wallet/core.py +1069 -0
  139. src/htcli/utils/wallet/crypto.py +1615 -0
  140. src/htcli/utils/wallet/migration.py +159 -0
@@ -0,0 +1,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
+ }