meshtensor-cli 9.18.1__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 (74) hide show
  1. meshtensor_cli/__init__.py +22 -0
  2. meshtensor_cli/cli.py +10742 -0
  3. meshtensor_cli/doc_generation_helper.py +4 -0
  4. meshtensor_cli/src/__init__.py +1085 -0
  5. meshtensor_cli/src/commands/__init__.py +0 -0
  6. meshtensor_cli/src/commands/axon/__init__.py +0 -0
  7. meshtensor_cli/src/commands/axon/axon.py +132 -0
  8. meshtensor_cli/src/commands/crowd/__init__.py +0 -0
  9. meshtensor_cli/src/commands/crowd/contribute.py +621 -0
  10. meshtensor_cli/src/commands/crowd/contributors.py +200 -0
  11. meshtensor_cli/src/commands/crowd/create.py +783 -0
  12. meshtensor_cli/src/commands/crowd/dissolve.py +219 -0
  13. meshtensor_cli/src/commands/crowd/refund.py +233 -0
  14. meshtensor_cli/src/commands/crowd/update.py +418 -0
  15. meshtensor_cli/src/commands/crowd/utils.py +124 -0
  16. meshtensor_cli/src/commands/crowd/view.py +991 -0
  17. meshtensor_cli/src/commands/governance/__init__.py +0 -0
  18. meshtensor_cli/src/commands/governance/governance.py +794 -0
  19. meshtensor_cli/src/commands/liquidity/__init__.py +0 -0
  20. meshtensor_cli/src/commands/liquidity/liquidity.py +699 -0
  21. meshtensor_cli/src/commands/liquidity/utils.py +202 -0
  22. meshtensor_cli/src/commands/proxy.py +700 -0
  23. meshtensor_cli/src/commands/stake/__init__.py +0 -0
  24. meshtensor_cli/src/commands/stake/add.py +799 -0
  25. meshtensor_cli/src/commands/stake/auto_staking.py +306 -0
  26. meshtensor_cli/src/commands/stake/children_hotkeys.py +865 -0
  27. meshtensor_cli/src/commands/stake/claim.py +770 -0
  28. meshtensor_cli/src/commands/stake/list.py +738 -0
  29. meshtensor_cli/src/commands/stake/move.py +1211 -0
  30. meshtensor_cli/src/commands/stake/remove.py +1466 -0
  31. meshtensor_cli/src/commands/stake/wizard.py +323 -0
  32. meshtensor_cli/src/commands/subnets/__init__.py +0 -0
  33. meshtensor_cli/src/commands/subnets/mechanisms.py +515 -0
  34. meshtensor_cli/src/commands/subnets/price.py +733 -0
  35. meshtensor_cli/src/commands/subnets/subnets.py +2908 -0
  36. meshtensor_cli/src/commands/sudo.py +1294 -0
  37. meshtensor_cli/src/commands/tc/__init__.py +0 -0
  38. meshtensor_cli/src/commands/tc/tc.py +190 -0
  39. meshtensor_cli/src/commands/treasury/__init__.py +0 -0
  40. meshtensor_cli/src/commands/treasury/treasury.py +194 -0
  41. meshtensor_cli/src/commands/view.py +354 -0
  42. meshtensor_cli/src/commands/wallets.py +2311 -0
  43. meshtensor_cli/src/commands/weights.py +467 -0
  44. meshtensor_cli/src/meshtensor/__init__.py +0 -0
  45. meshtensor_cli/src/meshtensor/balances.py +313 -0
  46. meshtensor_cli/src/meshtensor/chain_data.py +1263 -0
  47. meshtensor_cli/src/meshtensor/extrinsics/__init__.py +0 -0
  48. meshtensor_cli/src/meshtensor/extrinsics/mev_shield.py +174 -0
  49. meshtensor_cli/src/meshtensor/extrinsics/registration.py +1861 -0
  50. meshtensor_cli/src/meshtensor/extrinsics/root.py +550 -0
  51. meshtensor_cli/src/meshtensor/extrinsics/serving.py +255 -0
  52. meshtensor_cli/src/meshtensor/extrinsics/transfer.py +239 -0
  53. meshtensor_cli/src/meshtensor/meshtensor_interface.py +2598 -0
  54. meshtensor_cli/src/meshtensor/minigraph.py +254 -0
  55. meshtensor_cli/src/meshtensor/networking.py +12 -0
  56. meshtensor_cli/src/meshtensor/templates/main-filters.j2 +24 -0
  57. meshtensor_cli/src/meshtensor/templates/main-header.j2 +36 -0
  58. meshtensor_cli/src/meshtensor/templates/neuron-details.j2 +111 -0
  59. meshtensor_cli/src/meshtensor/templates/price-multi.j2 +113 -0
  60. meshtensor_cli/src/meshtensor/templates/price-single.j2 +99 -0
  61. meshtensor_cli/src/meshtensor/templates/subnet-details-header.j2 +49 -0
  62. meshtensor_cli/src/meshtensor/templates/subnet-details.j2 +32 -0
  63. meshtensor_cli/src/meshtensor/templates/subnet-metrics.j2 +57 -0
  64. meshtensor_cli/src/meshtensor/templates/subnets-table.j2 +28 -0
  65. meshtensor_cli/src/meshtensor/templates/table.j2 +267 -0
  66. meshtensor_cli/src/meshtensor/templates/view.css +1058 -0
  67. meshtensor_cli/src/meshtensor/templates/view.j2 +43 -0
  68. meshtensor_cli/src/meshtensor/templates/view.js +1053 -0
  69. meshtensor_cli/src/meshtensor/utils.py +2007 -0
  70. meshtensor_cli/version.py +23 -0
  71. meshtensor_cli-9.18.1.dist-info/METADATA +261 -0
  72. meshtensor_cli-9.18.1.dist-info/RECORD +74 -0
  73. meshtensor_cli-9.18.1.dist-info/WHEEL +4 -0
  74. meshtensor_cli-9.18.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,2007 @@
