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,1069 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wallet management utilities for the Hypertensor CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from substrateinterface import Keypair
|
|
14
|
+
|
|
15
|
+
from src.htcli.ui.display import print_success
|
|
16
|
+
|
|
17
|
+
from ...utils.logging import get_logger
|
|
18
|
+
from .crypto import public_key_to_evm_address
|
|
19
|
+
|
|
20
|
+
# Use centralized logging framework
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WalletType(Enum):
|
|
25
|
+
COLDKEY = 0
|
|
26
|
+
INDEPENDENT_HOTKEY = 1
|
|
27
|
+
OWNED_HOTKEY = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_wallet_directory() -> Path:
|
|
31
|
+
"""Get the wallet directory from config or use default."""
|
|
32
|
+
try:
|
|
33
|
+
from ...dependencies import get_config
|
|
34
|
+
|
|
35
|
+
config = get_config()
|
|
36
|
+
if config and config.wallet and config.wallet.path:
|
|
37
|
+
# Expand ~ and other path variables
|
|
38
|
+
wallet_path = Path(config.wallet.path).expanduser()
|
|
39
|
+
return wallet_path
|
|
40
|
+
except Exception:
|
|
41
|
+
# Fallback to default if config is not available
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Default fallback
|
|
45
|
+
return Path.home() / ".htcli" / "wallets"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def generate_mnemonic() -> str:
|
|
49
|
+
"""
|
|
50
|
+
Generate a new mnemonic phrase.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
str: A new mnemonic phrase
|
|
54
|
+
"""
|
|
55
|
+
return Keypair.generate_mnemonic()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def prompt_for_wallet_selection(
|
|
59
|
+
message: str = "Select wallet",
|
|
60
|
+
wallet_type: str = "hotkey",
|
|
61
|
+
auto_password: bool = True,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Prompt user to select a wallet by name from available wallets.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
message: The prompt message to display
|
|
68
|
+
wallet_type: Type of wallet to show - "hotkey", "coldkey", or "all"
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
str: The selected wallet name
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If no wallets are available
|
|
75
|
+
"""
|
|
76
|
+
from ...dependencies import get_client
|
|
77
|
+
from ...ui.prompts import select_prompt
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
# Get available wallets from the client
|
|
81
|
+
client = get_client()
|
|
82
|
+
response = client.offchain.wallet.list_wallets()
|
|
83
|
+
wallets = response.get("data", [])
|
|
84
|
+
|
|
85
|
+
if not wallets:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"No wallets found. Please create a wallet first using 'htcli wallet create'"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Filter wallets based on type
|
|
91
|
+
if wallet_type == "hotkey":
|
|
92
|
+
filtered_wallets = [w for w in wallets if w.get("is_hotkey", True)]
|
|
93
|
+
elif wallet_type == "coldkey":
|
|
94
|
+
filtered_wallets = [w for w in wallets if not w.get("is_hotkey", False)]
|
|
95
|
+
else: # "all"
|
|
96
|
+
filtered_wallets = wallets
|
|
97
|
+
|
|
98
|
+
if not filtered_wallets:
|
|
99
|
+
wallet_type_display = (
|
|
100
|
+
"hotkey"
|
|
101
|
+
if wallet_type == "hotkey"
|
|
102
|
+
else "coldkey"
|
|
103
|
+
if wallet_type == "coldkey"
|
|
104
|
+
else ""
|
|
105
|
+
)
|
|
106
|
+
from ...ui.display import print_error
|
|
107
|
+
|
|
108
|
+
print_error(
|
|
109
|
+
f"No {wallet_type_display} wallets found. Please create a {wallet_type_display} wallet first using 'htcli wallet create --type {wallet_type}'"
|
|
110
|
+
)
|
|
111
|
+
raise typer.Abort()
|
|
112
|
+
|
|
113
|
+
# Create address to name mapping for owner display
|
|
114
|
+
address_to_name = {}
|
|
115
|
+
for wallet in wallets:
|
|
116
|
+
if not wallet.get("is_hotkey", False): # This is a coldkey
|
|
117
|
+
address_to_name[wallet.get("ss58_address")] = wallet.get("name")
|
|
118
|
+
|
|
119
|
+
# Extract wallet names and create selection choices
|
|
120
|
+
wallet_choices = []
|
|
121
|
+
for wallet in filtered_wallets:
|
|
122
|
+
name = wallet.get("name", "Unknown")
|
|
123
|
+
address = wallet.get("ss58_address", "Unknown")
|
|
124
|
+
|
|
125
|
+
if wallet_type == "hotkey":
|
|
126
|
+
# For hotkeys, show owner information
|
|
127
|
+
owner_address = wallet.get("owner_address", "N/A")
|
|
128
|
+
if owner_address != "N/A" and owner_address in address_to_name:
|
|
129
|
+
owner_display = f"{address_to_name[owner_address]}"
|
|
130
|
+
display_text = f"{name} (Owner: {owner_display})"
|
|
131
|
+
else:
|
|
132
|
+
display_text = (
|
|
133
|
+
f"{name} (Owner: {owner_address[:8]}...{owner_address[-8:]})"
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
# For coldkeys or all, show address
|
|
137
|
+
display_text = f"{name} ({address[:8]}...{address[-8:]})"
|
|
138
|
+
|
|
139
|
+
wallet_choices.append((name, display_text, name))
|
|
140
|
+
|
|
141
|
+
return select_prompt(message, wallet_choices)
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
from ...ui.display import print_error
|
|
145
|
+
|
|
146
|
+
print_error(f"Failed to load wallets: {str(e)}")
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def prompt_for_wallet_and_keypair(
|
|
151
|
+
message: str = "Select wallet", wallet_type: str = "hotkey"
|
|
152
|
+
) -> tuple[str, "Keypair"]:
|
|
153
|
+
"""
|
|
154
|
+
Prompt user to select a wallet and return both name and keypair.
|
|
155
|
+
Handles password prompting automatically for encrypted wallets.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
message: The prompt message to display
|
|
159
|
+
wallet_type: Type of wallet to show - "hotkey", "coldkey", or "all"
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
tuple: (wallet_name, keypair)
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
typer.Abort: If no wallets are available or user cancels
|
|
166
|
+
"""
|
|
167
|
+
from ...dependencies import get_client
|
|
168
|
+
from ...ui.prompts import password_prompt
|
|
169
|
+
|
|
170
|
+
# Get wallet name from selection
|
|
171
|
+
wallet_name = prompt_for_wallet_selection(message, wallet_type, auto_password=False)
|
|
172
|
+
|
|
173
|
+
# Get client and load wallet
|
|
174
|
+
client = get_client()
|
|
175
|
+
response = client.offchain.wallet.list_wallets()
|
|
176
|
+
wallets = response.get("data", [])
|
|
177
|
+
|
|
178
|
+
# Find the selected wallet
|
|
179
|
+
selected_wallet = None
|
|
180
|
+
for wallet in wallets:
|
|
181
|
+
if wallet.get("name") == wallet_name:
|
|
182
|
+
selected_wallet = wallet
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
if not selected_wallet:
|
|
186
|
+
raise ValueError(f"Wallet '{wallet_name}' not found")
|
|
187
|
+
|
|
188
|
+
# Extract wallet metadata for disambiguation
|
|
189
|
+
is_hotkey = selected_wallet.get("is_hotkey", False)
|
|
190
|
+
owner_address = selected_wallet.get("owner_address") if is_hotkey else None
|
|
191
|
+
|
|
192
|
+
# Check if wallet is encrypted and prompt for password if needed
|
|
193
|
+
if selected_wallet.get("is_encrypted", False):
|
|
194
|
+
password = password_prompt(f"Enter password for wallet '{wallet_name}'")
|
|
195
|
+
keypair = client.offchain.wallet.get_keypair(
|
|
196
|
+
wallet_name, password, is_hotkey=is_hotkey, owner_address=owner_address
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
# For unencrypted wallets, get keypair directly
|
|
200
|
+
keypair = client.offchain.wallet.get_keypair(
|
|
201
|
+
wallet_name, None, is_hotkey=is_hotkey, owner_address=owner_address
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return wallet_name, keypair
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def retrieve_wallet_with_validation(
|
|
208
|
+
wallet_type: str = "coldkey",
|
|
209
|
+
purpose: str = "sign the transaction",
|
|
210
|
+
only_existing_wallets: bool = False,
|
|
211
|
+
) -> tuple[str, "Keypair"]:
|
|
212
|
+
"""
|
|
213
|
+
Comprehensive wallet retrieval mechanism that handles:
|
|
214
|
+
- Address vs wallet name selection
|
|
215
|
+
- Wallet type filtering (hotkey/coldkey)
|
|
216
|
+
- Password handling with retry attempts
|
|
217
|
+
- Option to create new wallet if needed
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
wallet_type: Type of wallet to retrieve - "hotkey" or "coldkey"
|
|
221
|
+
purpose: Description of what the wallet will be used for
|
|
222
|
+
only_existing_wallets: If True, skip initial selection and only allow selecting from existing wallets.
|
|
223
|
+
This is useful for owner-only operations that require an existing wallet.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
tuple: (wallet_name_or_address, keypair)
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
typer.Abort: If user cancels or max retries exceeded
|
|
230
|
+
"""
|
|
231
|
+
from ...dependencies import get_client
|
|
232
|
+
from ...ui.display import print_error
|
|
233
|
+
from ...ui.prompts import confirm_prompt, select_prompt, text_prompt
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
client = get_client()
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print_error(f"Failed to initialize client: {str(e)}")
|
|
239
|
+
print_error("Please check your configuration and try again.")
|
|
240
|
+
raise typer.Abort() from e
|
|
241
|
+
|
|
242
|
+
# If only_existing_wallets is True, skip the initial selection and go directly to wallet selection
|
|
243
|
+
if only_existing_wallets:
|
|
244
|
+
return _select_existing_wallet(client, wallet_type, purpose, allow_create_new=False)
|
|
245
|
+
|
|
246
|
+
# Step 1: Ask if user wants to provide address or wallet name
|
|
247
|
+
input_type = select_prompt(
|
|
248
|
+
f"How do you want to provide the {wallet_type} for {purpose}?",
|
|
249
|
+
[
|
|
250
|
+
("address", f"Provide {wallet_type} address directly"),
|
|
251
|
+
("wallet", f"Select from existing {wallet_type} wallets"),
|
|
252
|
+
("create", f"Create new {wallet_type} wallet"),
|
|
253
|
+
],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if input_type == "address":
|
|
257
|
+
# Step 2a: Get address directly
|
|
258
|
+
address = text_prompt(f"Enter {wallet_type} address")
|
|
259
|
+
|
|
260
|
+
# Validate address format
|
|
261
|
+
if wallet_type == "hotkey":
|
|
262
|
+
if not client.offchain.wallet.validate_ethereum_address(address):
|
|
263
|
+
print_error(
|
|
264
|
+
f"Invalid {wallet_type} address format. Expected Bytes20 format (0x + 40 hex characters)"
|
|
265
|
+
)
|
|
266
|
+
raise typer.Abort()
|
|
267
|
+
else: # coldkey
|
|
268
|
+
# For coldkey, we need to find a wallet with this address
|
|
269
|
+
from ...utils.wallet.crypto import list_keys
|
|
270
|
+
|
|
271
|
+
wallets = list_keys()
|
|
272
|
+
|
|
273
|
+
matching_wallet = None
|
|
274
|
+
for wallet in wallets:
|
|
275
|
+
# Check both ss58_address and derived address from public_key
|
|
276
|
+
if not wallet.get("is_hotkey", False):
|
|
277
|
+
# Match by ss58_address (primary)
|
|
278
|
+
if wallet.get("ss58_address") == address:
|
|
279
|
+
matching_wallet = wallet
|
|
280
|
+
break
|
|
281
|
+
# Also match by public_key (keypair derives 0x + public_key as address)
|
|
282
|
+
if wallet.get("public_key"):
|
|
283
|
+
derived_addr = "0x" + wallet.get("public_key")
|
|
284
|
+
if derived_addr.lower() == address.lower():
|
|
285
|
+
matching_wallet = wallet
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
if not matching_wallet:
|
|
289
|
+
print_error(f"No {wallet_type} wallet found with address: {address}")
|
|
290
|
+
if confirm_prompt("Would you like to create a new wallet instead?"):
|
|
291
|
+
return _create_new_wallet(client, wallet_type)
|
|
292
|
+
raise typer.Abort()
|
|
293
|
+
|
|
294
|
+
# Get keypair for the matching wallet
|
|
295
|
+
return _get_keypair_with_password_retry(
|
|
296
|
+
client, matching_wallet["name"], matching_wallet, 3
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# For hotkey addresses, we don't need a keypair (just the address)
|
|
300
|
+
return address, None
|
|
301
|
+
|
|
302
|
+
elif input_type == "wallet":
|
|
303
|
+
# Step 2b: Select from existing wallets
|
|
304
|
+
return _select_existing_wallet(client, wallet_type, purpose, allow_create_new=True)
|
|
305
|
+
|
|
306
|
+
else: # create
|
|
307
|
+
# Step 2c: Create new wallet
|
|
308
|
+
return _create_new_wallet(client, wallet_type)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _select_existing_wallet(
|
|
312
|
+
client, wallet_type: str, purpose: str, allow_create_new: bool = True
|
|
313
|
+
) -> tuple[str, "Keypair"]:
|
|
314
|
+
"""Select from existing wallets of specified type.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
client: Client instance
|
|
318
|
+
wallet_type: Type of wallet - "hotkey" or "coldkey"
|
|
319
|
+
purpose: Description of what the wallet will be used for
|
|
320
|
+
allow_create_new: If False, do not show option to create new wallet
|
|
321
|
+
"""
|
|
322
|
+
from ...ui.display import print_error
|
|
323
|
+
from ...ui.prompts import confirm_prompt, select_prompt
|
|
324
|
+
from ...utils.wallet.crypto import list_keys
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
# Get available wallets with proper structure including is_hotkey
|
|
328
|
+
wallets = list_keys()
|
|
329
|
+
|
|
330
|
+
# Ensure wallets is a list and not None
|
|
331
|
+
if wallets is None:
|
|
332
|
+
print_error("Failed to retrieve wallet information.")
|
|
333
|
+
if allow_create_new and confirm_prompt(f"Would you like to create a new {wallet_type} wallet?"):
|
|
334
|
+
return _create_new_wallet(client, wallet_type)
|
|
335
|
+
raise typer.Abort()
|
|
336
|
+
|
|
337
|
+
# Filter by wallet type
|
|
338
|
+
if wallet_type == "hotkey":
|
|
339
|
+
filtered_wallets = [w for w in wallets if w.get("is_hotkey", False)]
|
|
340
|
+
else: # coldkey
|
|
341
|
+
filtered_wallets = [w for w in wallets if not w.get("is_hotkey", False)]
|
|
342
|
+
|
|
343
|
+
# Check if we have any wallets of the requested type
|
|
344
|
+
if not filtered_wallets:
|
|
345
|
+
print_error(f"No {wallet_type} wallets found.")
|
|
346
|
+
if allow_create_new and confirm_prompt(f"Would you like to create a new {wallet_type} wallet?"):
|
|
347
|
+
return _create_new_wallet(client, wallet_type)
|
|
348
|
+
raise typer.Abort()
|
|
349
|
+
|
|
350
|
+
# Create selection choices
|
|
351
|
+
wallet_choices = []
|
|
352
|
+
|
|
353
|
+
for wallet in filtered_wallets:
|
|
354
|
+
name = wallet.get("name", "Unknown")
|
|
355
|
+
address = wallet.get("ss58_address", "Unknown")
|
|
356
|
+
|
|
357
|
+
if wallet_type == "hotkey":
|
|
358
|
+
# For hotkeys, always show owner information to disambiguate
|
|
359
|
+
owner_address = wallet.get("owner_address")
|
|
360
|
+
if owner_address:
|
|
361
|
+
# Find owner wallet name
|
|
362
|
+
owner_display = None
|
|
363
|
+
for w in wallets:
|
|
364
|
+
if (
|
|
365
|
+
w.get("ss58_address") == owner_address
|
|
366
|
+
or w.get("evm_address", "").lower() == owner_address.lower()
|
|
367
|
+
):
|
|
368
|
+
owner_display = w.get("name", "Unknown")
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
if owner_display:
|
|
372
|
+
display_text = f"{name} (Owner: {owner_display})"
|
|
373
|
+
else:
|
|
374
|
+
display_text = f"{name} (Owner: {owner_address[:8]}...{owner_address[-8:]})"
|
|
375
|
+
else:
|
|
376
|
+
display_text = f"{name} (No owner)"
|
|
377
|
+
else:
|
|
378
|
+
# For coldkeys, show address
|
|
379
|
+
display_text = f"{name} ({address[:8]}...{address[-8:]})"
|
|
380
|
+
|
|
381
|
+
# Create unique key for this wallet (for hotkeys, include owner_address to disambiguate)
|
|
382
|
+
if wallet_type == "hotkey":
|
|
383
|
+
owner_addr = wallet.get("owner_address") or ""
|
|
384
|
+
wallet_key = f"{name}__owner_{owner_addr}"
|
|
385
|
+
else:
|
|
386
|
+
wallet_key = f"{name}__address_{address}"
|
|
387
|
+
|
|
388
|
+
# Store the full wallet dict as the value so we can use it directly
|
|
389
|
+
wallet_choices.append((wallet_key, display_text, wallet))
|
|
390
|
+
|
|
391
|
+
# Add option to create new wallet only if allowed
|
|
392
|
+
if allow_create_new:
|
|
393
|
+
wallet_choices.append(
|
|
394
|
+
("CREATE_NEW", f"Create new {wallet_type} wallet", "CREATE_NEW")
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
prompt_message = f"Select {wallet_type} wallet for {purpose}"
|
|
398
|
+
selected_wallet = select_prompt(prompt_message, wallet_choices)
|
|
399
|
+
|
|
400
|
+
if selected_wallet == "CREATE_NEW":
|
|
401
|
+
return _create_new_wallet(client, wallet_type)
|
|
402
|
+
|
|
403
|
+
# The selected_wallet should be the wallet dict (stored as value in choices tuple)
|
|
404
|
+
if not isinstance(selected_wallet, dict):
|
|
405
|
+
# Fallback: if for some reason we got a string key instead of dict
|
|
406
|
+
# Find the wallet from filtered_wallets by matching the key
|
|
407
|
+
selected_key = str(selected_wallet)
|
|
408
|
+
selected_wallet = None
|
|
409
|
+
for wallet in filtered_wallets:
|
|
410
|
+
name = wallet.get("name", "Unknown")
|
|
411
|
+
if wallet_type == "hotkey":
|
|
412
|
+
owner_addr = wallet.get("owner_address") or ""
|
|
413
|
+
wallet_key = f"{name}__owner_{owner_addr}"
|
|
414
|
+
else:
|
|
415
|
+
address = wallet.get("ss58_address", "Unknown")
|
|
416
|
+
wallet_key = f"{name}__address_{address}"
|
|
417
|
+
|
|
418
|
+
if wallet_key == selected_key:
|
|
419
|
+
selected_wallet = wallet
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
if not selected_wallet or not isinstance(selected_wallet, dict):
|
|
423
|
+
raise ValueError(f"Selected wallet not found or invalid")
|
|
424
|
+
|
|
425
|
+
# Get wallet name from the selected wallet dict
|
|
426
|
+
selected_name = selected_wallet.get("name")
|
|
427
|
+
|
|
428
|
+
# Get keypair with password retry
|
|
429
|
+
# selected_wallet already has is_hotkey and owner_address from the wallet data
|
|
430
|
+
# _get_keypair_with_password_retry will extract these and pass them to get_keypair
|
|
431
|
+
return _get_keypair_with_password_retry(
|
|
432
|
+
client, selected_name, selected_wallet, 3
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
except Exception as e:
|
|
436
|
+
print_error(f"Error retrieving wallets: {str(e)}")
|
|
437
|
+
# Propagate actual error upward so callers can surface context
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _get_keypair_with_password_retry(
|
|
442
|
+
client, wallet_name: str, wallet_info: dict, max_attempts: int = 3
|
|
443
|
+
) -> tuple[str, "Keypair"]:
|
|
444
|
+
"""Get keypair with password retry mechanism."""
|
|
445
|
+
from ...ui.display import print_error
|
|
446
|
+
from ...ui.prompts import password_prompt
|
|
447
|
+
|
|
448
|
+
# Extract wallet type information for disambiguation
|
|
449
|
+
is_hotkey = wallet_info.get("is_hotkey", False)
|
|
450
|
+
owner_address = wallet_info.get("owner_address")
|
|
451
|
+
|
|
452
|
+
# Check if wallet is encrypted
|
|
453
|
+
if not wallet_info.get("is_encrypted", False):
|
|
454
|
+
# Unencrypted wallet - no password needed
|
|
455
|
+
try:
|
|
456
|
+
keypair = client.offchain.wallet.get_keypair(
|
|
457
|
+
wallet_name, None, is_hotkey=is_hotkey, owner_address=owner_address
|
|
458
|
+
)
|
|
459
|
+
return wallet_name, keypair
|
|
460
|
+
except Exception as e:
|
|
461
|
+
raise ValueError(f"Failed to unlock wallet '{wallet_name}': {str(e)}") from e
|
|
462
|
+
|
|
463
|
+
# Encrypted wallet - need password
|
|
464
|
+
for attempt in range(max_attempts):
|
|
465
|
+
try:
|
|
466
|
+
remaining = max_attempts - attempt
|
|
467
|
+
password = password_prompt(
|
|
468
|
+
f"Enter password for wallet '{wallet_name}' (attempt {attempt + 1}/{max_attempts})"
|
|
469
|
+
)
|
|
470
|
+
keypair = client.offchain.wallet.get_keypair(
|
|
471
|
+
wallet_name, password, is_hotkey=is_hotkey, owner_address=owner_address
|
|
472
|
+
)
|
|
473
|
+
print_success(f"Successfully unlocked wallet '{wallet_name}'")
|
|
474
|
+
return wallet_name, keypair
|
|
475
|
+
except ValueError as e:
|
|
476
|
+
if "Invalid password" in str(e):
|
|
477
|
+
if attempt < max_attempts - 1:
|
|
478
|
+
print_error(
|
|
479
|
+
f"Invalid password. {remaining - 1} attempts remaining."
|
|
480
|
+
)
|
|
481
|
+
else:
|
|
482
|
+
print_error("Invalid password. Maximum attempts exceeded.")
|
|
483
|
+
raise
|
|
484
|
+
else:
|
|
485
|
+
# Don't print error here - let the caller handle it
|
|
486
|
+
raise
|
|
487
|
+
except Exception as e:
|
|
488
|
+
error_message = str(e)
|
|
489
|
+
if "Invalid password" in error_message:
|
|
490
|
+
if attempt < max_attempts - 1:
|
|
491
|
+
print_error(
|
|
492
|
+
f"Invalid password. {remaining - 1} attempts remaining."
|
|
493
|
+
)
|
|
494
|
+
continue
|
|
495
|
+
print_error("Invalid password. Maximum attempts exceeded.")
|
|
496
|
+
raise ValueError(
|
|
497
|
+
f"Failed to unlock wallet '{wallet_name}': Invalid password"
|
|
498
|
+
) from e
|
|
499
|
+
raise ValueError(
|
|
500
|
+
f"Failed to unlock wallet '{wallet_name}': {error_message}"
|
|
501
|
+
) from e
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def resolve_coldkey_and_get_keypair(
|
|
505
|
+
coldkey_input: str,
|
|
506
|
+
) -> tuple[str, "Keypair"]:
|
|
507
|
+
"""
|
|
508
|
+
Resolve coldkey input (address or wallet name) and get the keypair.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
coldkey_input: Either a coldkey address (ss58 or 0x format) or wallet name
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
tuple: (wallet_name, keypair)
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
ValueError: If coldkey cannot be resolved or wallet not found
|
|
518
|
+
"""
|
|
519
|
+
from ...dependencies import get_client
|
|
520
|
+
from ...utils.wallet.crypto import get_wallet_info_by_name, list_keys
|
|
521
|
+
|
|
522
|
+
coldkey_input = coldkey_input.strip()
|
|
523
|
+
client = get_client()
|
|
524
|
+
|
|
525
|
+
# First, try to find by wallet name
|
|
526
|
+
try:
|
|
527
|
+
wallet_info = get_wallet_info_by_name(coldkey_input, is_hotkey=False)
|
|
528
|
+
# If found and it's a coldkey (not hotkey)
|
|
529
|
+
if not wallet_info.get("is_hotkey", False):
|
|
530
|
+
# Get keypair for this wallet
|
|
531
|
+
return _get_keypair_with_password_retry(
|
|
532
|
+
client, coldkey_input, wallet_info, 3
|
|
533
|
+
)
|
|
534
|
+
except FileNotFoundError:
|
|
535
|
+
# Not a wallet name, try as address
|
|
536
|
+
pass
|
|
537
|
+
except Exception as e:
|
|
538
|
+
raise ValueError(f"Failed to resolve coldkey '{coldkey_input}': {str(e)}") from e
|
|
539
|
+
|
|
540
|
+
# If not found by name, try to find by address
|
|
541
|
+
wallets = list_keys()
|
|
542
|
+
matching_wallet = None
|
|
543
|
+
|
|
544
|
+
for wallet in wallets:
|
|
545
|
+
if wallet.get("is_hotkey", False):
|
|
546
|
+
continue # Skip hotkeys
|
|
547
|
+
|
|
548
|
+
# Match by ss58_address (primary)
|
|
549
|
+
if wallet.get("ss58_address") and wallet.get("ss58_address") == coldkey_input:
|
|
550
|
+
matching_wallet = wallet
|
|
551
|
+
break
|
|
552
|
+
|
|
553
|
+
# Match by evm_address
|
|
554
|
+
if wallet.get("evm_address") and wallet.get("evm_address").lower() == coldkey_input.lower():
|
|
555
|
+
matching_wallet = wallet
|
|
556
|
+
break
|
|
557
|
+
|
|
558
|
+
# Match by address field
|
|
559
|
+
if wallet.get("address") and wallet.get("address").lower() == coldkey_input.lower():
|
|
560
|
+
matching_wallet = wallet
|
|
561
|
+
break
|
|
562
|
+
|
|
563
|
+
# Also match by public_key (keypair derives 0x + public_key as address)
|
|
564
|
+
if wallet.get("public_key"):
|
|
565
|
+
derived_addr = "0x" + wallet.get("public_key")
|
|
566
|
+
if derived_addr.lower() == coldkey_input.lower():
|
|
567
|
+
matching_wallet = wallet
|
|
568
|
+
break
|
|
569
|
+
|
|
570
|
+
if not matching_wallet:
|
|
571
|
+
raise ValueError(
|
|
572
|
+
f"No coldkey wallet found with address or name: {coldkey_input}"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Get keypair for the matching wallet
|
|
576
|
+
return _get_keypair_with_password_retry(
|
|
577
|
+
client, matching_wallet["name"], matching_wallet, 3
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def resolve_coldkey_address(coldkey_input: str) -> str:
|
|
582
|
+
"""
|
|
583
|
+
Resolve a coldkey wallet input (name or address) to a usable address string.
|
|
584
|
+
|
|
585
|
+
This is a lightweight resolver that does NOT unlock the wallet.
|
|
586
|
+
For ECDSA keys, always returns EVM address format.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
coldkey_input: Wallet name or address.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
Resolved EVM address string (0x... format).
|
|
593
|
+
|
|
594
|
+
Raises:
|
|
595
|
+
ValueError: If the input is empty or cannot be resolved.
|
|
596
|
+
"""
|
|
597
|
+
from ...utils.wallet.crypto import get_wallet_info_by_name, list_keys
|
|
598
|
+
|
|
599
|
+
if coldkey_input is None:
|
|
600
|
+
raise ValueError("Coldkey value cannot be empty")
|
|
601
|
+
|
|
602
|
+
value = coldkey_input.strip()
|
|
603
|
+
if not value:
|
|
604
|
+
raise ValueError("Coldkey value cannot be empty")
|
|
605
|
+
|
|
606
|
+
# Direct address passthrough (EVM format)
|
|
607
|
+
if value.startswith("0x") and len(value) == 42:
|
|
608
|
+
return value.lower()
|
|
609
|
+
|
|
610
|
+
# FIX: Don't accept SS58 addresses - only EVM format
|
|
611
|
+
# if len(value) in (48, 49) and value.startswith("5"):
|
|
612
|
+
# return value
|
|
613
|
+
|
|
614
|
+
# Try resolving by wallet name (coldkey only)
|
|
615
|
+
try:
|
|
616
|
+
wallet_info = get_wallet_info_by_name(value, is_hotkey=False)
|
|
617
|
+
if wallet_info and not wallet_info.get("is_hotkey", False):
|
|
618
|
+
# FIX: Always use evm_address for ECDSA keys
|
|
619
|
+
key_type = wallet_info.get("key_type", "").lower()
|
|
620
|
+
if key_type == "ecdsa":
|
|
621
|
+
resolved = wallet_info.get("evm_address") or wallet_info.get("address")
|
|
622
|
+
else:
|
|
623
|
+
resolved = (
|
|
624
|
+
wallet_info.get("evm_address")
|
|
625
|
+
or wallet_info.get("address")
|
|
626
|
+
or wallet_info.get("ss58_address")
|
|
627
|
+
)
|
|
628
|
+
if resolved:
|
|
629
|
+
return resolved
|
|
630
|
+
except FileNotFoundError:
|
|
631
|
+
pass
|
|
632
|
+
except Exception as e:
|
|
633
|
+
raise ValueError(f"Failed to resolve coldkey '{value}': {str(e)}") from e
|
|
634
|
+
|
|
635
|
+
# Fallback: scan known wallets for matching name or address
|
|
636
|
+
wallets = list_keys()
|
|
637
|
+
for wallet in wallets:
|
|
638
|
+
if wallet.get("is_hotkey", False):
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
# FIX: Match by evm_address first (for ECDSA)
|
|
642
|
+
if wallet.get("evm_address") and wallet.get("evm_address").lower() == value.lower():
|
|
643
|
+
return wallet.get("evm_address")
|
|
644
|
+
|
|
645
|
+
# Match by address field (should be EVM for ECDSA)
|
|
646
|
+
if wallet.get("address") and wallet.get("address").lower() == value.lower():
|
|
647
|
+
return wallet.get("address")
|
|
648
|
+
|
|
649
|
+
# Match by name
|
|
650
|
+
if wallet.get("name") == value:
|
|
651
|
+
key_type = wallet.get("key_type", "").lower()
|
|
652
|
+
if key_type == "ecdsa":
|
|
653
|
+
return wallet.get("evm_address") or wallet.get("address")
|
|
654
|
+
else:
|
|
655
|
+
return (
|
|
656
|
+
wallet.get("evm_address")
|
|
657
|
+
or wallet.get("address")
|
|
658
|
+
or wallet.get("ss58_address")
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
raise ValueError(f"No coldkey wallet found with address or name: {value}")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _create_new_wallet(client, wallet_type: str) -> tuple[str, "Keypair"]:
|
|
665
|
+
"""Create a new wallet of specified type."""
|
|
666
|
+
from ...ui.display import print_error, print_info, print_success
|
|
667
|
+
from ...ui.prompts import confirm_prompt, password_prompt, text_prompt
|
|
668
|
+
|
|
669
|
+
print_info(f"Creating new {wallet_type} wallet...")
|
|
670
|
+
|
|
671
|
+
# Get wallet name
|
|
672
|
+
wallet_name = text_prompt(f"Enter name for new {wallet_type} wallet")
|
|
673
|
+
|
|
674
|
+
# Ask for encryption
|
|
675
|
+
encrypt = confirm_prompt(
|
|
676
|
+
f"Encrypt the {wallet_type} wallet with a password?", default=True
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
password = None
|
|
680
|
+
if encrypt:
|
|
681
|
+
password = password_prompt(
|
|
682
|
+
f"Enter password for new {wallet_type} wallet", confirm=True
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
if wallet_type == "hotkey":
|
|
687
|
+
# For hotkeys, we need an owner (coldkey)
|
|
688
|
+
print_info("Hotkeys must have an owner (coldkey wallet).")
|
|
689
|
+
|
|
690
|
+
# Get available coldkeys
|
|
691
|
+
try:
|
|
692
|
+
wallets_response = client.offchain.wallet.list_wallets()
|
|
693
|
+
all_wallets = wallets_response.get("data", [])
|
|
694
|
+
|
|
695
|
+
# Filter for coldkeys only
|
|
696
|
+
coldkey_wallets = [
|
|
697
|
+
w for w in all_wallets if not w.get("is_hotkey", False)
|
|
698
|
+
]
|
|
699
|
+
|
|
700
|
+
if coldkey_wallets:
|
|
701
|
+
# Create selection choices
|
|
702
|
+
coldkey_choices = []
|
|
703
|
+
for _i, wallet in enumerate(coldkey_wallets):
|
|
704
|
+
name = wallet.get("name", "Unknown")
|
|
705
|
+
address = wallet.get("ss58_address", "Unknown")
|
|
706
|
+
display_text = f"{name} ({address[:8]}...{address[-8:]})"
|
|
707
|
+
coldkey_choices.append((name, display_text, name))
|
|
708
|
+
|
|
709
|
+
# Add option to enter address manually
|
|
710
|
+
coldkey_choices.append(
|
|
711
|
+
("MANUAL", "Enter coldkey address manually", "MANUAL")
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
# Let user select from available coldkeys
|
|
715
|
+
from ...ui.prompts import select_prompt
|
|
716
|
+
|
|
717
|
+
selected_owner = select_prompt(
|
|
718
|
+
"Select owner coldkey wallet", coldkey_choices
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
if selected_owner == "MANUAL":
|
|
722
|
+
# Fallback to manual entry
|
|
723
|
+
owner_input = text_prompt("Enter coldkey wallet address")
|
|
724
|
+
owner_address = owner_input
|
|
725
|
+
print_info(f"Using provided address as owner: {owner_address}")
|
|
726
|
+
else:
|
|
727
|
+
# Find the selected coldkey
|
|
728
|
+
selected_wallet = None
|
|
729
|
+
for wallet in coldkey_wallets:
|
|
730
|
+
if wallet.get("name") == selected_owner:
|
|
731
|
+
selected_wallet = wallet
|
|
732
|
+
break
|
|
733
|
+
|
|
734
|
+
if selected_wallet:
|
|
735
|
+
owner_address = selected_wallet["ss58_address"]
|
|
736
|
+
print_info(
|
|
737
|
+
f"Using coldkey wallet '{selected_owner}' as owner: {owner_address}"
|
|
738
|
+
)
|
|
739
|
+
else:
|
|
740
|
+
raise ValueError(
|
|
741
|
+
f"Selected coldkey '{selected_owner}' not found"
|
|
742
|
+
)
|
|
743
|
+
else:
|
|
744
|
+
# No coldkeys available, ask for manual entry
|
|
745
|
+
print_info(
|
|
746
|
+
"No coldkey wallets found. Please provide a coldkey address manually."
|
|
747
|
+
)
|
|
748
|
+
owner_input = text_prompt("Enter owner coldkey wallet address")
|
|
749
|
+
owner_address = owner_input
|
|
750
|
+
print_info(f"Using provided address as owner: {owner_address}")
|
|
751
|
+
|
|
752
|
+
except Exception as e:
|
|
753
|
+
# Fallback to manual entry if listing fails
|
|
754
|
+
print_info(f"Could not list coldkey wallets: {str(e)}")
|
|
755
|
+
owner_input = text_prompt("Enter owner coldkey wallet address")
|
|
756
|
+
owner_address = owner_input
|
|
757
|
+
print_info(f"Using provided address as owner: {owner_address}")
|
|
758
|
+
|
|
759
|
+
# Create hotkey wallet with owner
|
|
760
|
+
result = client.offchain.wallet.create_hotkey_wallet(
|
|
761
|
+
wallet_name, owner_address, password
|
|
762
|
+
)
|
|
763
|
+
else:
|
|
764
|
+
# Create coldkey wallet
|
|
765
|
+
result = client.offchain.wallet.create_coldkey_wallet(wallet_name, password)
|
|
766
|
+
|
|
767
|
+
if result.get("success"):
|
|
768
|
+
print_success(f"Successfully created {wallet_type} wallet '{wallet_name}'")
|
|
769
|
+
# Get the keypair for the newly created wallet
|
|
770
|
+
# For hotkeys, pass is_hotkey=True and owner_address to help locate the wallet
|
|
771
|
+
if wallet_type == "hotkey":
|
|
772
|
+
keypair = client.offchain.wallet.get_keypair(
|
|
773
|
+
wallet_name, password, is_hotkey=True, owner_address=owner_address
|
|
774
|
+
)
|
|
775
|
+
else:
|
|
776
|
+
keypair = client.offchain.wallet.get_keypair(wallet_name, password)
|
|
777
|
+
return wallet_name, keypair
|
|
778
|
+
else:
|
|
779
|
+
raise Exception(result.get("message", "Failed to create wallet"))
|
|
780
|
+
|
|
781
|
+
except Exception as e:
|
|
782
|
+
print_error(f"Failed to create {wallet_type} wallet: {str(e)}")
|
|
783
|
+
raise
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def create_keypair_from_mnemonic(mnemonic: str) -> Keypair:
|
|
787
|
+
"""
|
|
788
|
+
Create a keypair from a mnemonic phrase.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
mnemonic: The mnemonic phrase to create the keypair from
|
|
792
|
+
|
|
793
|
+
Returns:
|
|
794
|
+
Keypair: The generated keypair
|
|
795
|
+
|
|
796
|
+
Raises:
|
|
797
|
+
RuntimeError: If the mnemonic is invalid
|
|
798
|
+
"""
|
|
799
|
+
try:
|
|
800
|
+
# Match mesh-template pattern - no ss58_format for ECDSA
|
|
801
|
+
keypair = Keypair.create_from_mnemonic(mnemonic, crypto_type=2)
|
|
802
|
+
logger.info("Created keypair from mnemonic:")
|
|
803
|
+
|
|
804
|
+
return keypair
|
|
805
|
+
except Exception as e:
|
|
806
|
+
raise RuntimeError(f"Failed to create keypair from mnemonic: {e}") from e
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def get_account_id(public_key: bytes) -> str:
|
|
810
|
+
"""
|
|
811
|
+
Generate the account ID from a public key.
|
|
812
|
+
This is a 32-byte blake2b hash of the public key.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
public_key: The public key bytes
|
|
816
|
+
|
|
817
|
+
Returns:
|
|
818
|
+
str: The account ID in hex format with 0x prefix
|
|
819
|
+
"""
|
|
820
|
+
# Use blake2b hash function with 32-byte output
|
|
821
|
+
blake2b = hashlib.blake2b(digest_size=32)
|
|
822
|
+
blake2b.update(public_key)
|
|
823
|
+
return "0x" + blake2b.hexdigest()
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def encrypt_data(data: bytes, password: str) -> bytes:
|
|
827
|
+
"""
|
|
828
|
+
Encrypt data using XOR with the password and add a password hash for validation.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
data: The data to encrypt
|
|
832
|
+
password: The password to use for encryption
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
bytes: The encrypted data with password hash
|
|
836
|
+
"""
|
|
837
|
+
# For empty password, return data as is (unencrypted)
|
|
838
|
+
if not password:
|
|
839
|
+
return data
|
|
840
|
+
|
|
841
|
+
# Generate password hash for validation
|
|
842
|
+
password_hash = hashlib.sha256(password.encode()).digest()
|
|
843
|
+
|
|
844
|
+
# Encrypt the data
|
|
845
|
+
key_bytes = password.encode("utf-8")
|
|
846
|
+
encrypted = bytes(data[i] ^ key_bytes[i % len(key_bytes)] for i in range(len(data)))
|
|
847
|
+
|
|
848
|
+
# Combine password hash and encrypted data
|
|
849
|
+
return password_hash + encrypted
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def decrypt_data(encrypted_data: bytes, password: str) -> bytes:
|
|
853
|
+
"""
|
|
854
|
+
Decrypt data that was encrypted with XOR using the password.
|
|
855
|
+
Also validates the password using the stored hash.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
encrypted_data: The encrypted data with password hash
|
|
859
|
+
password: The password used for encryption
|
|
860
|
+
|
|
861
|
+
Returns:
|
|
862
|
+
bytes: The decrypted data
|
|
863
|
+
|
|
864
|
+
Raises:
|
|
865
|
+
RuntimeError: If the password is incorrect
|
|
866
|
+
"""
|
|
867
|
+
if not password:
|
|
868
|
+
return encrypted_data
|
|
869
|
+
|
|
870
|
+
# Extract password hash and encrypted data
|
|
871
|
+
stored_hash = encrypted_data[:32] # First 32 bytes are the hash
|
|
872
|
+
encrypted = encrypted_data[32:] # Rest is the encrypted data
|
|
873
|
+
|
|
874
|
+
# Verify password
|
|
875
|
+
password_hash = hashlib.sha256(password.encode()).digest()
|
|
876
|
+
if password_hash != stored_hash:
|
|
877
|
+
raise RuntimeError("Invalid password")
|
|
878
|
+
|
|
879
|
+
# Decrypt the data
|
|
880
|
+
key_bytes = password.encode("utf-8")
|
|
881
|
+
return bytes(
|
|
882
|
+
encrypted[i] ^ key_bytes[i % len(key_bytes)] for i in range(len(encrypted))
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def create_wallet(
|
|
887
|
+
name: str, # This will be the file name
|
|
888
|
+
wallet_dir: Optional[Path] = None, # Accept the full target directory path
|
|
889
|
+
is_hotkey: bool = False, # True for hotkey, False for coldkey
|
|
890
|
+
password: Optional[str] = None, # Password for encryption
|
|
891
|
+
owner_address: Optional[str] = None, # Required for owned hotkeys
|
|
892
|
+
mnemonic: Optional[str] = None, # Optional mnemonic for regeneration,
|
|
893
|
+
force: bool = False, # Skip confirmation prompt or overwrite existing wallet
|
|
894
|
+
) -> tuple[str, str, str]: # Return main wallet file path, ss58_address, and mnemonic
|
|
895
|
+
"""
|
|
896
|
+
Create a Hypertensor-compatible wallet in the specified directory.
|
|
897
|
+
Generates a new keypair and saves it as a JSON file with encrypted private key.
|
|
898
|
+
|
|
899
|
+
Args:
|
|
900
|
+
name: The desired file name.
|
|
901
|
+
wallet_dir: The full Path object for the directory where the wallet files should be created.
|
|
902
|
+
is_hotkey: True for hotkey, False for coldkey.
|
|
903
|
+
password: Optional password for encryption.
|
|
904
|
+
owner_address: Required for owned hotkeys, specifies the coldkey address that owns this hotkey.
|
|
905
|
+
mnemonic: Optional mnemonic phrase for regeneration. If provided, uses this instead of generating a new one.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
tuple of (main_wallet_file_path, ss58_address, mnemonic)
|
|
909
|
+
"""
|
|
910
|
+
if not wallet_dir:
|
|
911
|
+
wallet_dir = get_wallet_directory()
|
|
912
|
+
|
|
913
|
+
# Create directory if it doesn't exist
|
|
914
|
+
wallet_dir.mkdir(parents=True, exist_ok=True)
|
|
915
|
+
|
|
916
|
+
main_wallet_file_path = wallet_dir / f"{name}.json"
|
|
917
|
+
|
|
918
|
+
# Check if wallet file already exists
|
|
919
|
+
if main_wallet_file_path.exists():
|
|
920
|
+
if not force:
|
|
921
|
+
raise ValueError(
|
|
922
|
+
f"Wallet file '{name}' already exists in {wallet_dir}. Use --force to overwrite existing file or choose another name."
|
|
923
|
+
)
|
|
924
|
+
else:
|
|
925
|
+
# Remove existing file
|
|
926
|
+
logger.info(
|
|
927
|
+
f"Wallet file '{name}' already exists in {wallet_dir}. Overwriting existing file."
|
|
928
|
+
)
|
|
929
|
+
logger.info(f"Removing existing file '{name}'...")
|
|
930
|
+
main_wallet_file_path.unlink()
|
|
931
|
+
logger.info(f"Removed existing file '{name}'.")
|
|
932
|
+
|
|
933
|
+
# Validate owner_address for owned hotkeys
|
|
934
|
+
if is_hotkey and owner_address:
|
|
935
|
+
if not (owner_address.startswith("5") or owner_address.startswith("0x")):
|
|
936
|
+
raise ValueError(
|
|
937
|
+
"Owner address must be a valid SS58 address starting with '5' or EVM address starting with '0x'"
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
# Generate or use provided mnemonic
|
|
941
|
+
if not mnemonic:
|
|
942
|
+
mnemonic = Keypair.generate_mnemonic()
|
|
943
|
+
|
|
944
|
+
# Create keypair from mnemonic
|
|
945
|
+
keypair = create_keypair_from_mnemonic(mnemonic)
|
|
946
|
+
evm_address = public_key_to_evm_address(keypair.public_key)
|
|
947
|
+
|
|
948
|
+
# Prepare wallet data
|
|
949
|
+
wallet_data = {
|
|
950
|
+
"name": name,
|
|
951
|
+
"key_type": "ecdsa", # Default to ecdsa for EVM compatibility
|
|
952
|
+
"public_key": keypair.public_key.hex(),
|
|
953
|
+
"ss58_address": keypair.ss58_address,
|
|
954
|
+
"evm_address": evm_address,
|
|
955
|
+
"is_hotkey": is_hotkey,
|
|
956
|
+
"owner_address": owner_address,
|
|
957
|
+
"is_encrypted": password is not None,
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
# Store private key
|
|
961
|
+
if password:
|
|
962
|
+
encrypted_private_key = encrypt_data(keypair.private_key, password)
|
|
963
|
+
wallet_data["private_key"] = "0x" + encrypted_private_key.hex()
|
|
964
|
+
else:
|
|
965
|
+
wallet_data["private_key"] = "0x" + keypair.private_key.hex()
|
|
966
|
+
|
|
967
|
+
try:
|
|
968
|
+
with open(main_wallet_file_path, "w") as f:
|
|
969
|
+
json.dump(wallet_data, f, indent=2)
|
|
970
|
+
os.chmod(main_wallet_file_path, 0o600) # Secure file permissions
|
|
971
|
+
except Exception as e:
|
|
972
|
+
if main_wallet_file_path.exists():
|
|
973
|
+
os.remove(main_wallet_file_path)
|
|
974
|
+
raise RuntimeError(f"Failed to save wallet file: {e}") from e
|
|
975
|
+
|
|
976
|
+
return str(main_wallet_file_path), evm_address, mnemonic
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def import_wallet(
|
|
980
|
+
name: str, wallet_dir: Optional[Path] = None, password: Optional[str] = None
|
|
981
|
+
) -> Keypair:
|
|
982
|
+
"""
|
|
983
|
+
Import a wallet and return its keypair.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
name (str): Name of the wallet
|
|
987
|
+
wallet_dir (Path): Directory containing the wallet file
|
|
988
|
+
password (str, optional): Password for encrypted wallets
|
|
989
|
+
|
|
990
|
+
Returns:
|
|
991
|
+
Keypair: The wallet's keypair
|
|
992
|
+
|
|
993
|
+
Raises:
|
|
994
|
+
ValueError: If wallet file is not found or invalid
|
|
995
|
+
RuntimeError: If password is incorrect or wallet is corrupted
|
|
996
|
+
"""
|
|
997
|
+
if not wallet_dir:
|
|
998
|
+
wallet_dir = get_wallet_directory()
|
|
999
|
+
|
|
1000
|
+
wallet_path = wallet_dir / f"{name}.json"
|
|
1001
|
+
if not wallet_path.exists():
|
|
1002
|
+
raise ValueError(f"Wallet file not found at {wallet_path}")
|
|
1003
|
+
|
|
1004
|
+
try:
|
|
1005
|
+
with open(wallet_path) as f:
|
|
1006
|
+
wallet_data = json.load(f)
|
|
1007
|
+
except json.JSONDecodeError as j:
|
|
1008
|
+
raise ValueError("Invalid wallet file: not a valid JSON file") from j
|
|
1009
|
+
|
|
1010
|
+
# Get private key
|
|
1011
|
+
private_key_hex = wallet_data.get("private_key", "").replace("0x", "")
|
|
1012
|
+
if not private_key_hex:
|
|
1013
|
+
raise ValueError("Invalid wallet file: missing private key")
|
|
1014
|
+
|
|
1015
|
+
try:
|
|
1016
|
+
private_key_bytes = bytes.fromhex(private_key_hex)
|
|
1017
|
+
except ValueError as v:
|
|
1018
|
+
raise ValueError("Invalid wallet file: private key is not valid hex") from v
|
|
1019
|
+
|
|
1020
|
+
# Check if wallet is encrypted
|
|
1021
|
+
is_encrypted = wallet_data.get("is_encrypted", False)
|
|
1022
|
+
|
|
1023
|
+
# For unencrypted wallets
|
|
1024
|
+
if not is_encrypted:
|
|
1025
|
+
if password:
|
|
1026
|
+
raise RuntimeError("Invalid password: This wallet is not encrypted")
|
|
1027
|
+
try:
|
|
1028
|
+
# Match mesh-template pattern - no ss58_format for ECDSA
|
|
1029
|
+
return Keypair.create_from_private_key(private_key_bytes, crypto_type=2)
|
|
1030
|
+
except Exception as e:
|
|
1031
|
+
raise RuntimeError(f"Failed to create keypair: {str(e)}") from e
|
|
1032
|
+
|
|
1033
|
+
# For encrypted wallets
|
|
1034
|
+
if not password:
|
|
1035
|
+
raise RuntimeError(
|
|
1036
|
+
"Invalid password: Wallet is encrypted but no password provided"
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
try:
|
|
1040
|
+
private_key_bytes = decrypt_data(private_key_bytes, password)
|
|
1041
|
+
if not is_valid_private_key(private_key_bytes):
|
|
1042
|
+
raise RuntimeError("Invalid password: Failed to decrypt private key")
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
raise RuntimeError("Invalid password: Failed to decrypt private key") from e
|
|
1045
|
+
|
|
1046
|
+
try:
|
|
1047
|
+
# Match mesh-template pattern - no ss58_format for ECDSA
|
|
1048
|
+
return Keypair.create_from_private_key(private_key_bytes, crypto_type=2)
|
|
1049
|
+
except Exception as e:
|
|
1050
|
+
raise RuntimeError(f"Failed to create keypair: {str(e)}") from e
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def is_valid_private_key(private_key_bytes: bytes) -> bool:
|
|
1054
|
+
"""
|
|
1055
|
+
Validate if the decrypted private key is valid.
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
private_key_bytes (bytes): The decrypted private key bytes
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
bool: True if the private key is valid, False otherwise
|
|
1062
|
+
"""
|
|
1063
|
+
try:
|
|
1064
|
+
# Try to create a keypair with the private key
|
|
1065
|
+
# Match mesh-template pattern - no ss58_format for ECDSA
|
|
1066
|
+
Keypair.create_from_private_key(private_key_bytes, crypto_type=2)
|
|
1067
|
+
return True
|
|
1068
|
+
except Exception:
|
|
1069
|
+
return False
|