bittensor-cli 8.4.3__py3-none-any.whl → 9.0.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.
- bittensor_cli/__init__.py +1 -1
- bittensor_cli/cli.py +1827 -1392
- bittensor_cli/src/__init__.py +623 -168
- bittensor_cli/src/bittensor/balances.py +41 -8
- bittensor_cli/src/bittensor/chain_data.py +557 -428
- bittensor_cli/src/bittensor/extrinsics/registration.py +129 -23
- bittensor_cli/src/bittensor/extrinsics/root.py +3 -3
- bittensor_cli/src/bittensor/extrinsics/transfer.py +6 -11
- bittensor_cli/src/bittensor/minigraph.py +46 -8
- bittensor_cli/src/bittensor/subtensor_interface.py +567 -250
- bittensor_cli/src/bittensor/utils.py +399 -25
- bittensor_cli/src/commands/stake/__init__.py +154 -0
- bittensor_cli/src/commands/stake/add.py +625 -0
- bittensor_cli/src/commands/stake/children_hotkeys.py +103 -75
- bittensor_cli/src/commands/stake/list.py +687 -0
- bittensor_cli/src/commands/stake/move.py +1000 -0
- bittensor_cli/src/commands/stake/remove.py +1146 -0
- bittensor_cli/src/commands/subnets/__init__.py +0 -0
- bittensor_cli/src/commands/subnets/price.py +867 -0
- bittensor_cli/src/commands/subnets/subnets.py +2028 -0
- bittensor_cli/src/commands/sudo.py +554 -12
- bittensor_cli/src/commands/wallets.py +225 -531
- bittensor_cli/src/commands/weights.py +2 -2
- {bittensor_cli-8.4.3.dist-info → bittensor_cli-9.0.0.dist-info}/METADATA +7 -4
- bittensor_cli-9.0.0.dist-info/RECORD +34 -0
- bittensor_cli/src/bittensor/async_substrate_interface.py +0 -2748
- bittensor_cli/src/commands/root.py +0 -1752
- bittensor_cli/src/commands/stake/stake.py +0 -1448
- bittensor_cli/src/commands/subnets.py +0 -897
- bittensor_cli-8.4.3.dist-info/RECORD +0 -31
- {bittensor_cli-8.4.3.dist-info → bittensor_cli-9.0.0.dist-info}/WHEEL +0 -0
- {bittensor_cli-8.4.3.dist-info → bittensor_cli-9.0.0.dist-info}/entry_points.txt +0 -0
- {bittensor_cli-8.4.3.dist-info → bittensor_cli-9.0.0.dist-info}/top_level.txt +0 -0
@@ -3,10 +3,14 @@ from collections import namedtuple
|
|
3
3
|
import math
|
4
4
|
import os
|
5
5
|
import sqlite3
|
6
|
+
import platform
|
6
7
|
import webbrowser
|
8
|
+
import sys
|
7
9
|
from pathlib import Path
|
8
10
|
from typing import TYPE_CHECKING, Any, Collection, Optional, Union, Callable
|
9
11
|
from urllib.parse import urlparse
|
12
|
+
from functools import partial
|
13
|
+
import re
|
10
14
|
|
11
15
|
from bittensor_wallet import Wallet, Keypair
|
12
16
|
from bittensor_wallet.utils import SS58_FORMAT
|
@@ -17,20 +21,17 @@ from markupsafe import Markup
|
|
17
21
|
import numpy as np
|
18
22
|
from numpy.typing import NDArray
|
19
23
|
from rich.console import Console
|
20
|
-
import
|
21
|
-
from scalecodec.
|
22
|
-
from scalecodec.type_registry import load_type_registry_preset
|
24
|
+
from rich.prompt import Prompt
|
25
|
+
from scalecodec.utils.ss58 import ss58_encode, ss58_decode
|
23
26
|
import typer
|
24
27
|
|
25
28
|
|
26
29
|
from bittensor_cli.src.bittensor.balances import Balance
|
30
|
+
from bittensor_cli.src import defaults, Constants
|
27
31
|
|
28
32
|
|
29
33
|
if TYPE_CHECKING:
|
30
34
|
from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters
|
31
|
-
from bittensor_cli.src.bittensor.async_substrate_interface import (
|
32
|
-
AsyncSubstrateInterface,
|
33
|
-
)
|
34
35
|
|
35
36
|
console = Console()
|
36
37
|
err_console = Console(stderr=True)
|
@@ -39,6 +40,23 @@ verbose_console = Console(quiet=True)
|
|
39
40
|
UnlockStatus = namedtuple("UnlockStatus", ["success", "message"])
|
40
41
|
|
41
42
|
|
43
|
+
class _Hotkey:
|
44
|
+
def __init__(self, hotkey_ss58=None):
|
45
|
+
self.ss58_address = hotkey_ss58
|
46
|
+
|
47
|
+
|
48
|
+
class WalletLike:
|
49
|
+
def __init__(self, name=None, hotkey_ss58=None, hotkey_str=None):
|
50
|
+
self.name = name
|
51
|
+
self.hotkey_ss58 = hotkey_ss58
|
52
|
+
self.hotkey_str = hotkey_str
|
53
|
+
self._hotkey = _Hotkey(hotkey_ss58)
|
54
|
+
|
55
|
+
@property
|
56
|
+
def hotkey(self):
|
57
|
+
return self._hotkey
|
58
|
+
|
59
|
+
|
42
60
|
def print_console(message: str, colour: str, title: str, console: Console):
|
43
61
|
console.print(
|
44
62
|
f"[bold {colour}][{title}]:[/bold {colour}] [{colour}]{message}[/{colour}]\n"
|
@@ -197,13 +215,14 @@ def convert_root_weight_uids_and_vals_to_tensor(
|
|
197
215
|
|
198
216
|
|
199
217
|
def get_hotkey_wallets_for_wallet(
|
200
|
-
wallet: Wallet, show_nulls: bool = False
|
218
|
+
wallet: Wallet, show_nulls: bool = False, show_encrypted: bool = False
|
201
219
|
) -> list[Optional[Wallet]]:
|
202
220
|
"""
|
203
221
|
Returns wallet objects with hotkeys for a single given wallet
|
204
222
|
|
205
223
|
:param wallet: Wallet object to use for the path
|
206
224
|
:param show_nulls: will add `None` into the output if a hotkey is encrypted or not on the device
|
225
|
+
:param show_encrypted: will add some basic info about the encrypted hotkey
|
207
226
|
|
208
227
|
:return: a list of wallets (with Nones included for cases of a hotkey being encrypted or not on the device, if
|
209
228
|
`show_nulls` is set to `True`)
|
@@ -219,12 +238,18 @@ def get_hotkey_wallets_for_wallet(
|
|
219
238
|
hotkey_for_name = Wallet(path=str(wallet_path), name=wallet.name, hotkey=h_name)
|
220
239
|
try:
|
221
240
|
if (
|
222
|
-
hotkey_for_name.hotkey_file.exists_on_device()
|
241
|
+
(exists := hotkey_for_name.hotkey_file.exists_on_device())
|
223
242
|
and not hotkey_for_name.hotkey_file.is_encrypted()
|
224
243
|
# and hotkey_for_name.coldkeypub.ss58_address
|
225
244
|
and hotkey_for_name.hotkey.ss58_address
|
226
245
|
):
|
227
246
|
hotkey_wallets.append(hotkey_for_name)
|
247
|
+
elif (
|
248
|
+
show_encrypted and exists and hotkey_for_name.hotkey_file.is_encrypted()
|
249
|
+
):
|
250
|
+
hotkey_wallets.append(
|
251
|
+
WalletLike(str(wallet_path), "<ENCRYPTED>", h_name)
|
252
|
+
)
|
228
253
|
elif show_nulls:
|
229
254
|
hotkey_wallets.append(None)
|
230
255
|
except (
|
@@ -376,21 +401,15 @@ def is_valid_bittensor_address_or_public_key(address: Union[str, bytes]) -> bool
|
|
376
401
|
return False
|
377
402
|
|
378
403
|
|
379
|
-
def
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
obj = rpc_runtime_config.create_scale_object(return_type, scale_bytes)
|
385
|
-
if obj.data.to_hex() == "0x0400": # RPC returned None result
|
386
|
-
return None
|
387
|
-
return obj.decode()
|
404
|
+
def decode_account_id(account_id_bytes: Union[tuple[int], tuple[tuple[int]]]):
|
405
|
+
if isinstance(account_id_bytes, tuple) and isinstance(account_id_bytes[0], tuple):
|
406
|
+
account_id_bytes = account_id_bytes[0]
|
407
|
+
# Convert the AccountId bytes to a Base64 string
|
408
|
+
return ss58_encode(bytes(account_id_bytes).hex(), SS58_FORMAT)
|
388
409
|
|
389
410
|
|
390
|
-
def
|
391
|
-
|
392
|
-
account_id_hex: str = scalecodec.ss58_decode(ss58_address, SS58_FORMAT)
|
393
|
-
return bytes.fromhex(account_id_hex)
|
411
|
+
def encode_account_id(ss58_address: str) -> bytes:
|
412
|
+
return bytes.fromhex(ss58_decode(ss58_address, SS58_FORMAT))
|
394
413
|
|
395
414
|
|
396
415
|
def ss58_to_vec_u8(ss58_address: str) -> list[int]:
|
@@ -401,7 +420,7 @@ def ss58_to_vec_u8(ss58_address: str) -> list[int]:
|
|
401
420
|
|
402
421
|
:return: A list of integers representing the byte values of the SS58 address.
|
403
422
|
"""
|
404
|
-
ss58_bytes: bytes =
|
423
|
+
ss58_bytes: bytes = encode_account_id(ss58_address)
|
405
424
|
encoded_address: list[int] = [int(byte) for byte in ss58_bytes]
|
406
425
|
return encoded_address
|
407
426
|
|
@@ -507,7 +526,10 @@ def format_error_message(error_message: Union[dict, Exception]) -> str:
|
|
507
526
|
|
508
527
|
# subtensor custom error marker
|
509
528
|
if err_data.startswith("Custom error:"):
|
510
|
-
err_description =
|
529
|
+
err_description = (
|
530
|
+
f"{err_data} | Please consult "
|
531
|
+
f"https://docs.bittensor.com/subtensor-nodes/subtensor-error-messages"
|
532
|
+
)
|
511
533
|
else:
|
512
534
|
err_description = err_data
|
513
535
|
|
@@ -542,7 +564,8 @@ def decode_hex_identity_dict(info_dictionary) -> dict[str, Any]:
|
|
542
564
|
"""
|
543
565
|
Decodes hex-encoded strings in a dictionary.
|
544
566
|
|
545
|
-
This function traverses the given dictionary, identifies hex-encoded strings, and decodes them into readable
|
567
|
+
This function traverses the given dictionary, identifies hex-encoded strings, and decodes them into readable
|
568
|
+
strings. It handles nested dictionaries and lists within the dictionary.
|
546
569
|
|
547
570
|
Args:
|
548
571
|
info_dictionary (dict): The dictionary containing hex-encoded strings to decode.
|
@@ -630,6 +653,32 @@ def millify(n: int):
|
|
630
653
|
return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx])
|
631
654
|
|
632
655
|
|
656
|
+
def millify_tao(n: float, start_at: str = "K") -> str:
|
657
|
+
"""
|
658
|
+
Dupe of millify, but for ease in converting tao values.
|
659
|
+
Allows thresholds to be specified for different suffixes.
|
660
|
+
"""
|
661
|
+
mill_names = ["", "k", "m", "b", "t"]
|
662
|
+
thresholds = {"K": 1, "M": 2, "B": 3, "T": 4}
|
663
|
+
|
664
|
+
if start_at not in thresholds:
|
665
|
+
raise ValueError(f"start_at must be one of {list(thresholds.keys())}")
|
666
|
+
|
667
|
+
n_ = float(n)
|
668
|
+
if n_ == 0:
|
669
|
+
return "0.00"
|
670
|
+
|
671
|
+
mill_idx = int(math.floor(math.log10(abs(n_)) / 3))
|
672
|
+
|
673
|
+
# Number's index is below our threshold, return with commas
|
674
|
+
if mill_idx < thresholds[start_at]:
|
675
|
+
return f"{n_:,.2f}"
|
676
|
+
|
677
|
+
mill_idx = max(thresholds[start_at], min(len(mill_names) - 1, mill_idx))
|
678
|
+
|
679
|
+
return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx])
|
680
|
+
|
681
|
+
|
633
682
|
def normalize_hyperparameters(
|
634
683
|
subnet: "SubnetHyperparameters",
|
635
684
|
) -> list[tuple[str, str, str]]:
|
@@ -942,7 +991,7 @@ def retry_prompt(
|
|
942
991
|
rejection_text: str,
|
943
992
|
default="",
|
944
993
|
show_default=False,
|
945
|
-
prompt_type=
|
994
|
+
prompt_type=Prompt.ask,
|
946
995
|
):
|
947
996
|
"""
|
948
997
|
Allows for asking prompts again if they do not meet a certain criteria (as defined in `rejection`)
|
@@ -965,6 +1014,302 @@ def retry_prompt(
|
|
965
1014
|
err_console.print(rejection_text)
|
966
1015
|
|
967
1016
|
|
1017
|
+
def validate_netuid(value: int) -> int:
|
1018
|
+
if value is not None and value < 0:
|
1019
|
+
raise typer.BadParameter("Negative netuid passed. Please use correct netuid.")
|
1020
|
+
return value
|
1021
|
+
|
1022
|
+
|
1023
|
+
def validate_uri(uri: str) -> str:
|
1024
|
+
if not uri:
|
1025
|
+
return None
|
1026
|
+
clean_uri = uri.lstrip("/").lower()
|
1027
|
+
if not clean_uri.isalnum():
|
1028
|
+
raise typer.BadParameter(
|
1029
|
+
f"Invalid URI format: {uri}. URI must contain only alphanumeric characters (e.g. 'alice', 'bob')"
|
1030
|
+
)
|
1031
|
+
return f"//{clean_uri.capitalize()}"
|
1032
|
+
|
1033
|
+
|
1034
|
+
def get_effective_network(config, network: Optional[list[str]]) -> str:
|
1035
|
+
"""
|
1036
|
+
Determines the effective network to be used, considering the network parameter,
|
1037
|
+
the configuration, and the default.
|
1038
|
+
"""
|
1039
|
+
if network:
|
1040
|
+
for item in network:
|
1041
|
+
if item.startswith("ws"):
|
1042
|
+
network_ = item
|
1043
|
+
break
|
1044
|
+
else:
|
1045
|
+
network_ = item
|
1046
|
+
return network_
|
1047
|
+
elif config.get("network"):
|
1048
|
+
return config["network"]
|
1049
|
+
else:
|
1050
|
+
return defaults.subtensor.network
|
1051
|
+
|
1052
|
+
|
1053
|
+
def is_rao_network(network: str) -> bool:
|
1054
|
+
"""Check if the given network is 'rao'."""
|
1055
|
+
network = network.lower()
|
1056
|
+
rao_identifiers = [
|
1057
|
+
"rao",
|
1058
|
+
Constants.rao_entrypoint,
|
1059
|
+
]
|
1060
|
+
return (
|
1061
|
+
network == "rao"
|
1062
|
+
or network in rao_identifiers
|
1063
|
+
or "rao.chain.opentensor.ai" in network
|
1064
|
+
)
|
1065
|
+
|
1066
|
+
|
1067
|
+
def prompt_for_identity(
|
1068
|
+
current_identity: dict,
|
1069
|
+
name: Optional[str],
|
1070
|
+
web_url: Optional[str],
|
1071
|
+
image_url: Optional[str],
|
1072
|
+
discord: Optional[str],
|
1073
|
+
description: Optional[str],
|
1074
|
+
additional: Optional[str],
|
1075
|
+
github_repo: Optional[str],
|
1076
|
+
):
|
1077
|
+
"""
|
1078
|
+
Prompts the user for identity fields with validation.
|
1079
|
+
Returns a dictionary with the updated fields.
|
1080
|
+
"""
|
1081
|
+
identity_fields = {}
|
1082
|
+
|
1083
|
+
fields = [
|
1084
|
+
("name", "[blue]Display name[/blue]", name),
|
1085
|
+
("url", "[blue]Web URL[/blue]", web_url),
|
1086
|
+
("image", "[blue]Image URL[/blue]", image_url),
|
1087
|
+
("discord", "[blue]Discord handle[/blue]", discord),
|
1088
|
+
("description", "[blue]Description[/blue]", description),
|
1089
|
+
("additional", "[blue]Additional information[/blue]", additional),
|
1090
|
+
("github_repo", "[blue]GitHub repository URL[/blue]", github_repo),
|
1091
|
+
]
|
1092
|
+
|
1093
|
+
text_rejection = partial(
|
1094
|
+
retry_prompt,
|
1095
|
+
rejection=lambda x: sys.getsizeof(x) > 113,
|
1096
|
+
rejection_text="[red]Error:[/red] Identity field must be <= 64 raw bytes.",
|
1097
|
+
)
|
1098
|
+
|
1099
|
+
if not any(
|
1100
|
+
[
|
1101
|
+
name,
|
1102
|
+
web_url,
|
1103
|
+
image_url,
|
1104
|
+
discord,
|
1105
|
+
description,
|
1106
|
+
additional,
|
1107
|
+
github_repo,
|
1108
|
+
]
|
1109
|
+
):
|
1110
|
+
console.print(
|
1111
|
+
"\n[yellow]All fields are optional. Press Enter to skip and keep the default/existing value.[/yellow]\n"
|
1112
|
+
"[dark_sea_green3]Tip: Entering a space and pressing Enter will clear existing default value.\n"
|
1113
|
+
)
|
1114
|
+
|
1115
|
+
for key, prompt, value in fields:
|
1116
|
+
if value:
|
1117
|
+
identity_fields[key] = value
|
1118
|
+
else:
|
1119
|
+
identity_fields[key] = text_rejection(
|
1120
|
+
prompt,
|
1121
|
+
default=current_identity.get(key, ""),
|
1122
|
+
show_default=True,
|
1123
|
+
)
|
1124
|
+
|
1125
|
+
return identity_fields
|
1126
|
+
|
1127
|
+
|
1128
|
+
def prompt_for_subnet_identity(
|
1129
|
+
subnet_name: Optional[str],
|
1130
|
+
github_repo: Optional[str],
|
1131
|
+
subnet_contact: Optional[str],
|
1132
|
+
subnet_url: Optional[str],
|
1133
|
+
discord: Optional[str],
|
1134
|
+
description: Optional[str],
|
1135
|
+
additional: Optional[str],
|
1136
|
+
):
|
1137
|
+
"""
|
1138
|
+
Prompts the user for required subnet identity fields with validation.
|
1139
|
+
Returns a dictionary with the updated fields.
|
1140
|
+
|
1141
|
+
Args:
|
1142
|
+
subnet_name (Optional[str]): Name of the subnet
|
1143
|
+
github_repo (Optional[str]): GitHub repository URL
|
1144
|
+
subnet_contact (Optional[str]): Contact information for subnet (email)
|
1145
|
+
|
1146
|
+
Returns:
|
1147
|
+
dict: Dictionary containing the subnet identity fields
|
1148
|
+
"""
|
1149
|
+
identity_fields = {}
|
1150
|
+
|
1151
|
+
fields = [
|
1152
|
+
(
|
1153
|
+
"subnet_name",
|
1154
|
+
"[blue]Subnet name [dim](optional)[/blue]",
|
1155
|
+
subnet_name,
|
1156
|
+
lambda x: x and sys.getsizeof(x) > 113,
|
1157
|
+
"[red]Error:[/red] Subnet name must be <= 64 raw bytes.",
|
1158
|
+
),
|
1159
|
+
(
|
1160
|
+
"github_repo",
|
1161
|
+
"[blue]GitHub repository URL [dim](optional)[/blue]",
|
1162
|
+
github_repo,
|
1163
|
+
lambda x: x and not is_valid_github_url(x),
|
1164
|
+
"[red]Error:[/red] Please enter a valid GitHub repository URL (e.g., https://github.com/username/repo).",
|
1165
|
+
),
|
1166
|
+
(
|
1167
|
+
"subnet_contact",
|
1168
|
+
"[blue]Contact email [dim](optional)[/blue]",
|
1169
|
+
subnet_contact,
|
1170
|
+
lambda x: x and not is_valid_contact(x),
|
1171
|
+
"[red]Error:[/red] Please enter a valid email address.",
|
1172
|
+
),
|
1173
|
+
(
|
1174
|
+
"subnet_url",
|
1175
|
+
"[blue]Subnet URL [dim](optional)[/blue]",
|
1176
|
+
subnet_url,
|
1177
|
+
lambda x: x and sys.getsizeof(x) > 113,
|
1178
|
+
"[red]Error:[/red] Please enter a valid URL.",
|
1179
|
+
),
|
1180
|
+
(
|
1181
|
+
"discord",
|
1182
|
+
"[blue]Discord handle [dim](optional)[/blue]",
|
1183
|
+
discord,
|
1184
|
+
lambda x: x and sys.getsizeof(x) > 113,
|
1185
|
+
"[red]Error:[/red] Please enter a valid Discord handle.",
|
1186
|
+
),
|
1187
|
+
(
|
1188
|
+
"description",
|
1189
|
+
"[blue]Description [dim](optional)[/blue]",
|
1190
|
+
description,
|
1191
|
+
lambda x: x and sys.getsizeof(x) > 113,
|
1192
|
+
"[red]Error:[/red] Description must be <= 64 raw bytes.",
|
1193
|
+
),
|
1194
|
+
(
|
1195
|
+
"additional",
|
1196
|
+
"[blue]Additional information [dim](optional)[/blue]",
|
1197
|
+
additional,
|
1198
|
+
lambda x: x and sys.getsizeof(x) > 113,
|
1199
|
+
"[red]Error:[/red] Additional information must be <= 64 raw bytes.",
|
1200
|
+
),
|
1201
|
+
]
|
1202
|
+
|
1203
|
+
for key, prompt, value, rejection_func, rejection_msg in fields:
|
1204
|
+
if value:
|
1205
|
+
if rejection_func(value):
|
1206
|
+
raise ValueError(rejection_msg)
|
1207
|
+
identity_fields[key] = value
|
1208
|
+
else:
|
1209
|
+
identity_fields[key] = retry_prompt(
|
1210
|
+
prompt,
|
1211
|
+
rejection=rejection_func,
|
1212
|
+
rejection_text=rejection_msg,
|
1213
|
+
default=None, # Maybe we can add some defaults later
|
1214
|
+
show_default=True,
|
1215
|
+
)
|
1216
|
+
|
1217
|
+
return identity_fields
|
1218
|
+
|
1219
|
+
|
1220
|
+
def is_valid_github_url(url: str) -> bool:
|
1221
|
+
"""
|
1222
|
+
Validates if the provided URL is a valid GitHub repository URL.
|
1223
|
+
|
1224
|
+
Args:
|
1225
|
+
url (str): URL to validate
|
1226
|
+
|
1227
|
+
Returns:
|
1228
|
+
bool: True if valid GitHub repo URL, False otherwise
|
1229
|
+
"""
|
1230
|
+
try:
|
1231
|
+
parsed = urlparse(url)
|
1232
|
+
if parsed.netloc != "github.com":
|
1233
|
+
return False
|
1234
|
+
|
1235
|
+
# Check path follows github.com/user/repo format
|
1236
|
+
path_parts = [p for p in parsed.path.split("/") if p]
|
1237
|
+
if len(path_parts) < 2: # Need at least username/repo
|
1238
|
+
return False
|
1239
|
+
|
1240
|
+
return True
|
1241
|
+
except Exception: # TODO figure out the exceptions that can be raised in here
|
1242
|
+
return False
|
1243
|
+
|
1244
|
+
|
1245
|
+
def is_valid_contact(contact: str) -> bool:
|
1246
|
+
"""
|
1247
|
+
Validates if the provided contact is a valid email address.
|
1248
|
+
|
1249
|
+
Args:
|
1250
|
+
contact (str): Contact information to validate
|
1251
|
+
|
1252
|
+
Returns:
|
1253
|
+
bool: True if valid email, False otherwise
|
1254
|
+
"""
|
1255
|
+
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
1256
|
+
return bool(re.match(email_pattern, contact))
|
1257
|
+
|
1258
|
+
|
1259
|
+
def get_subnet_name(subnet_info) -> str:
|
1260
|
+
"""Get the subnet name, prioritizing subnet_identity.subnet_name over subnet.subnet_name.
|
1261
|
+
|
1262
|
+
Args:
|
1263
|
+
subnet: The subnet dynamic info
|
1264
|
+
|
1265
|
+
Returns:
|
1266
|
+
str: The subnet name or empty string if no name is found
|
1267
|
+
"""
|
1268
|
+
return (
|
1269
|
+
subnet_info.subnet_identity.subnet_name
|
1270
|
+
if hasattr(subnet_info, "subnet_identity")
|
1271
|
+
and subnet_info.subnet_identity is not None
|
1272
|
+
and subnet_info.subnet_identity.subnet_name is not None
|
1273
|
+
else (subnet_info.subnet_name if subnet_info.subnet_name is not None else "")
|
1274
|
+
)
|
1275
|
+
|
1276
|
+
|
1277
|
+
def print_linux_dependency_message():
|
1278
|
+
"""Prints the WebKit dependency message for Linux systems."""
|
1279
|
+
console.print("[red]This command requires WebKit dependencies on Linux.[/red]")
|
1280
|
+
console.print(
|
1281
|
+
"\nPlease install the required packages using one of the following commands based on your distribution:"
|
1282
|
+
)
|
1283
|
+
console.print("\nArch Linux / Manjaro:")
|
1284
|
+
console.print("[green]sudo pacman -S webkit2gtk[/green]")
|
1285
|
+
console.print("\nDebian / Ubuntu:")
|
1286
|
+
console.print("[green]sudo apt install libwebkit2gtk-4.0-dev[/green]")
|
1287
|
+
console.print("\nFedora / CentOS / AlmaLinux:")
|
1288
|
+
console.print("[green]sudo dnf install gtk3-devel webkit2gtk3-devel[/green]")
|
1289
|
+
|
1290
|
+
|
1291
|
+
def is_linux():
|
1292
|
+
"""Returns True if the operating system is Linux."""
|
1293
|
+
return platform.system().lower() == "linux"
|
1294
|
+
|
1295
|
+
|
1296
|
+
def validate_rate_tolerance(value: Optional[float]) -> Optional[float]:
|
1297
|
+
"""Validates rate tolerance input"""
|
1298
|
+
if value is not None:
|
1299
|
+
if value < 0:
|
1300
|
+
raise typer.BadParameter(
|
1301
|
+
"Rate tolerance cannot be negative (less than 0%)."
|
1302
|
+
)
|
1303
|
+
if value > 1:
|
1304
|
+
raise typer.BadParameter("Rate tolerance cannot be greater than 1 (100%).")
|
1305
|
+
if value > 0.5:
|
1306
|
+
console.print(
|
1307
|
+
f"[yellow]Warning: High rate tolerance of {value*100}% specified. "
|
1308
|
+
"This may result in unfavorable transaction execution.[/yellow]"
|
1309
|
+
)
|
1310
|
+
return value
|
1311
|
+
|
1312
|
+
|
968
1313
|
def unlock_key(
|
969
1314
|
wallet: Wallet, unlock_type="cold", print_out: bool = True
|
970
1315
|
) -> "UnlockStatus":
|
@@ -1010,3 +1355,32 @@ def hex_to_bytes(hex_str: str) -> bytes:
|
|
1010
1355
|
else:
|
1011
1356
|
bytes_result = bytes.fromhex(hex_str)
|
1012
1357
|
return bytes_result
|
1358
|
+
|
1359
|
+
|
1360
|
+
def blocks_to_duration(blocks: int) -> str:
|
1361
|
+
"""Convert blocks to human readable duration string using two largest units.
|
1362
|
+
|
1363
|
+
Args:
|
1364
|
+
blocks (int): Number of blocks (12s per block)
|
1365
|
+
|
1366
|
+
Returns:
|
1367
|
+
str: Duration string like '2d 5h', '3h 45m', '2m 10s', or '0s'
|
1368
|
+
"""
|
1369
|
+
if blocks <= 0:
|
1370
|
+
return "0s"
|
1371
|
+
|
1372
|
+
seconds = blocks * 12
|
1373
|
+
intervals = [
|
1374
|
+
("d", 86400), # 60 * 60 * 24
|
1375
|
+
("h", 3600), # 60 * 60
|
1376
|
+
("m", 60),
|
1377
|
+
("s", 1),
|
1378
|
+
]
|
1379
|
+
results = []
|
1380
|
+
for unit, seconds_per_unit in intervals:
|
1381
|
+
unit_count = seconds // seconds_per_unit
|
1382
|
+
seconds %= seconds_per_unit
|
1383
|
+
if unit_count > 0:
|
1384
|
+
results.append(f"{unit_count}{unit}")
|
1385
|
+
# Return only the first two non-zero units
|
1386
|
+
return " ".join(results[:2]) or "0s"
|
@@ -0,0 +1,154 @@
|
|
1
|
+
from typing import Optional, TYPE_CHECKING
|
2
|
+
|
3
|
+
import rich.prompt
|
4
|
+
from rich.table import Table
|
5
|
+
|
6
|
+
from bittensor_cli.src.bittensor.chain_data import DelegateInfoLite
|
7
|
+
from bittensor_cli.src.bittensor.utils import console
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface
|
11
|
+
|
12
|
+
|
13
|
+
async def select_delegate(subtensor: "SubtensorInterface", netuid: int):
|
14
|
+
# Get a list of delegates and sort them by total stake in descending order
|
15
|
+
delegates: list[DelegateInfoLite] = (
|
16
|
+
await subtensor.get_delegates_by_netuid_light(netuid)
|
17
|
+
).sort(key=lambda x: x.total_stake, reverse=True)
|
18
|
+
|
19
|
+
# Get registered delegates details.
|
20
|
+
registered_delegate_info = await subtensor.get_delegate_identities()
|
21
|
+
|
22
|
+
# Create a table to display delegate information
|
23
|
+
table = Table(
|
24
|
+
show_header=True,
|
25
|
+
header_style="bold",
|
26
|
+
border_style="rgb(7,54,66)",
|
27
|
+
style="rgb(0,43,54)",
|
28
|
+
)
|
29
|
+
|
30
|
+
# Add columns to the table with specific styles
|
31
|
+
table.add_column("Index", style="rgb(253,246,227)", no_wrap=True)
|
32
|
+
table.add_column("Delegate Name", no_wrap=True)
|
33
|
+
table.add_column("Hotkey SS58", style="rgb(211,54,130)", no_wrap=True)
|
34
|
+
table.add_column("Owner SS58", style="rgb(133,153,0)", no_wrap=True)
|
35
|
+
table.add_column("Take", style="rgb(181,137,0)", no_wrap=True)
|
36
|
+
table.add_column(
|
37
|
+
"Total Stake", style="rgb(38,139,210)", no_wrap=True, justify="right"
|
38
|
+
)
|
39
|
+
table.add_column(
|
40
|
+
"Owner Stake", style="rgb(220,50,47)", no_wrap=True, justify="right"
|
41
|
+
)
|
42
|
+
# table.add_column("Return per 1000", style="rgb(108,113,196)", no_wrap=True, justify="right")
|
43
|
+
# table.add_column("Total Daily Return", style="rgb(42,161,152)", no_wrap=True, justify="right")
|
44
|
+
|
45
|
+
# List to store visible delegates
|
46
|
+
visible_delegates = []
|
47
|
+
|
48
|
+
def get_user_input() -> str:
|
49
|
+
return rich.prompt.Prompt.ask(
|
50
|
+
'Press Enter to scroll, enter a number (1-N) to select, or type "h" for help: ',
|
51
|
+
choices=["", "h"] + [str(x) for x in range(1, len(delegates) - 1)],
|
52
|
+
show_choices=True,
|
53
|
+
)
|
54
|
+
|
55
|
+
# TODO: Add pagination to handle large number of delegates more efficiently
|
56
|
+
# Iterate through delegates and display their information
|
57
|
+
|
58
|
+
def loop_selections() -> Optional[int]:
|
59
|
+
idx = 0
|
60
|
+
selected_idx = None
|
61
|
+
while idx < len(delegates):
|
62
|
+
if idx < len(delegates):
|
63
|
+
delegate = delegates[idx]
|
64
|
+
|
65
|
+
# Add delegate to visible list
|
66
|
+
visible_delegates.append(delegate)
|
67
|
+
|
68
|
+
# Add a row to the table with delegate information
|
69
|
+
table.add_row(
|
70
|
+
str(idx),
|
71
|
+
registered_delegate_info[delegate.hotkey_ss58].name
|
72
|
+
if delegate.hotkey_ss58 in registered_delegate_info
|
73
|
+
else "",
|
74
|
+
delegate.hotkey_ss58[:5]
|
75
|
+
+ "..."
|
76
|
+
+ delegate.hotkey_ss58[-5:], # Show truncated hotkey
|
77
|
+
delegate.owner_ss58[:5]
|
78
|
+
+ "..."
|
79
|
+
+ delegate.owner_ss58[-5:], # Show truncated owner address
|
80
|
+
f"{delegate.take:.6f}",
|
81
|
+
f"τ{delegate.total_stake.tao:,.4f}",
|
82
|
+
f"τ{delegate.owner_stake.tao:,.4f}",
|
83
|
+
# f"τ{delegate.return_per_1000.tao:,.4f}",
|
84
|
+
# f"τ{delegate.total_daily_return.tao:,.4f}",
|
85
|
+
)
|
86
|
+
|
87
|
+
# Clear console and print updated table
|
88
|
+
console.clear()
|
89
|
+
console.print(table)
|
90
|
+
|
91
|
+
# Prompt user for input
|
92
|
+
user_input: str = get_user_input()
|
93
|
+
|
94
|
+
# Add a help option to display information about each column
|
95
|
+
if user_input == "h":
|
96
|
+
console.print("\nColumn Information:")
|
97
|
+
console.print(
|
98
|
+
"[rgb(253,246,227)]Index:[/rgb(253,246,227)] Position in the list of delegates"
|
99
|
+
)
|
100
|
+
console.print(
|
101
|
+
"[rgb(211,54,130)]Hotkey SS58:[/rgb(211,54,130)] Truncated public key of the delegate's hotkey"
|
102
|
+
)
|
103
|
+
console.print(
|
104
|
+
"[rgb(133,153,0)]Owner SS58:[/rgb(133,153,0)] Truncated public key of the delegate's owner"
|
105
|
+
)
|
106
|
+
console.print(
|
107
|
+
"[rgb(181,137,0)]Take:[/rgb(181,137,0)] Percentage of rewards the delegate takes"
|
108
|
+
)
|
109
|
+
console.print(
|
110
|
+
"[rgb(38,139,210)]Total Stake:[/rgb(38,139,210)] Total amount staked to this delegate"
|
111
|
+
)
|
112
|
+
console.print(
|
113
|
+
"[rgb(220,50,47)]Owner Stake:[/rgb(220,50,47)] Amount staked by the delegate owner"
|
114
|
+
)
|
115
|
+
console.print(
|
116
|
+
"[rgb(108,113,196)]Return per 1000:[/rgb(108,113,196)] Estimated return for 1000 Tao staked"
|
117
|
+
)
|
118
|
+
console.print(
|
119
|
+
"[rgb(42,161,152)]Total Daily Return:[/rgb(42,161,152)] Estimated total daily return for all stake"
|
120
|
+
)
|
121
|
+
user_input = get_user_input()
|
122
|
+
|
123
|
+
# If user presses Enter, continue to next delegate
|
124
|
+
if user_input and user_input != "h":
|
125
|
+
selected_idx = int(user_input)
|
126
|
+
break
|
127
|
+
|
128
|
+
if idx < len(delegates):
|
129
|
+
idx += 1
|
130
|
+
|
131
|
+
return selected_idx
|
132
|
+
|
133
|
+
# TODO( const ): uncomment for check
|
134
|
+
# Add a confirmation step before returning the selected delegate
|
135
|
+
# console.print(f"\nSelected delegate: [rgb(211,54,130)]{visible_delegates[selected_idx].hotkey_ss58}[/rgb(211,54,130)]")
|
136
|
+
# console.print(f"Take: [rgb(181,137,0)]{visible_delegates[selected_idx].take:.6f}[/rgb(181,137,0)]")
|
137
|
+
# console.print(f"Total Stake: [rgb(38,139,210)]{visible_delegates[selected_idx].total_stake}[/rgb(38,139,210)]")
|
138
|
+
|
139
|
+
# confirmation = Prompt.ask("Do you want to proceed with this delegate? (y/n)")
|
140
|
+
# if confirmation.lower() != 'yes' and confirmation.lower() != 'y':
|
141
|
+
# return select_delegate( subtensor, netuid )
|
142
|
+
|
143
|
+
# Return the selected delegate
|
144
|
+
while True:
|
145
|
+
selected_idx_ = loop_selections()
|
146
|
+
if selected_idx_ is None:
|
147
|
+
if not rich.prompt.Confirm.ask(
|
148
|
+
"You've reached the end of the list. You must make a selection. Loop through again?"
|
149
|
+
):
|
150
|
+
raise IndexError
|
151
|
+
else:
|
152
|
+
continue
|
153
|
+
else:
|
154
|
+
return delegates[selected_idx_]
|