1
+ import ast
2
+ import json
3
+ from collections import namedtuple
4
+ import math
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ import webbrowser
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any, Collection, Optional, Union, Callable, Generator
12
+ from urllib.parse import urlparse
13
+ from functools import partial
14
+ import re
15
+
16
+ import aiohttp
17
+ from async_substrate_interface import AsyncExtrinsicReceipt
18
+ from meshtensor_wallet import Wallet, Keypair
19
+ from meshtensor_wallet.utils import SS58_FORMAT
20
+ from meshtensor_wallet.errors import KeyFileError, PasswordError
21
+ from meshtensor_wallet import utils
22
+ from jinja2 import Template, Environment, PackageLoader, select_autoescape
23
+ from markupsafe import Markup
24
+ import numpy as np
25
+ from numpy.typing import NDArray
26
+ from rich.console import Console
27
+ from rich.prompt import Confirm, Prompt
28
+ from scalecodec import GenericCall
29
+ from scalecodec.utils.ss58 import ss58_encode, ss58_decode
30
+ import typer
31
+
32
+
33
+ from meshtensor_cli.src.meshtensor.balances import Balance
34
+ from meshtensor_cli.src import defaults, Constants
35
+
36
+
37
+ if TYPE_CHECKING:
38
+ from meshtensor_cli.src.meshtensor.chain_data import SubnetHyperparameters
39
+ from rich.prompt import PromptBase
40
+
41
+ BT_DOCS_LINK = "https://docs.learnmeshtensor.org"
42
+
43
+ GLOBAL_MAX_SUBNET_COUNT = 4096
44
+ MEV_SHIELD_PUBLIC_KEY_SIZE = 1184
45
+
46
+ # Detect if we're in a test environment (pytest captures stdout, making it non-TTY)
47
+ # or if NO_COLOR is set, disable colors
48
+ # Also check for pytest environment variables
49
+ _is_pytest = "pytest" in sys.modules or os.getenv("PYTEST_CURRENT_TEST") is not None
50
+ _no_color = os.getenv("NO_COLOR", "") != "" or not sys.stdout.isatty() or _is_pytest
51
+ # Force no terminal detection when in pytest or when stdout is not a TTY
52
+ _force_terminal = False if (_is_pytest or not sys.stdout.isatty()) else None
53
+ console = Console(no_color=_no_color, force_terminal=_force_terminal)
54
+ json_console = Console(
55
+ markup=False, highlight=False, force_terminal=False, no_color=True
56
+ )
57
+ err_console = Console(stderr=True, no_color=_no_color, force_terminal=_force_terminal)
58
+ verbose_console = Console(
59
+ quiet=True, no_color=_no_color, force_terminal=_force_terminal
60
+ )
61
+
62
+
63
+ def confirm_action(
64
+ message: str,
65
+ default: bool = False,
66
+ decline: bool = False,
67
+ quiet: bool = False,
68
+ ) -> bool:
69
+ """
70
+ Ask for user confirmation with support for auto-decline via --no flag.
71
+
72
+ When decline=True (--no flag is set):
73
+ - Prints the prompt message (unless quiet=True / --quiet is specified)
74
+ - Automatically returns False (declines)
75
+
76
+ Args:
77
+ message: The confirmation message to display.
78
+ default: Default value if user just presses Enter (only used in interactive mode).
79
+ decline: If True, automatically decline without prompting.
80
+ quiet: If True, suppresses the prompt message when auto-declining.
81
+
82
+ Returns:
83
+ True if confirmed, False if declined.
84
+ """
85
+ if decline:
86
+ if not quiet:
87
+ console.print(f"{message} [Auto-declined via --no flag]")
88
+ return False
89
+ return Confirm.ask(message, default=default)
90
+
91
+
92
+ jinja_env = Environment(
93
+ loader=PackageLoader("meshtensor_cli", "src/meshtensor/templates"),
94
+ autoescape=select_autoescape(),
95
+ )
96
+
97
+ UnlockStatus = namedtuple("UnlockStatus", ["success", "message"])
98
+
99
+
100
+ class _Hotkey:
101
+ def __init__(self, hotkey_ss58=None):
102
+ self.ss58_address = hotkey_ss58
103
+
104
+
105
+ class _Coldkeypub:
106
+ def __init__(self, coldkey_ss58=None):
107
+ self.ss58_address = coldkey_ss58
108
+
109
+
110
+ class WalletLike:
111
+ def __init__(
112
+ self,
113
+ name=None,
114
+ hotkey_ss58=None,
115
+ hotkey_str=None,
116
+ coldkeypub_ss58=None,
117
+ ):
118
+ self.name = name
119
+ self.hotkey_ss58 = hotkey_ss58
120
+ self.hotkey_str = hotkey_str
121
+ self._hotkey = _Hotkey(hotkey_ss58)
122
+ self._coldkeypub = _Coldkeypub(coldkeypub_ss58)
123
+
124
+ @property
125
+ def hotkey(self):
126
+ return self._hotkey
127
+
128
+ @property
129
+ def coldkeypub(self):
130
+ return self._coldkeypub
131
+
132
+
133
+ def print_console(message: str, colour: str, console_: Console, title: str = ""):
134
+ title_part = f"[bold {colour}][{title}]:[/bold {colour}] " if title else ""
135
+ console_.print(f"{title_part}[{colour}]{message}[/{colour}]\n")
136
+
137
+
138
+ def print_verbose(message: str, status=None):
139
+ """Print verbose messages while temporarily pausing the status spinner."""
140
+ if status:
141
+ status.stop()
142
+ print_console(message, "green", verbose_console, "Verbose")
143
+ status.start()
144
+ else:
145
+ print_console(message, "green", verbose_console, "Verbose")
146
+
147
+
148
+ def print_error(message: str, status=None):
149
+ """Print error messages while temporarily pausing the status spinner."""
150
+ error_message = f":cross_mark: {message}"
151
+ if status:
152
+ status.stop()
153
+ print_console(error_message, "red", err_console)
154
+ status.start()
155
+ else:
156
+ print_console(error_message, "red", err_console)
157
+
158
+
159
+ def print_success(message: str, status=None):
160
+ """Print success messages while temporarily pausing the status spinner."""
161
+ success_message = f":white_heavy_check_mark: {message}"
162
+ if status:
163
+ status.stop()
164
+ print_console(success_message, "green", console)
165
+ status.start()
166
+ else:
167
+ print_console(success_message, "green", console)
168
+
169
+
170
+ def print_protection_warnings(
171
+ mev_protection: bool,
172
+ safe_staking: Optional[bool] = None,
173
+ command_name: str = "",
174
+ ) -> None:
175
+ """
176
+ Print warnings about missing MEV protection and/or limit price protection.
177
+
178
+ Args:
179
+ mev_protection: Whether MEV protection is enabled.
180
+ safe_staking: Whether safe staking (limit price protection) is enabled.
181
+ None if limit price protection is not available for this command.
182
+ command_name: Name of the command (e.g., "stake add") for context.
183
+ """
184
+ warnings = []
185
+
186
+ if not mev_protection:
187
+ warnings.append(
188
+ "⚠️ [dim][yellow]Warning:[/yellow] MEV protection is disabled. "
189
+ "This transaction may be exposed to MEV attacks.[/dim]"
190
+ )
191
+
192
+ if safe_staking is not None and not safe_staking:
193
+ warnings.append(
194
+ "⚠️ [dim][yellow]Warning:[/yellow] Limit price protection (safe staking) is disabled. "
195
+ "This transaction may be subject to slippage.[/dim]"
196
+ )
197
+
198
+ if warnings:
199
+ if command_name:
200
+ console.print(f"\n[dim]Protection status for '{command_name}':[/dim]")
201
+ for warning in warnings:
202
+ console.print(warning)
203
+ console.print()
204
+
205
+
206
+ MESHLET_PER_MESH = 1e9
207
+ U16_MAX = 65535
208
+ U64_MAX = 18446744073709551615
209
+
210
+
211
+ def u16_normalized_float(x: int) -> float:
212
+ """Converts a u16 int to a float"""
213
+ return float(x) / float(U16_MAX)
214
+
215
+
216
+ def u64_normalized_float(x: int) -> float:
217
+ """Converts a u64 int to a float"""
218
+ return float(x) / float(U64_MAX)
219
+
220
+
221
+ def string_to_u64(value: str) -> int:
222
+ """Converts a string to u64"""
223
+ return float_to_u64(float(value))
224
+
225
+
226
+ def float_to_u64(value: float) -> int:
227
+ """Converts a float to a u64 int"""
228
+ # Ensure the input is within the expected range
229
+ if not (0 <= value <= 1):
230
+ raise ValueError("Input value must be between 0 and 1")
231
+
232
+ # Convert the float to a u64 value
233
+ return int(value * (2**64 - 1))
234
+
235
+
236
+ def u64_to_float(value: int) -> float:
237
+ u64_max = 2**64 - 1
238
+ # Allow for a small margin of error (e.g., 1) to account for potential rounding issues
239
+ if not (0 <= value <= u64_max + 1):
240
+ raise ValueError(
241
+ f"Input value ({value}) must be between 0 and {u64_max} (2^64 - 1)"
242
+ )
243
+ return min(value / u64_max, 1.0) # Ensure the result is never greater than 1.0
244
+
245
+
246
+ def string_to_u16(value: str) -> int:
247
+ """Converts a string to a u16 int"""
248
+ return float_to_u16(float(value))
249
+
250
+
251
+ def float_to_u16(value: float) -> int:
252
+ # Ensure the input is within the expected range
253
+ if not (0 <= value <= 1):
254
+ raise ValueError("Input value must be between 0 and 1")
255
+
256
+ # Calculate the u16 representation
257
+ u16_max = 65535
258
+ return int(value * u16_max)
259
+
260
+
261
+ def u16_to_float(value: int) -> float:
262
+ # Ensure the input is within the expected range
263
+ if not (0 <= value <= 65535):
264
+ raise ValueError("Input value must be between 0 and 65535")
265
+
266
+ # Calculate the float representation
267
+ u16_max = 65535
268
+ return value / u16_max
269
+
270
+
271
+ def convert_weight_uids_and_vals_to_tensor(
272
+ n: int, uids: Collection[int], weights: Collection[int]
273
+ ) -> NDArray[np.float32]:
274
+ """
275
+ Converts weights and uids from chain representation into a `np.array` (inverse operation from
276
+ convert_weights_and_uids_for_emit)
277
+
278
+ :param n: number of neurons on network.
279
+ :param uids: Tensor of uids as destinations for passed weights.
280
+ :param weights: Tensor of weights.
281
+
282
+ :return: row_weights: Converted row weights.
283
+ """
284
+ row_weights = np.zeros([n], dtype=np.float32)
285
+ for uid_j, wij in list(zip(uids, weights)):
286
+ row_weights[uid_j] = float(
287
+ wij
288
+ ) # assumes max-upscaled values (w_max = U16_MAX).
289
+ row_sum = row_weights.sum()
290
+ if row_sum > 0:
291
+ row_weights /= row_sum # normalize
292
+ return row_weights
293
+
294
+
295
+ def convert_bond_uids_and_vals_to_tensor(
296
+ n: int, uids: list[int], bonds: list[int]
297
+ ) -> NDArray[np.int64]:
298
+ """Converts bond and uids from chain representation into a np.array.
299
+
300
+ :param n: number of neurons on network.
301
+ :param uids: Tensor of uids as destinations for passed bonds.
302
+ :param bonds: Tensor of bonds.
303
+
304
+ :return: Converted row bonds.
305
+ """
306
+ row_bonds = np.zeros([n], dtype=np.int64)
307
+
308
+ for uid_j, bij in list(zip(uids, bonds)):
309
+ row_bonds[uid_j] = int(bij)
310
+ return row_bonds
311
+
312
+
313
+ def convert_root_weight_uids_and_vals_to_tensor(
314
+ n: int, uids: list[int], weights: list[int], subnets: list[int]
315
+ ) -> NDArray:
316
+ """
317
+ Converts root weights and uids from chain representation into a `np.array` or `torch.FloatTensor` (inverse operation
318
+ from `convert_weights_and_uids_for_emit`)
319
+
320
+ :param n: number of neurons on network.
321
+ :param uids: Tensor of uids as destinations for passed weights.
322
+ :param weights: Tensor of weights.
323
+ :param subnets: list of subnets on the network
324
+
325
+ :return: row_weights: Converted row weights.
326
+ """
327
+
328
+ row_weights = np.zeros([n], dtype=np.float32)
329
+ for uid_j, wij in list(zip(uids, weights)):
330
+ if uid_j in subnets:
331
+ index_s = subnets.index(uid_j)
332
+ row_weights[index_s] = float(
333
+ wij
334
+ ) # assumes max-upscaled values (w_max = U16_MAX).
335
+ else:
336
+ # TODO standardise logging
337
+ # logging.warning(
338
+ # f"Incorrect Subnet uid {uid_j} in Subnets {subnets}. The subnet is unavailable at the moment."
339
+ # )
340
+ continue
341
+ row_sum = row_weights.sum()
342
+ if row_sum > 0:
343
+ row_weights /= row_sum # normalize
344
+ return row_weights
345
+
346
+
347
+ def get_hotkey_wallets_for_wallet(
348
+ wallet: Wallet, show_nulls: bool = False, show_encrypted: bool = False
349
+ ) -> list[Optional[Wallet]]:
350
+ """
351
+ Returns wallet objects with hotkeys for a single given wallet
352
+
353
+ :param wallet: Wallet object to use for the path
354
+ :param show_nulls: will add `None` into the output if a hotkey is encrypted or not on the device
355
+ :param show_encrypted: will add some basic info about the encrypted hotkey
356
+
357
+ :return: a list of wallets (with Nones included for cases of a hotkey being encrypted or not on the device, if
358
+ `show_nulls` is set to `True`)
359
+ """
360
+ hotkey_wallets = []
361
+ wallet_path = Path(wallet.path).expanduser()
362
+ hotkeys_path = wallet_path / wallet.name / "hotkeys"
363
+ try:
364
+ hotkeys = [entry.name for entry in hotkeys_path.iterdir()]
365
+ except (FileNotFoundError, NotADirectoryError):
366
+ hotkeys = []
367
+ for h_name in hotkeys:
368
+ if h_name.endswith("pub.txt"):
369
+ if h_name.split("pub.txt")[0] in hotkeys:
370
+ continue
371
+ else:
372
+ hotkey_for_name = Wallet(
373
+ path=str(wallet_path),
374
+ name=wallet.name,
375
+ hotkey=h_name.split("pub.txt")[0],
376
+ )
377
+ else:
378
+ hotkey_for_name = Wallet(
379
+ path=str(wallet_path), name=wallet.name, hotkey=h_name
380
+ )
381
+ try:
382
+ exists = (
383
+ hotkey_for_name.hotkey_file.exists_on_device()
384
+ or hotkey_for_name.hotkeypub_file.exists_on_device()
385
+ )
386
+ if (
387
+ exists
388
+ and not hotkey_for_name.hotkey_file.is_encrypted()
389
+ # and hotkey_for_name.coldkeypub.ss58_address
390
+ and get_hotkey_pub_ss58(hotkey_for_name)
391
+ ):
392
+ hotkey_wallets.append(hotkey_for_name)
393
+ elif (
394
+ show_encrypted and exists and hotkey_for_name.hotkey_file.is_encrypted()
395
+ ):
396
+ hotkey_wallets.append(
397
+ WalletLike(str(wallet_path), "<ENCRYPTED>", h_name)
398
+ )
399
+ elif show_nulls:
400
+ hotkey_wallets.append(None)
401
+ except (
402
+ UnicodeDecodeError,
403
+ AttributeError,
404
+ TypeError,
405
+ KeyFileError,
406
+ ValueError,
407
+ ): # usually an unrelated file like .DS_Store
408
+ continue
409
+
410
+ return hotkey_wallets
411
+
412
+
413
+ def get_coldkey_wallets_for_path(path: str) -> list[Wallet]:
414
+ """Gets all wallets with coldkeys from a given path"""
415
+ wallet_path = Path(path).expanduser()
416
+ try:
417
+ wallets = [
418
+ Wallet(name=directory.name, path=path)
419
+ for directory in wallet_path.iterdir()
420
+ if directory.is_dir()
421
+ ]
422
+ except FileNotFoundError:
423
+ wallets = []
424
+ return wallets
425
+
426
+
427
+ def get_all_wallets_for_path(path: str) -> list[Wallet]:
428
+ """Gets all wallets from a given path."""
429
+ all_wallets = []
430
+ cold_wallets = get_coldkey_wallets_for_path(path)
431
+ for cold_wallet in cold_wallets:
432
+ try:
433
+ if (
434
+ cold_wallet.coldkeypub_file.exists_on_device()
435
+ and not cold_wallet.coldkeypub_file.is_encrypted()
436
+ ):
437
+ all_wallets.extend(get_hotkey_wallets_for_wallet(cold_wallet))
438
+ except UnicodeDecodeError: # usually an incorrect file like .DS_Store
439
+ continue
440
+ return all_wallets
441
+
442
+
443
+ def validate_coldkey_presence(
444
+ wallets: list[Wallet],
445
+ ) -> tuple[list[Wallet], list[Wallet]]:
446
+ """
447
+ Validates the presence of coldkeypub.txt for each wallet.
448
+
449
+ Returns:
450
+ tuple[list[Wallet], list[Wallet]]: A tuple containing two lists:
451
+ - The first list contains wallets with the required coldkey.
452
+ - The second list contains wallets without the required coldkey.
453
+ """
454
+ valid_wallets = []
455
+ invalid_wallets = []
456
+
457
+ for wallet in wallets:
458
+ if not os.path.exists(wallet.coldkeypub_file.path):
459
+ invalid_wallets.append(wallet)
460
+ else:
461
+ valid_wallets.append(wallet)
462
+ return valid_wallets, invalid_wallets
463
+
464
+
465
+ def is_valid_wallet(wallet: Wallet) -> tuple[bool, bool]:
466
+ """
467
+ Verifies that the wallet with specified parameters.
468
+
469
+ :param wallet: a Wallet instance
470
+
471
+ :return: tuple[bool], whether wallet appears valid, whether valid hotkey in wallet
472
+ """
473
+ return (
474
+ all(
475
+ [
476
+ os.path.exists(wp := os.path.expanduser(wallet.path)),
477
+ os.path.exists(os.path.join(wp, wallet.name)),
478
+ ]
479
+ ),
480
+ os.path.isfile(os.path.join(wp, wallet.name, "hotkeys", wallet.hotkey_str)),
481
+ )
482
+
483
+
484
+ def is_valid_ss58_address(address: str) -> bool:
485
+ """
486
+ Checks if the given address is a valid ss58 address.
487
+
488
+ :param address: The address to check.
489
+
490
+ :return: `True` if the address is a valid ss58 address for Meshtensor, `False` otherwise.
491
+ """
492
+ try:
493
+ return utils.is_valid_ss58_address(
494
+ address
495
+ ) # Default substrate ss58 format (legacy)
496
+ except IndexError:
497
+ return False
498
+
499
+
500
+ def is_valid_ss58_address_prompt(text: str) -> str:
501
+ valid = False
502
+ address = ""
503
+ while not valid:
504
+ address = Prompt.ask(text).strip()
505
+ valid = is_valid_ss58_address(address)
506
+ return address
507
+
508
+
509
+ def is_valid_ed25519_pubkey(public_key: Union[str, bytes]) -> bool:
510
+ """
511
+ Checks if the given public_key is a valid ed25519 key.
512
+
513
+ :param public_key: The public_key to check.
514
+
515
+ :return: True if the public_key is a valid ed25519 key, False otherwise.
516
+
517
+ """
518
+ try:
519
+ if isinstance(public_key, str):
520
+ if len(public_key) != 64 and len(public_key) != 66:
521
+ raise ValueError("a public_key should be 64 or 66 characters")
522
+ elif isinstance(public_key, bytes):
523
+ if len(public_key) != 32:
524
+ raise ValueError("a public_key should be 32 bytes")
525
+ else:
526
+ raise ValueError("public_key must be a string or bytes")
527
+
528
+ keypair = Keypair(public_key=public_key, ss58_format=SS58_FORMAT)
529
+
530
+ ss58_addr = keypair.ss58_address
531
+ return ss58_addr is not None
532
+
533
+ except (ValueError, IndexError):
534
+ return False
535
+
536
+
537
+ def is_valid_meshtensor_address_or_public_key(address: Union[str, bytes]) -> bool:
538
+ """
539
+ Checks if the given address is a valid destination address.
540
+
541
+ :param address: The address to check.
542
+
543
+ :return: True if the address is a valid destination address, False otherwise.
544
+ """
545
+ if isinstance(address, str):
546
+ # Check if ed25519
547
+ if address.startswith("0x"):
548
+ return is_valid_ed25519_pubkey(address)
549
+ else:
550
+ # Assume ss58 address
551
+ return is_valid_ss58_address(address)
552
+ elif isinstance(address, bytes):
553
+ # Check if ed25519
554
+ return is_valid_ed25519_pubkey(address)
555
+ else:
556
+ # Invalid address type
557
+ return False
558
+
559
+
560
+ def decode_account_id(account_id_bytes: Union[tuple[int], tuple[tuple[int]]]):
561
+ if isinstance(account_id_bytes, tuple) and isinstance(account_id_bytes[0], tuple):
562
+ account_id_bytes = account_id_bytes[0]
563
+ # Convert the AccountId bytes to a Base64 string
564
+ return ss58_encode(bytes(account_id_bytes).hex(), SS58_FORMAT)
565
+
566
+
567
+ def encode_account_id(ss58_address: str) -> bytes:
568
+ return bytes.fromhex(ss58_decode(ss58_address, SS58_FORMAT))
569
+
570
+
571
+ def ss58_to_vec_u8(ss58_address: str) -> list[int]:
572
+ """
573
+ Converts an SS58 address to a list of integers (vector of u8).
574
+
575
+ :param ss58_address: The SS58 address to be converted.
576
+
577
+ :return: A list of integers representing the byte values of the SS58 address.
578
+ """
579
+ ss58_bytes: bytes = encode_account_id(ss58_address)
580
+ encoded_address: list[int] = [int(byte) for byte in ss58_bytes]
581
+ return encoded_address
582
+
583
+
584
+ def get_explorer_root_url_by_network_from_map(
585
+ network: str, network_map: dict[str, dict[str, str]]
586
+ ) -> dict[str, str]:
587
+ """
588
+ Returns the explorer root url for the given network name from the given network map.
589
+
590
+ :param network: The network to get the explorer url for.
591
+ :param network_map: The network map to get the explorer url from.
592
+
593
+ :return: The explorer url for the given network.
594
+ """
595
+ explorer_urls: dict[str, str] = {}
596
+ for entity_nm, entity_network_map in network_map.items():
597
+ if network in entity_network_map:
598
+ explorer_urls[entity_nm] = entity_network_map[network]
599
+
600
+ return explorer_urls
601
+
602
+
603
+ def get_explorer_url_for_network(
604
+ network: str, block_hash: str, network_map: dict[str, dict[str, str]]
605
+ ) -> dict[str, str]:
606
+ """
607
+ Returns the explorer url for the given block hash and network.
608
+
609
+ :param network: The network to get the explorer url for.
610
+ :param block_hash: The block hash to get the explorer url for.
611
+ :param network_map: The network maps to get the explorer urls from.
612
+
613
+ :return: The explorer url for the given block hash and network
614
+ """
615
+ # TODO remove
616
+
617
+ explorer_urls: dict[str, str] = {}
618
+ # Will be None if the network is not known. i.e. not in network_map
619
+ explorer_root_urls: dict[str, str] = get_explorer_root_url_by_network_from_map(
620
+ network, network_map
621
+ )
622
+
623
+ if explorer_root_urls != {}:
624
+ # We are on a known network.
625
+ explorer_opentensor_url = "{root_url}/query/{block_hash}".format(
626
+ root_url=explorer_root_urls.get("opentensor"), block_hash=block_hash
627
+ )
628
+ explorer_taostats_url = "{root_url}/hash/{block_hash}".format(
629
+ root_url=explorer_root_urls.get("taostats"), block_hash=block_hash
630
+ )
631
+ explorer_urls["opentensor"] = explorer_opentensor_url
632
+ explorer_urls["taostats"] = explorer_taostats_url
633
+
634
+ return explorer_urls
635
+
636
+
637
+ def format_error_message(error_message: Union[dict, Exception]) -> str:
638
+ """
639
+ Formats an error message from the Meshtensor error information for use in extrinsics.
640
+
641
+ Args:
642
+ error_message: A dictionary containing the error information from Meshtensor, or a SubstrateRequestException
643
+ containing dictionary literal args.
644
+
645
+ Returns:
646
+ str: A formatted error message string.
647
+ """
648
+ err_name = "UnknownError"
649
+ err_type = "UnknownType"
650
+ err_description = "Unknown Description"
651
+
652
+ if isinstance(error_message, Exception):
653
+ # generally gotten through SubstrateRequestException args
654
+ new_error_message = None
655
+ for arg in error_message.args:
656
+ try:
657
+ d = ast.literal_eval(arg)
658
+ if isinstance(d, dict):
659
+ if "error" in d:
660
+ new_error_message = d["error"]
661
+ break
662
+ elif all(x in d for x in ["code", "message", "data"]):
663
+ new_error_message = d
664
+ break
665
+ except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
666
+ pass
667
+ if new_error_message is None:
668
+ return_val = " ".join(error_message.args)
669
+
670
+ return f"Meshtensor returned: {return_val}"
671
+ else:
672
+ error_message = new_error_message
673
+
674
+ if isinstance(error_message, dict):
675
+ # meshtensor error structure
676
+ if (
677
+ error_message.get("code")
678
+ and error_message.get("message")
679
+ and error_message.get("data")
680
+ ):
681
+ err_name = "SubstrateRequestException"
682
+ err_type = error_message.get("message", "")
683
+ err_data = error_message.get("data", "")
684
+
685
+ # meshtensor custom error marker
686
+ if err_data.startswith("Custom error:"):
687
+ err_description = (
688
+ f"{err_data} | Please consult {BT_DOCS_LINK}/errors/custom"
689
+ )
690
+ else:
691
+ err_description = err_data
692
+
693
+ elif (
694
+ error_message.get("type")
695
+ and error_message.get("name")
696
+ and error_message.get("docs")
697
+ ):
698
+ err_type = error_message.get("type", err_type)
699
+ err_name = error_message.get("name", err_name)
700
+ err_docs = error_message.get("docs", [err_description])
701
+ err_description = (
702
+ " ".join(err_docs) if not isinstance(err_docs, str) else err_docs
703
+ )
704
+ err_description += (
705
+ f" | Please consult {BT_DOCS_LINK}/errors/meshtensor#{err_name.lower()}"
706
+ )
707
+
708
+ elif error_message.get("code") and error_message.get("message"):
709
+ err_type = error_message.get("code", err_name)
710
+ err_name = "Custom type"
711
+ err_description = error_message.get("message", err_description)
712
+
713
+ else:
714
+ print_error(
715
+ f"String representation of real error_message: {str(error_message)}"
716
+ )
717
+
718
+ return f"Meshtensor returned `{err_name}({err_type})` error. This means: `{err_description}`."
719
+
720
+
721
+ def convert_blocks_to_time(blocks: int, block_time: int = 12) -> tuple[int, int, int]:
722
+ """
723
+ Converts number of blocks into number of hours, minutes, seconds.
724
+ :param blocks: number of blocks
725
+ :param block_time: time per block, by default this is 12
726
+ :return: tuple containing number of hours, number of minutes, number of seconds
727
+ """
728
+ seconds = blocks * block_time
729
+ hours = seconds // 3600
730
+ minutes = (seconds % 3600) // 60
731
+ remaining_seconds = seconds % 60
732
+ return hours, minutes, remaining_seconds
733
+
734
+
735
+ def decode_hex_identity_dict(info_dictionary) -> dict[str, Any]:
736
+ """
737
+ Decodes hex-encoded strings in a dictionary.
738
+
739
+ This function traverses the given dictionary, identifies hex-encoded strings, and decodes them into readable
740
+ strings. It handles nested dictionaries and lists within the dictionary.
741
+
742
+ Args:
743
+ info_dictionary (dict): The dictionary containing hex-encoded strings to decode.
744
+
745
+ Returns:
746
+ dict: The dictionary with decoded strings.
747
+
748
+ Examples:
749
+ input_dict = {
750
+ "name": {"value": "0x6a6f686e"},
751
+ "additional": [
752
+ {"data1": "0x64617461"},
753
+ ("data2", "0x64617461")
754
+ ]
755
+ }
756
+ decode_hex_identity_dict(input_dict)
757
+ {'name': 'john', 'additional': [('data1', 'data'), ('data2', 'data')]}
758
+ """
759
+
760
+ def get_decoded(data: Optional[str]) -> str:
761
+ """Decodes a hex-encoded string."""
762
+ if data is None:
763
+ return ""
764
+ try:
765
+ return hex_to_bytes(data).decode()
766
+ except (UnicodeDecodeError, ValueError):
767
+ print(f"Could not decode: {key}: {item}")
768
+
769
+ for key, value in info_dictionary.items():
770
+ if isinstance(value, dict):
771
+ item = list(value.values())[0]
772
+ if isinstance(item, str) and item.startswith("0x"):
773
+ try:
774
+ info_dictionary[key] = get_decoded(item)
775
+ except UnicodeDecodeError:
776
+ print(f"Could not decode: {key}: {item}")
777
+ else:
778
+ info_dictionary[key] = item
779
+ if key == "additional":
780
+ additional = []
781
+ for item in value:
782
+ if isinstance(item, dict):
783
+ for k, v in item.items():
784
+ additional.append((k, get_decoded(v)))
785
+ else:
786
+ if isinstance(item, (tuple, list)) and len(item) == 2:
787
+ k_, v = item
788
+ k = k_ if k_ is not None else ""
789
+ additional.append((k, get_decoded(v)))
790
+ info_dictionary[key] = additional
791
+
792
+ return info_dictionary
793
+
794
+
795
+ def get_human_readable(num: float, suffix="H"):
796
+ """
797
+ Converts a number to a human-readable string.
798
+
799
+ :return: human-readable string representation of a number.
800
+ """
801
+ for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
802
+ if abs(num) < 1000.0:
803
+ return f"{num:3.1f}{unit}{suffix}"
804
+ num /= 1000.0
805
+ return f"{num:.1f}Y{suffix}"
806
+
807
+
808
+ def millify(n: int):
809
+ """
810
+ Convert a large number into a more readable format with appropriate suffixes.
811
+
812
+ This function transforms a large integer into a shorter, human-readable string with
813
+ suffixes such as K, M, B, and T for thousands, millions, billions, and trillions,
814
+ respectively. The number is formatted to two decimal places.
815
+
816
+ :param n: The number to be converted.
817
+
818
+ :return: The formatted string representing the number with a suffix.
819
+ """
820
+ mill_names = ["", " K", " M", " B", " T"]
821
+ n_ = float(n)
822
+ mill_idx = max(
823
+ 0,
824
+ min(
825
+ len(mill_names) - 1,
826
+ int(math.floor(0 if n_ == 0 else math.log10(abs(n_)) / 3)),
827
+ ),
828
+ )
829
+
830
+ return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx])
831
+
832
+
833
+ def millify_tao(n: float, start_at: str = "K") -> str:
834
+ """
835
+ Dupe of millify, but for ease in converting mesh values.
836
+ Allows thresholds to be specified for different suffixes.
837
+ """
838
+ mill_names = ["", "k", "m", "b", "t"]
839
+ thresholds = {"K": 1, "M": 2, "B": 3, "T": 4}
840
+
841
+ if start_at not in thresholds:
842
+ raise ValueError(f"start_at must be one of {list(thresholds.keys())}")
843
+
844
+ n_ = float(n)
845
+ if n_ == 0:
846
+ return "0.00"
847
+
848
+ mill_idx = int(math.floor(math.log10(abs(n_)) / 3))
849
+
850
+ # Number's index is below our threshold, return with commas
851
+ if mill_idx < thresholds[start_at]:
852
+ return f"{n_:,.2f}"
853
+
854
+ mill_idx = max(thresholds[start_at], min(len(mill_names) - 1, mill_idx))
855
+
856
+ return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx])
857
+
858
+
859
+ def normalize_hyperparameters(
860
+ subnet: "SubnetHyperparameters",
861
+ json_output: bool = False,
862
+ ) -> list[tuple[str, str, str]]:
863
+ """
864
+ Normalizes the hyperparameters of a subnet.
865
+
866
+ :param subnet: The subnet hyperparameters object.
867
+ :param json_output: Whether this normalisation will be for a JSON output or console string (determines whether
868
+ items get stringified or safe for JSON encoding)
869
+
870
+ :return: A list of tuples containing the parameter name, value, and normalized value.
871
+ """
872
+ param_mappings = {
873
+ "adjustment_alpha": u64_normalized_float,
874
+ "min_difficulty": u64_normalized_float,
875
+ "max_difficulty": u64_normalized_float,
876
+ "difficulty": u64_normalized_float,
877
+ "bonds_moving_avg": u64_normalized_float,
878
+ "max_weight_limit": u16_normalized_float,
879
+ "kappa": u16_normalized_float,
880
+ "alpha_high": u16_normalized_float,
881
+ "alpha_low": u16_normalized_float,
882
+ "alpha_sigmoid_steepness": u16_normalized_float,
883
+ "min_burn": Balance.from_meshlet,
884
+ "max_burn": Balance.from_meshlet,
885
+ }
886
+
887
+ normalized_values: list[tuple[str, str, str]] = []
888
+ subnet_dict = subnet.__dict__
889
+
890
+ for param, value in subnet_dict.items():
891
+ try:
892
+ if param in param_mappings:
893
+ norm_value = param_mappings[param](value)
894
+ if isinstance(norm_value, float):
895
+ norm_value = f"{norm_value:.{10}g}"
896
+ if isinstance(norm_value, Balance) and json_output:
897
+ norm_value = norm_value.to_dict()
898
+ else:
899
+ norm_value = value
900
+ except Exception:
901
+ # meshtensor.logging.warning(f"Error normalizing parameter '{param}': {e}")
902
+ norm_value = "-"
903
+ if not json_output:
904
+ normalized_values.append((param, str(value), str(norm_value)))
905
+ else:
906
+ normalized_values.append((param, value, norm_value))
907
+
908
+ return normalized_values
909
+
910
+
911
+ class TableDefinition:
912
+ """
913
+ Base class for address book table definitions/functions
914
+ """
915
+
916
+ name: str
917
+ cols: tuple[tuple[str, str], ...]
918
+
919
+ @staticmethod
920
+ @contextmanager
921
+ def get_db() -> Generator[tuple[sqlite3.Connection, sqlite3.Cursor], None, None]:
922
+ """
923
+ Helper function to get a DB connection
924
+ """
925
+ with DB() as (conn, cursor):
926
+ yield conn, cursor
927
+
928
+ @classmethod
929
+ def create_if_not_exists(cls, conn: sqlite3.Connection, _: sqlite3.Cursor) -> None:
930
+ """
931
+ Creates the table if it doesn't exist.
932
+ Args:
933
+ conn: sqlite3 connection
934
+ _: sqlite3 cursor
935
+ """
936
+ columns_ = ", ".join([" ".join(x) for x in cls.cols])
937
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {cls.name} ({columns_})")
938
+ conn.commit()
939
+
940
+ @classmethod
941
+ def read_rows(
942
+ cls,
943
+ _: sqlite3.Connection,
944
+ cursor: sqlite3.Cursor,
945
+ include_header: bool = True,
946
+ ) -> list[tuple[Union[str, int], ...]]:
947
+ """
948
+ Reads rows from a table.
949
+
950
+ Args:
951
+ _: sqlite3 connection
952
+ cursor: sqlite3 cursor
953
+ include_header: Whether to include the header row
954
+
955
+ Returns:
956
+ rows of the table, with column names as the header row if `include_header` is set
957
+
958
+ """
959
+ header = tuple(x[0] for x in cls.cols)
960
+ cols = ", ".join(header)
961
+ cursor.execute(f"SELECT {cols} FROM {cls.name}")
962
+ rows = cursor.fetchall()
963
+ if not include_header:
964
+ return rows
965
+ else:
966
+ return [header] + rows
967
+
968
+ @classmethod
969
+ def clear_table(
970
+ cls,
971
+ conn: sqlite3.Connection,
972
+ cursor: sqlite3.Cursor,
973
+ ):
974
+ """Truncates the table. Use with caution."""
975
+ cursor.execute(f"DELETE FROM {cls.name}")
976
+ conn.commit()
977
+
978
+ @classmethod
979
+ def update_entry(cls, *args, **kwargs):
980
+ """
981
+ Updates an existing entry in the table.
982
+ """
983
+ raise NotImplementedError()
984
+
985
+ @classmethod
986
+ def add_entry(cls, *args, **kwargs):
987
+ """
988
+ Adds an entry to the table.
989
+ """
990
+ raise NotImplementedError()
991
+
992
+ @classmethod
993
+ def delete_entry(cls, *args, **kwargs):
994
+ """
995
+ Deletes an entry from the table.
996
+ """
997
+ raise NotImplementedError()
998
+
999
+
1000
+ class AddressBook(TableDefinition):
1001
+ name = "address_book"
1002
+ cols = (("name", "TEXT"), ("ss58_address", "TEXT"), ("note", "TEXT"))
1003
+
1004
+ @classmethod
1005
+ def add_entry(
1006
+ cls,
1007
+ conn: sqlite3.Connection,
1008
+ _: sqlite3.Cursor,
1009
+ *,
1010
+ name: str,
1011
+ ss58_address: str,
1012
+ note: str,
1013
+ ) -> None:
1014
+ conn.execute(
1015
+ f"INSERT INTO {cls.name} (name, ss58_address, note) VALUES (?, ?, ?)",
1016
+ (name, ss58_address, note),
1017
+ )
1018
+ conn.commit()
1019
+
1020
+ @classmethod
1021
+ def update_entry(
1022
+ cls,
1023
+ conn: sqlite3.Connection,
1024
+ cursor: sqlite3.Cursor,
1025
+ *,
1026
+ name: str,
1027
+ ss58_address: Optional[str] = None,
1028
+ note: Optional[str] = None,
1029
+ ):
1030
+ cursor.execute(
1031
+ f"SELECT ss58_address, note FROM {cls.name} WHERE name = ?",
1032
+ (name,),
1033
+ )
1034
+ row = cursor.fetchone()
1035
+ ss58_address_ = ss58_address or row[0]
1036
+ note_ = note or row[1]
1037
+ conn.execute(
1038
+ f"UPDATE {cls.name} SET ss58_address = ?, note = ? WHERE name = ?",
1039
+ (ss58_address_, note_, name),
1040
+ )
1041
+ conn.commit()
1042
+
1043
+ @classmethod
1044
+ def delete_entry(
1045
+ cls, conn: sqlite3.Connection, cursor: sqlite3.Cursor, *, name: str
1046
+ ):
1047
+ conn.execute(
1048
+ f"DELETE FROM {cls.name} WHERE name = ?",
1049
+ (name,),
1050
+ )
1051
+ conn.commit()
1052
+
1053
+
1054
+ class ProxyAddressBook(TableDefinition):
1055
+ name = "proxy_address_book"
1056
+ cols = (
1057
+ ("name", "TEXT"),
1058
+ ("ss58_address", "TEXT"),
1059
+ ("delay", "INTEGER"),
1060
+ ("spawner", "TEXT"),
1061
+ ("proxy_type", "TEXT"),
1062
+ ("note", "TEXT"),
1063
+ )
1064
+
1065
+ @classmethod
1066
+ def update_entry(
1067
+ cls,
1068
+ conn: sqlite3.Connection,
1069
+ cursor: sqlite3.Cursor,
1070
+ *,
1071
+ name: str,
1072
+ ss58_address: Optional[str] = None,
1073
+ delay: Optional[int] = None,
1074
+ spawner: Optional[str] = None,
1075
+ proxy_type: Optional[str] = None,
1076
+ note: Optional[str] = None,
1077
+ ) -> None:
1078
+ cursor.execute(
1079
+ f"SELECT ss58_address, spawner, proxy_type, delay, note FROM {cls.name} WHERE name = ?",
1080
+ (name,),
1081
+ )
1082
+ row = cursor.fetchone()
1083
+ ss58_address_ = ss58_address or row[0]
1084
+ spawner_ = spawner or row[1]
1085
+ proxy_type_ = proxy_type or row[2]
1086
+ delay = delay if delay is not None else row[3]
1087
+ note_ = note or row[4]
1088
+ conn.execute(
1089
+ f"UPDATE {cls.name} SET ss58_address = ?, spawner = ?, proxy_type = ?, delay = ?, note = ? WHERE name = ?",
1090
+ (ss58_address_, spawner_, proxy_type_, delay, note_, name),
1091
+ )
1092
+ conn.commit()
1093
+
1094
+ @classmethod
1095
+ def add_entry(
1096
+ cls,
1097
+ conn: sqlite3.Connection,
1098
+ _: sqlite3.Cursor,
1099
+ *,
1100
+ name: str,
1101
+ ss58_address: str,
1102
+ delay: int,
1103
+ spawner: str,
1104
+ proxy_type: str,
1105
+ note: str,
1106
+ ) -> None:
1107
+ conn.execute(
1108
+ f"INSERT INTO {cls.name} (name, ss58_address, delay, spawner, proxy_type, note) VALUES (?, ?, ?, ?, ?, ?)",
1109
+ (name, ss58_address, delay, spawner, proxy_type, note),
1110
+ )
1111
+ conn.commit()
1112
+
1113
+ @classmethod
1114
+ def delete_entry(
1115
+ cls,
1116
+ conn: sqlite3.Connection,
1117
+ _: sqlite3.Cursor,
1118
+ *,
1119
+ name: str,
1120
+ ):
1121
+ conn.execute(
1122
+ f"DELETE FROM {cls.name} WHERE name = ?",
1123
+ (name,),
1124
+ )
1125
+ conn.commit()
1126
+
1127
+
1128
+ class ProxyAnnouncements(TableDefinition):
1129
+ name = "proxy_announcements"
1130
+ cols = (
1131
+ ("id", "INTEGER PRIMARY KEY"),
1132
+ ("address", "TEXT"),
1133
+ ("epoch_time", "INTEGER"),
1134
+ ("block", "INTEGER"),
1135
+ ("call_hash", "TEXT"),
1136
+ ("call", "TEXT"),
1137
+ ("call_serialized", "TEXT"),
1138
+ ("executed", "INTEGER"),
1139
+ )
1140
+
1141
+ @classmethod
1142
+ def add_entry(
1143
+ cls,
1144
+ conn: sqlite3.Connection,
1145
+ _: sqlite3.Cursor,
1146
+ *,
1147
+ address: str,
1148
+ epoch_time: int,
1149
+ block: int,
1150
+ call_hash: str,
1151
+ call: GenericCall,
1152
+ executed: bool = False,
1153
+ ) -> None:
1154
+ call_hex = call.data.to_hex()
1155
+ call_serialized = json.dumps(call.serialize())
1156
+ executed_int = int(executed)
1157
+ conn.execute(
1158
+ f"INSERT INTO {cls.name} (address, epoch_time, block, call_hash, call, call_serialized, executed)"
1159
+ " VALUES (?, ?, ?, ?, ?, ?, ?)",
1160
+ (
1161
+ address,
1162
+ epoch_time,
1163
+ block,
1164
+ call_hash,
1165
+ call_hex,
1166
+ call_serialized,
1167
+ executed_int,
1168
+ ),
1169
+ )
1170
+ conn.commit()
1171
+
1172
+ @classmethod
1173
+ def delete_entry(
1174
+ cls,
1175
+ conn: sqlite3.Connection,
1176
+ _: sqlite3.Cursor,
1177
+ *,
1178
+ address: str,
1179
+ epoch_time: int,
1180
+ block: int,
1181
+ call_hash: str,
1182
+ ):
1183
+ conn.execute(
1184
+ f"DELETE FROM {cls.name} WHERE call_hash = ? AND address = ? AND epoch_time = ? AND block = ?",
1185
+ (call_hash, address, epoch_time, block),
1186
+ )
1187
+ conn.commit()
1188
+
1189
+ @classmethod
1190
+ def mark_as_executed(cls, conn: sqlite3.Connection, _: sqlite3.Cursor, idx: int):
1191
+ conn.execute(
1192
+ f"UPDATE {cls.name} SET executed = ? WHERE id = ?",
1193
+ (1, idx),
1194
+ )
1195
+ conn.commit()
1196
+
1197
+
1198
+ class DB:
1199
+ """
1200
+ For ease of interaction with the SQLite database used for --reuse-last and --html outputs of tables
1201
+
1202
+ Also for address book
1203
+ """
1204
+
1205
+ def __init__(
1206
+ self,
1207
+ db_path: Optional[str] = None,
1208
+ row_factory=None,
1209
+ ):
1210
+ if db_path is None:
1211
+ if path_from_env := os.getenv("BTCLI_PROXIES_PATH"):
1212
+ db_path = path_from_env
1213
+ else:
1214
+ db_path = os.path.join(
1215
+ os.path.expanduser(defaults.config.base_path), "meshtensor.db"
1216
+ )
1217
+ self.db_path = db_path
1218
+ self.conn: Optional[sqlite3.Connection] = None
1219
+ self.row_factory = row_factory
1220
+
1221
+ def __enter__(self):
1222
+ self.conn = sqlite3.connect(self.db_path)
1223
+ self.conn.row_factory = self.row_factory
1224
+ return self.conn, self.conn.cursor()
1225
+
1226
+ def __exit__(self, exc_type, exc_val, exc_tb):
1227
+ if self.conn:
1228
+ self.conn.close()
1229
+
1230
+
1231
+ def create_and_populate_table(
1232
+ title: str, columns: list[tuple[str, str]], rows: list[list]
1233
+ ) -> None:
1234
+ """
1235
+ Creates and populates the rows of a table in the SQLite database.
1236
+
1237
+ Warning:
1238
+ Will overwrite the existing table.
1239
+
1240
+ :param title: title of the table
1241
+ :param columns: [(column name, column type), ...]
1242
+ :param rows: [[element, element, ...], ...]
1243
+ :return: None
1244
+ """
1245
+ blob_cols = []
1246
+ for idx, (_, col_type) in enumerate(columns):
1247
+ if col_type == "BLOB":
1248
+ blob_cols.append(idx)
1249
+ if blob_cols:
1250
+ for row in rows:
1251
+ for idx in blob_cols:
1252
+ row[idx] = row[idx].to_bytes(row[idx].bit_length() + 7, byteorder="big")
1253
+ with DB() as (conn, cursor):
1254
+ drop_query = f"DROP TABLE IF EXISTS {title}"
1255
+ cursor.execute(drop_query)
1256
+ conn.commit()
1257
+ columns_ = ", ".join([" ".join(x) for x in columns])
1258
+ creation_query = f"CREATE TABLE IF NOT EXISTS {title} ({columns_})"
1259
+ conn.execute(creation_query)
1260
+ conn.commit()
1261
+ query = f"INSERT INTO {title} ({', '.join([x[0] for x in columns])}) VALUES ({', '.join(['?'] * len(columns))})"
1262
+ cursor.executemany(query, rows)
1263
+ conn.commit()
1264
+ return
1265
+
1266
+
1267
+ def read_table(table_name: str, order_by: str = "") -> tuple[list, list]:
1268
+ """
1269
+ Reads a table from a SQLite database, returning back a column names and rows as a tuple
1270
+ :param table_name: the table name in the database
1271
+ :param order_by: the order of the columns in the table, optional
1272
+ :return: ([column names], [rows])
1273
+ """
1274
+ with DB() as (conn, cursor):
1275
+ cursor.execute(f"PRAGMA table_info({table_name})")
1276
+ columns_info = cursor.fetchall()
1277
+ column_names = [info[1] for info in columns_info]
1278
+ column_types = [info[2] for info in columns_info]
1279
+ cursor.execute(f"SELECT * FROM {table_name} {order_by}")
1280
+ rows = cursor.fetchall()
1281
+ blob_cols = []
1282
+ for idx, col_type in enumerate(column_types):
1283
+ if col_type == "BLOB":
1284
+ blob_cols.append(idx)
1285
+ if blob_cols:
1286
+ rows = [list(row) for row in rows]
1287
+ for row in rows:
1288
+ for idx in blob_cols:
1289
+ row[idx] = int.from_bytes(row[idx], byteorder="big")
1290
+ return column_names, rows
1291
+
1292
+
1293
+ def update_metadata_table(table_name: str, values: dict[str, str]) -> None:
1294
+ """
1295
+ Used for updating the metadata for storing a table. This includes items like total_neurons, etc.
1296
+ :param table_name: the name of the table you're referencing inside of the metadata table (this is generally
1297
+ going to be the same as the table for which you have rows.)
1298
+ :param values: {key: value} dict for items you wish to insert
1299
+ :return: None
1300
+ """
1301
+ with DB() as (conn, cursor):
1302
+ cursor.execute(
1303
+ "CREATE TABLE IF NOT EXISTS metadata (TableName TEXT, Key TEXT, Value TEXT)"
1304
+ )
1305
+ conn.commit()
1306
+ for key, value in values.items():
1307
+ cursor.execute(
1308
+ "UPDATE metadata SET Value = ? WHERE Key = ? AND TableName = ?",
1309
+ (value, key, table_name),
1310
+ )
1311
+ conn.commit()
1312
+ if cursor.rowcount == 0:
1313
+ cursor.execute(
1314
+ "INSERT INTO metadata (TableName, Key, Value) VALUES (?, ?, ?)",
1315
+ (table_name, key, value),
1316
+ )
1317
+ conn.commit()
1318
+ return
1319
+
1320
+
1321
+ def get_metadata_table(table_name: str) -> dict[str, str]:
1322
+ """
1323
+ Retrieves the metadata dict for the specified table.
1324
+ :param table_name: Table name within the metadata table.
1325
+ :return: {key: value} dict for metadata items.
1326
+ """
1327
+ with DB() as (conn, cursor):
1328
+ cursor.execute(
1329
+ "SELECT Key, Value FROM metadata WHERE TableName = ?", (table_name,)
1330
+ )
1331
+ data = cursor.fetchall()
1332
+ return dict(data)
1333
+
1334
+
1335
+ def render_table(table_name: str, table_info: str, columns: list[dict], show=True):
1336
+ """
1337
+ Renders the table to HTML, and displays it in the browser
1338
+ :param table_name: The table name in the database
1339
+ :param table_info: Think of this like a subtitle
1340
+ :param columns: list of dicts that conform to Tabulator's expected columns format
1341
+ :param show: whether to open a browser window with the rendered table HTML
1342
+ :return: None
1343
+ """
1344
+ db_cols, rows = read_table(table_name)
1345
+ template_dir = os.path.join(os.path.dirname(__file__), "templates")
1346
+ with open(os.path.join(template_dir, "table.j2"), "r") as f:
1347
+ template = Template(f.read())
1348
+ rendered = template.render(
1349
+ title=table_name,
1350
+ columns=Markup(columns),
1351
+ rows=Markup([{c: v for (c, v) in zip(db_cols, r)} for r in rows]),
1352
+ column_names=db_cols,
1353
+ table_info=table_info,
1354
+ tree=False,
1355
+ )
1356
+ output_file = "/tmp/meshtensor_table.html"
1357
+ with open(output_file, "w+") as f:
1358
+ f.write(rendered)
1359
+ if show:
1360
+ webbrowser.open(f"file://{output_file}")
1361
+
1362
+
1363
+ def render_tree(
1364
+ table_name: str,
1365
+ table_info: str,
1366
+ columns: list[dict],
1367
+ parent_column: int = 0,
1368
+ show=True,
1369
+ ):
1370
+ """
1371
+ Largely the same as render_table, but this renders the table with nested data.
1372
+ This is done by a table looking like: (FOO ANY, BAR ANY, BAZ ANY, CHILD INTEGER)
1373
+ where CHILD is 0 or 1, determining if the row should be treated as a child of another row.
1374
+ The parent and child rows should contain same value for the given parent_column
1375
+
1376
+ E.g. Let's say you have rows as such:
1377
+ (COLDKEY TEXT, BALANCE REAL, STAKE REAL, CHILD INTEGER)
1378
+ ("5GTjidas", 1.0, 0.0, 0)
1379
+ ("5GTjidas", 0.0, 1.0, 1)
1380
+ ("DJIDSkod", 1.0, 0.0, 0)
1381
+
1382
+ This will be rendered as:
1383
+ Coldkey | Balance | Stake
1384
+ 5GTjidas | 1.0 | 0.0
1385
+ └ | 0.0 | 1.0
1386
+ DJIDSkod | 1.0 | 0.0
1387
+
1388
+ :param table_name: The table name in the database
1389
+ :param table_info: Think of this like a subtitle
1390
+ :param columns: list of dicts that conform to Tabulator's expected columns format
1391
+ :param parent_column: the index of the column to use as for parent reference
1392
+ :param show: whether to open a browser window with the rendered table HTML
1393
+ :return: None
1394
+ """
1395
+ db_cols, rows = read_table(table_name, "ORDER BY CHILD ASC")
1396
+ template_dir = os.path.join(os.path.dirname(__file__), "templates")
1397
+ result = []
1398
+ parent_dicts = {}
1399
+ for row in rows:
1400
+ row_dict = {c: v for (c, v) in zip(db_cols, row)}
1401
+ child = row_dict["CHILD"]
1402
+ del row_dict["CHILD"]
1403
+ if child == 0:
1404
+ row_dict["_children"] = []
1405
+ result.append(row_dict)
1406
+ parent_dicts[row_dict[db_cols[parent_column]]] = (
1407
+ row_dict # Reference to row obj
1408
+ )
1409
+ elif child == 1:
1410
+ parent_key = row[parent_column]
1411
+ row_dict[db_cols[parent_column]] = None
1412
+ if parent_key in parent_dicts:
1413
+ parent_dicts[parent_key]["_children"].append(row_dict)
1414
+ with open(os.path.join(template_dir, "table.j2"), "r") as f:
1415
+ template = Template(f.read())
1416
+ rendered = template.render(
1417
+ title=table_name,
1418
+ columns=Markup(columns),
1419
+ rows=Markup(result),
1420
+ column_names=db_cols,
1421
+ table_info=table_info,
1422
+ tree=True,
1423
+ )
1424
+ output_file = "/tmp/meshtensor_table.html"
1425
+ with open(output_file, "w+") as f:
1426
+ f.write(rendered)
1427
+ if show:
1428
+ webbrowser.open(f"file://{output_file}")
1429
+
1430
+
1431
+ def ensure_address_book_tables_exist():
1432
+ """
1433
+ Creates address book tables if they don't exist.
1434
+
1435
+ Should be run at startup to ensure that the address book tables exist.
1436
+ """
1437
+ with DB() as (conn, cursor):
1438
+ for table in (AddressBook, ProxyAddressBook, ProxyAnnouncements):
1439
+ table.create_if_not_exists(conn, cursor)
1440
+
1441
+
1442
+ def group_subnets(registrations):
1443
+ if not registrations:
1444
+ return ""
1445
+
1446
+ ranges = []
1447
+ start = registrations[0]
1448
+
1449
+ for i in range(1, len(registrations)):
1450
+ if registrations[i] != registrations[i - 1] + 1:
1451
+ # Append the current range or single number
1452
+ if start == registrations[i - 1]:
1453
+ ranges.append(str(start))
1454
+ else:
1455
+ ranges.append(f"{start}-{registrations[i - 1]}")
1456
+ start = registrations[i]
1457
+
1458
+ # Append the final range or single number
1459
+ if start == registrations[-1]:
1460
+ ranges.append(str(start))
1461
+ else:
1462
+ ranges.append(f"{start}-{registrations[-1]}")
1463
+
1464
+ return ", ".join(ranges)
1465
+
1466
+
1467
+ def parse_subnet_range(input_str: str, total_subnets: int) -> list[int]:
1468
+ """
1469
+ Parse subnet range input like "1-24, 30-40, 5".
1470
+
1471
+ Args:
1472
+ input_str: Comma-separated list of subnets and ranges
1473
+ Examples: "1-5", "1,2,3", "1-5, 10, 20-25"
1474
+ total_subnets: Total number of subnets available
1475
+
1476
+ Returns:
1477
+ Sorted list of unique subnet IDs
1478
+
1479
+ Raises:
1480
+ ValueError: If input format is invalid
1481
+
1482
+ Examples:
1483
+ >>> parse_subnet_range("1-5, 10")
1484
+ [1, 2, 3, 4, 5, 10]
1485
+ >>> parse_subnet_range("5, 3, 1")
1486
+ [1, 3, 5]
1487
+ """
1488
+ subnets = set()
1489
+ parts = [p.strip() for p in input_str.split(",") if p.strip()]
1490
+ for part in parts:
1491
+ if "-" in part:
1492
+ try:
1493
+ start, end = part.split("-", 1)
1494
+ start_num = int(start.strip())
1495
+ end_num = int(end.strip())
1496
+
1497
+ if start_num > end_num:
1498
+ raise ValueError(f"Invalid range '{part}': start must be ≤ end")
1499
+
1500
+ if end_num - start_num > total_subnets:
1501
+ raise ValueError(
1502
+ f"Range '{part}' is not valid (total of {total_subnets} subnets)"
1503
+ )
1504
+
1505
+ subnets.update(range(start_num, end_num + 1))
1506
+ except ValueError as e:
1507
+ if "invalid literal" in str(e):
1508
+ raise ValueError(f"Invalid range '{part}': must be 'start-end'")
1509
+ raise
1510
+ else:
1511
+ try:
1512
+ subnets.add(int(part))
1513
+ except ValueError:
1514
+ raise ValueError(f"Invalid subnet ID '{part}': must be a number")
1515
+
1516
+ return sorted(subnets)
1517
+
1518
+
1519
+ def validate_chain_endpoint(endpoint_url) -> tuple[bool, str]:
1520
+ parsed = urlparse(endpoint_url)
1521
+ if parsed.scheme not in ("ws", "wss"):
1522
+ return False, (
1523
+ f"Invalid URL or network name provided: [bright_cyan]({endpoint_url})[/bright_cyan].\n"
1524
+ "Allowed network names are [bright_cyan]finney, test, local[/bright_cyan]. "
1525
+ "Valid chain endpoints should use the scheme [bright_cyan]`ws` or `wss`[/bright_cyan].\n"
1526
+ )
1527
+ if not parsed.netloc:
1528
+ return False, "Invalid URL passed as the endpoint"
1529
+ return True, ""
1530
+
1531
+
1532
+ def retry_prompt(
1533
+ helper_text: str,
1534
+ rejection: Callable,
1535
+ rejection_text: str,
1536
+ default="",
1537
+ show_default=False,
1538
+ prompt_type: "PromptBase.ask" = Prompt.ask,
1539
+ ):
1540
+ """
1541
+ Allows for asking prompts again if they do not meet a certain criteria (as defined in `rejection`)
1542
+ Args:
1543
+ helper_text: The helper text to display for the prompt
1544
+ rejection: A function that returns True if the input should be rejected, and False if it should be accepted
1545
+ rejection_text: The text to display to the user if their input hits the rejection
1546
+ default: the default value to use for the prompt, default ""
1547
+ show_default: whether to show the default, default False
1548
+ prompt_type: the type of prompt, default `typer.prompt`
1549
+
1550
+ Returns: the input value (or default)
1551
+
1552
+ """
1553
+ while True:
1554
+ var = prompt_type(helper_text, default=default, show_default=show_default)
1555
+ if not rejection(var):
1556
+ return var
1557
+ else:
1558
+ print_error(rejection_text)
1559
+
1560
+
1561
+ def validate_netuid(value: int) -> int:
1562
+ if value is not None and value < 0:
1563
+ raise typer.BadParameter("Negative netuid passed. Please use correct netuid.")
1564
+ return value
1565
+
1566
+
1567
+ def validate_uri(uri: str) -> str:
1568
+ if not uri:
1569
+ return None
1570
+ clean_uri = uri.lstrip("/").lower()
1571
+ if not clean_uri.isalnum():
1572
+ raise typer.BadParameter(
1573
+ f"Invalid URI format: {uri}. URI must contain only alphanumeric characters (e.g. 'alice', 'bob')"
1574
+ )
1575
+ return f"//{clean_uri.capitalize()}"
1576
+
1577
+
1578
+ def get_effective_network(config, network: Optional[list[str]]) -> str:
1579
+ """
1580
+ Determines the effective network to be used, considering the network parameter,
1581
+ the configuration, and the default.
1582
+ """
1583
+ if network:
1584
+ network_ = ""
1585
+ for item in network:
1586
+ if item.startswith("ws"):
1587
+ network_ = item
1588
+ break
1589
+ else:
1590
+ network_ = item
1591
+ return network_
1592
+ elif config.get("network"):
1593
+ return config["network"]
1594
+ else:
1595
+ return defaults.meshtensor.network
1596
+
1597
+
1598
+ def is_meshlet_network(network: str) -> bool:
1599
+ """Check if the given network is 'meshlet'."""
1600
+ network = network.lower()
1601
+ meshlet_identifiers = [
1602
+ "meshlet",
1603
+ Constants.meshlet_entrypoint,
1604
+ ]
1605
+ return (
1606
+ network == "meshlet"
1607
+ or network in meshlet_identifiers
1608
+ or "meshlet.chain.opentensor.ai" in network
1609
+ )
1610
+
1611
+
1612
+ def prompt_for_identity(
1613
+ current_identity: dict,
1614
+ name: Optional[str],
1615
+ web_url: Optional[str],
1616
+ image_url: Optional[str],
1617
+ discord: Optional[str],
1618
+ description: Optional[str],
1619
+ additional: Optional[str],
1620
+ github_repo: Optional[str],
1621
+ ):
1622
+ """
1623
+ Prompts the user for identity fields with validation.
1624
+ Returns a dictionary with the updated fields.
1625
+ """
1626
+ identity_fields = {}
1627
+
1628
+ fields = [
1629
+ ("name", "[blue]Display name[/blue]", name, 256),
1630
+ ("url", "[blue]Web URL[/blue]", web_url, 256),
1631
+ ("image", "[blue]Image URL[/blue]", image_url, 1024),
1632
+ ("discord", "[blue]Discord handle[/blue]", discord, 256),
1633
+ ("description", "[blue]Description[/blue]", description, 1024),
1634
+ ("additional", "[blue]Additional information[/blue]", additional, 1024),
1635
+ ("github_repo", "[blue]GitHub repository URL[/blue]", github_repo, 256),
1636
+ ]
1637
+
1638
+ if not any(
1639
+ [name, web_url, image_url, discord, description, additional, github_repo]
1640
+ ):
1641
+ console.print(
1642
+ "\n[yellow]All fields are optional. Press Enter to skip and keep the default/existing value.[/yellow]\n"
1643
+ "[dark_sea_green3]Tip: Entering a space and pressing Enter will clear existing default value.\n"
1644
+ )
1645
+
1646
+ for key, prompt, value, byte_limit in fields:
1647
+ text_rejection = partial(
1648
+ retry_prompt,
1649
+ rejection=lambda x: len(x.encode("utf-8")) > byte_limit,
1650
+ rejection_text=f"[red]Error:[/red] {key} field must be <= {byte_limit} bytes.",
1651
+ )
1652
+
1653
+ if value:
1654
+ identity_fields[key] = value
1655
+ else:
1656
+ identity_fields[key] = text_rejection(
1657
+ prompt,
1658
+ default=current_identity.get(key, ""),
1659
+ show_default=True,
1660
+ )
1661
+
1662
+ return identity_fields
1663
+
1664
+
1665
+ def prompt_for_subnet_identity(
1666
+ current_identity: dict,
1667
+ subnet_name: Optional[str],
1668
+ github_repo: Optional[str],
1669
+ subnet_contact: Optional[str],
1670
+ subnet_url: Optional[str],
1671
+ discord: Optional[str],
1672
+ description: Optional[str],
1673
+ logo_url: Optional[str],
1674
+ additional: Optional[str],
1675
+ ):
1676
+ """
1677
+ Prompts the user for required subnet identity fields with validation.
1678
+ Returns a dictionary with the updated fields.
1679
+
1680
+ Args:
1681
+ subnet_name (Optional[str]): Name of the subnet
1682
+ github_repo (Optional[str]): GitHub repository URL
1683
+ subnet_contact (Optional[str]): Contact information for subnet (email)
1684
+
1685
+ Returns:
1686
+ dict: Dictionary containing the subnet identity fields
1687
+ """
1688
+ identity_fields = {}
1689
+
1690
+ fields = [
1691
+ (
1692
+ "subnet_name",
1693
+ "[blue]Subnet name [dim](optional)[/blue]",
1694
+ subnet_name,
1695
+ lambda x: x and len(x.encode("utf-8")) > 256,
1696
+ "[red]Error:[/red] Subnet name must be <= 256 bytes.",
1697
+ ),
1698
+ (
1699
+ "github_repo",
1700
+ "[blue]GitHub repository URL [dim](optional)[/blue]",
1701
+ github_repo,
1702
+ lambda x: x
1703
+ and (not is_valid_github_url(x) or len(x.encode("utf-8")) > 1024),
1704
+ "[red]Error:[/red] Please enter a valid GitHub repository URL (e.g., https://github.com/username/repo).",
1705
+ ),
1706
+ (
1707
+ "subnet_contact",
1708
+ "[blue]Contact email [dim](optional)[/blue]",
1709
+ subnet_contact,
1710
+ lambda x: x and (not is_valid_contact(x) or len(x.encode("utf-8")) > 1024),
1711
+ "[red]Error:[/red] Please enter a valid email address.",
1712
+ ),
1713
+ (
1714
+ "subnet_url",
1715
+ "[blue]Subnet URL [dim](optional)[/blue]",
1716
+ subnet_url,
1717
+ lambda x: x and len(x.encode("utf-8")) > 1024,
1718
+ "[red]Error:[/red] Please enter a valid URL <= 1024 bytes.",
1719
+ ),
1720
+ (
1721
+ "discord",
1722
+ "[blue]Discord handle [dim](optional)[/blue]",
1723
+ discord,
1724
+ lambda x: x and len(x.encode("utf-8")) > 256,
1725
+ "[red]Error:[/red] Please enter a valid Discord handle <= 256 bytes.",
1726
+ ),
1727
+ (
1728
+ "description",
1729
+ "[blue]Description [dim](optional)[/blue]",
1730
+ description,
1731
+ lambda x: x and len(x.encode("utf-8")) > 1024,
1732
+ "[red]Error:[/red] Description must be <= 1024 bytes.",
1733
+ ),
1734
+ (
1735
+ "logo_url",
1736
+ "[blue]Logo URL [dim](optional)[/blue]",
1737
+ logo_url,
1738
+ lambda x: x and len(x.encode("utf-8")) > 1024,
1739
+ "[red]Error:[/red] Logo URL must be <= 1024 bytes.",
1740
+ ),
1741
+ (
1742
+ "additional",
1743
+ "[blue]Additional information [dim](optional)[/blue]",
1744
+ additional,
1745
+ lambda x: x and len(x.encode("utf-8")) > 1024,
1746
+ "[red]Error:[/red] Additional information must be <= 1024 bytes.",
1747
+ ),
1748
+ ]
1749
+
1750
+ for key, prompt, value, rejection_func, rejection_msg in fields:
1751
+ if value:
1752
+ if rejection_func(value):
1753
+ raise ValueError(rejection_msg)
1754
+ identity_fields[key] = value
1755
+ else:
1756
+ identity_fields[key] = retry_prompt(
1757
+ prompt,
1758
+ rejection=rejection_func,
1759
+ rejection_text=rejection_msg,
1760
+ default=current_identity.get(key, ""),
1761
+ show_default=True,
1762
+ )
1763
+
1764
+ return identity_fields
1765
+
1766
+
1767
+ def is_valid_github_url(url: str) -> bool:
1768
+ """
1769
+ Validates if the provided URL is a valid GitHub repository URL.
1770
+
1771
+ Args:
1772
+ url (str): URL to validate
1773
+
1774
+ Returns:
1775
+ bool: True if valid GitHub repo URL, False otherwise
1776
+ """
1777
+ try:
1778
+ parsed = urlparse(url)
1779
+ if parsed.netloc != "github.com":
1780
+ return False
1781
+
1782
+ # Check path follows github.com/user/repo format
1783
+ path_parts = [p for p in parsed.path.split("/") if p]
1784
+ if len(path_parts) < 2: # Need at least username/repo
1785
+ return False
1786
+
1787
+ return True
1788
+ except Exception: # TODO figure out the exceptions that can be raised in here
1789
+ return False
1790
+
1791
+
1792
+ def is_valid_contact(contact: str) -> bool:
1793
+ """
1794
+ Validates if the provided contact is a valid email address.
1795
+
1796
+ Args:
1797
+ contact (str): Contact information to validate
1798
+
1799
+ Returns:
1800
+ bool: True if valid email, False otherwise
1801
+ """
1802
+ email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
1803
+ return bool(re.match(email_pattern, contact))
1804
+
1805
+
1806
+ def get_subnet_name(subnet_info, max_length: int = 20) -> str:
1807
+ """Get the subnet name, prioritizing subnet_identity.subnet_name over subnet.subnet_name.
1808
+ Truncates the name if it exceeds max_length.
1809
+
1810
+ Args:
1811
+ subnet_info: The subnet dynamic info
1812
+ max_length: Maximum length of the returned name. Names longer than this will be truncated with '...'
1813
+
1814
+ Returns:
1815
+ str: The subnet name (truncated if necessary) or empty string if no name is found
1816
+ """
1817
+ name = (
1818
+ subnet_info.subnet_identity.subnet_name
1819
+ if hasattr(subnet_info, "subnet_identity")
1820
+ and subnet_info.subnet_identity is not None
1821
+ and subnet_info.subnet_identity.subnet_name is not None
1822
+ else (subnet_info.subnet_name if subnet_info.subnet_name is not None else "")
1823
+ )
1824
+
1825
+ if len(name) > max_length:
1826
+ return name[: max_length - 3] + "..."
1827
+ return name
1828
+
1829
+
1830
+ def validate_rate_tolerance(value: Optional[float]) -> Optional[float]:
1831
+ """Validates rate tolerance input"""
1832
+ if value is not None:
1833
+ if value < 0:
1834
+ raise typer.BadParameter(
1835
+ "Rate tolerance cannot be negative (less than 0%)."
1836
+ )
1837
+ if value > 1:
1838
+ raise typer.BadParameter("Rate tolerance cannot be greater than 1 (100%).")
1839
+ if value > 0.5:
1840
+ console.print(
1841
+ f"[yellow]Warning: High rate tolerance of {value * 100}% specified. "
1842
+ "This may result in unfavorable transaction execution.[/yellow]"
1843
+ )
1844
+ return value
1845
+
1846
+
1847
+ def unlock_key(
1848
+ wallet: Wallet, unlock_type="cold", print_out: bool = True
1849
+ ) -> "UnlockStatus":
1850
+ """
1851
+ Attempts to decrypt a wallet's coldkey or hotkey
1852
+ Args:
1853
+ wallet: a Wallet object
1854
+ unlock_type: the key type, 'cold' or 'hot'
1855
+ print_out: whether to print out the error message to the err_console
1856
+
1857
+ Returns: UnlockStatus for success status of unlock, with error message if unsuccessful
1858
+
1859
+ """
1860
+ if unlock_type == "cold":
1861
+ unlocker = "unlock_coldkey"
1862
+ elif unlock_type == "hot":
1863
+ unlocker = "unlock_hotkey"
1864
+ else:
1865
+ raise ValueError(
1866
+ f"Invalid unlock type provided: {unlock_type}. Must be 'cold' or 'hot'."
1867
+ )
1868
+ try:
1869
+ getattr(wallet, unlocker)()
1870
+ return UnlockStatus(True, "")
1871
+ except PasswordError:
1872
+ err_msg = f"The password used to decrypt your {unlock_type.capitalize()}key Keyfile is invalid."
1873
+ if print_out:
1874
+ print_error(f"Failed: {err_msg}")
1875
+ return unlock_key(wallet, unlock_type, print_out)
1876
+ return UnlockStatus(False, err_msg)
1877
+ except KeyFileError:
1878
+ err_msg = f"{unlock_type.capitalize()}key Keyfile is corrupt, non-writable, or non-readable, or non-existent."
1879
+ if print_out:
1880
+ print_error(f"Failed: {err_msg}")
1881
+ return UnlockStatus(False, err_msg)
1882
+
1883
+
1884
+ def hex_to_bytes(hex_str: str) -> bytes:
1885
+ """
1886
+ Converts a hex-encoded string into bytes. Handles 0x-prefixed and non-prefixed hex-encoded strings.
1887
+ """
1888
+ if hex_str.startswith("0x"):
1889
+ bytes_result = bytes.fromhex(hex_str[2:])
1890
+ else:
1891
+ bytes_result = bytes.fromhex(hex_str)
1892
+ return bytes_result
1893
+
1894
+
1895
+ def blocks_to_duration(blocks: int) -> str:
1896
+ """Convert blocks to human readable duration string using two largest units.
1897
+
1898
+ Args:
1899
+ blocks (int): Number of blocks (12s per block)
1900
+
1901
+ Returns:
1902
+ str: Duration string like '2d 5h', '3h 45m', '2m 10s', or '0s'
1903
+ """
1904
+ if blocks <= 0:
1905
+ return "0s"
1906
+
1907
+ seconds = blocks * 12
1908
+ intervals = [
1909
+ ("d", 86400), # 60 * 60 * 24
1910
+ ("h", 3600), # 60 * 60
1911
+ ("m", 60),
1912
+ ("s", 1),
1913
+ ]
1914
+ results = []
1915
+ for unit, seconds_per_unit in intervals:
1916
+ unit_count = seconds // seconds_per_unit
1917
+ seconds %= seconds_per_unit
1918
+ if unit_count > 0:
1919
+ results.append(f"{unit_count}{unit}")
1920
+ # Return only the first two non-zero units
1921
+ return " ".join(results[:2]) or "0s"
1922
+
1923
+
1924
+ def get_hotkey_pub_ss58(wallet: Wallet) -> str:
1925
+ """
1926
+ Helper fn to retrieve the hotkeypub ss58 of a wallet that may have been created before
1927
+ bt-wallet 3.1.1 and thus not have a wallet hotkeypub. In this case, it will return the hotkey
1928
+ SS58.
1929
+ """
1930
+ try:
1931
+ return wallet.hotkeypub.ss58_address
1932
+ except (KeyFileError, AttributeError):
1933
+ return wallet.hotkey.ss58_address
1934
+
1935
+
1936
+ def get_netuid_and_subuid_by_storage_index(storage_index: int) -> tuple[int, int]:
1937
+ """Returns the netuid and subuid from the storage index.
1938
+
1939
+ Chain APIs (e.g., SubMetagraph response) returns netuid which is storage index that encodes both the netuid and
1940
+ subuid. This function reverses the encoding to extract these components.
1941
+
1942
+ Parameters:
1943
+ storage_index: The storage index of the subnet.
1944
+
1945
+ Returns:
1946
+ tuple[int, int]:
1947
+ - netuid subnet identifier.
1948
+ - subuid identifier.
1949
+ """
1950
+ return (
1951
+ storage_index % GLOBAL_MAX_SUBNET_COUNT,
1952
+ storage_index // GLOBAL_MAX_SUBNET_COUNT,
1953
+ )
1954
+
1955
+
1956
+ async def print_extrinsic_id(
1957
+ extrinsic_receipt: Optional[AsyncExtrinsicReceipt],
1958
+ ) -> None:
1959
+ """
1960
+ Prints the extrinsic identifier to the console. If the substrate attached to the extrinsic receipt is on a finney
1961
+ node, it will also include a link to browse the extrinsic in mesh dot app.
1962
+ Args:
1963
+ extrinsic_receipt: AsyncExtrinsicReceipt object from a successful extrinsic submission.
1964
+ """
1965
+ if extrinsic_receipt is None or not (await extrinsic_receipt.is_success):
1966
+ return
1967
+ substrate = extrinsic_receipt.substrate
1968
+ ext_id = await extrinsic_receipt.get_extrinsic_identifier()
1969
+ if substrate:
1970
+ query = await substrate.rpc_request("system_chainType", [])
1971
+ if query.get("result") == "Live":
1972
+ console.print(
1973
+ f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}: "
1974
+ f"[blue]https://tao.app/extrinsic/{ext_id}[/blue]"
1975
+ )
1976
+ return
1977
+ console.print(
1978
+ f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}"
1979
+ )
1980
+ return
1981
+
1982
+
1983
+ async def check_img_mimetype(img_url: str) -> tuple[bool, str, str]:
1984
+ """
1985
+ Checks to see if the given URL is an image, as defined by its mimetype.
1986
+
1987
+ Args:
1988
+ img_url: the URL to check
1989
+
1990
+ Returns:
1991
+ tuple:
1992
+ bool: True if the URL has a MIME type indicating image (e.g. 'image/...'), False otherwise.
1993
+ str: MIME type of the URL.
1994
+ str: error message if the URL could not be retrieved
1995
+
1996
+ """
1997
+ async with aiohttp.ClientSession() as session:
1998
+ try:
1999
+ async with session.get(img_url) as response:
2000
+ if response.status != 200:
2001
+ return False, "", "Could not fetch image"
2002
+ elif "image/" not in response.content_type:
2003
+ return False, response.content_type, ""
2004
+ else:
2005
+ return True, response.content_type, ""
2006
+ except aiohttp.ClientError:
2007
+ return False, "", "Could not fetch image"