diff --git a/.github/workflows/e2e-subtensor-tests.yml b/.github/workflows/e2e-subtensor-tests.yml index ab7f7145..1c625180 100644 --- a/.github/workflows/e2e-subtensor-tests.yml +++ b/.github/workflows/e2e-subtensor-tests.yml @@ -61,7 +61,7 @@ jobs: - name: Setup subtensor repo working-directory: ${{ github.workspace }}/subtensor - run: git checkout main + run: git checkout testnet - name: Install Python dependencies run: python3 -m pip install -e . pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ea2f7c..3af5bada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 9.0.0 /2025-02-13 + +## What's Changed +* Btcli ported to Rao by @ibraheem-opentensor & @thewhaleking in https://github.com/opentensor/btcli/tree/rao-games/pools +* fix netuid from str to int by @roman-opentensor in https://github.com/opentensor/btcli/pull/195 +* add runtime apis to reg by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/196 +* Updated tables (st list, s list) by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/200 +* Modifying descriptions and links in stake and subnets dot py files by @rajkaramchedu in https://github.com/opentensor/btcli/pull/246 +* Fixes Identity Lookup (Rao Games Pools) by @thewhaleking in https://github.com/opentensor/btcli/pull/279 +* Show encrypted hotkeys in w list by @thewhaleking in https://github.com/opentensor/btcli/pull/288 +* Backmerge rao branch to decoding branch by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/290 +* Updates identity, sn identity, and other chain stuff by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/292 +* Updates Rao to decode using chain by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/286 +* Fix/rao remove mention of cost by @camfairchild in https://github.com/opentensor/btcli/pull/293 +* Uses uvloop if it's installed by @thewhaleking in https://github.com/opentensor/btcli/pull/294 +* Feat: Safe staking by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/299 +* Removes stake from w balances by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/301 +* Updates docstrings for commands by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/303 +* Release/9.0.0rc4 by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/306 +* Rao to staging merge (new branch) by @thewhaleking in https://github.com/opentensor/btcli/pull/305 +* [WIP] Rao by @thewhaleking in https://github.com/opentensor/btcli/pull/129 +* Updates e2e tests for rao by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/307 +* Update dividends, adds sort by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/308 +* Final cleanups for Rao by @ibraheem-opentensor in https://github.com/opentensor/btcli/pull/309 + +## New Contributors +* @camfairchild made their first contribution in https://github.com/opentensor/btcli/pull/293 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v8.4.4...v9.0.0 + ## 8.4.4 /2025-02-07 - 18:30 PST ## What's Changed diff --git a/README.md b/README.md index bd71dfe5..1d8fcc0d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@
# Bittensor CLI +### Rao Development Version [![Discord Chat](https://img.shields.io/discord/308323056592486420.svg)](https://discord.gg/bittensor) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![PyPI version](https://badge.fury.io/py/bittensor_cli.svg)](https://badge.fury.io/py/bittensor_cli) diff --git a/bittensor_cli/__init__.py b/bittensor_cli/__init__.py index 226e8787..55ce167e 100644 --- a/bittensor_cli/__init__.py +++ b/bittensor_cli/__init__.py @@ -18,6 +18,6 @@ from .cli import CLIManager -__version__ = "8.4.4" +__version__ = "9.0.0" __all__ = ["CLIManager", "__version__"] diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 1d70a22e..66aa4036 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 import asyncio -import binascii import curses -from functools import partial +import importlib import os.path import re import ssl @@ -19,21 +18,28 @@ from rich import box from rich.prompt import Confirm, FloatPrompt, Prompt, IntPrompt from rich.table import Column, Table +from rich.tree import Tree from bittensor_cli.src import ( defaults, HELP_PANELS, WalletOptions as WO, WalletValidationTypes as WV, Constants, + COLOR_PALETTE, ) from bittensor_cli.src.bittensor import utils from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.bittensor.async_substrate_interface import ( - SubstrateRequestException, -) -from bittensor_cli.src.commands import root, subnets, sudo, wallets +from async_substrate_interface.errors import SubstrateRequestException +from bittensor_cli.src.commands import sudo, wallets from bittensor_cli.src.commands import weights as weights_cmds -from bittensor_cli.src.commands.stake import children_hotkeys, stake +from bittensor_cli.src.commands.subnets import price, subnets +from bittensor_cli.src.commands.stake import ( + children_hotkeys, + list as list_stake, + move as move_stake, + add as add_stake, + remove as remove_stake, +) from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters from bittensor_cli.src.bittensor.utils import ( @@ -43,22 +49,30 @@ is_valid_ss58_address, print_error, validate_chain_endpoint, - retry_prompt, + validate_netuid, + is_rao_network, + get_effective_network, + prompt_for_identity, + validate_uri, + prompt_for_subnet_identity, + print_linux_dependency_message, + is_linux, + validate_rate_tolerance, ) from typing_extensions import Annotated -from textwrap import dedent -from websockets import ConnectionClosed +from websockets import ConnectionClosed, InvalidHandshake from yaml import safe_dump, safe_load try: from git import Repo, GitError except ImportError: + Repo = None class GitError(Exception): pass -__version__ = "8.4.4" +__version__ = "9.0.0" _core_version = re.match(r"^\d+\.\d+\.\d+", __version__).group(0) @@ -155,8 +169,19 @@ class Options: ) netuid = typer.Option( None, - help="The netuid of the subnet in the root network, (e.g. 1).", + help="The netuid of the subnet in the network, (e.g. 1).", prompt=True, + callback=validate_netuid, + ) + netuid_not_req = typer.Option( + None, + help="The netuid of the subnet in the network, (e.g. 1).", + prompt=False, + ) + all_netuids = typer.Option( + False, + help="Use all netuids", + prompt=False, ) weights = typer.Option( None, @@ -202,6 +227,39 @@ class Options: "--quiet", help="Display only critical information on the console.", ) + live = typer.Option( + False, + "--live", + help="Display live view of the table", + ) + uri = typer.Option( + None, + "--uri", + help="Create wallet from uri (e.g. 'Alice', 'Bob', 'Charlie', 'Dave', 'Eve')", + callback=validate_uri, + ) + rate_tolerance = typer.Option( + None, + "--slippage", + "--slippage-tolerance", + "--tolerance", + help="Set the rate tolerance percentage for transactions (default: 0.05%).", + callback=validate_rate_tolerance, + ) + safe_staking = typer.Option( + None, + "--safe-staking/--no-safe-staking", + "--safe/--unsafe", + help="Enable or disable safe staking mode (default: enabled).", + ) + allow_partial_stake = typer.Option( + None, + "--allow-partial-stake/--no-allow-partial-stake", + "--partial/--no-partial", + "--allow/--not-allow", + "--allow-partial/--not-partial", + help="Enable or disable partial stake mode (default: disabled).", + ) def list_prompt(init_var: list, list_type: type, help_text: str) -> list: @@ -244,7 +302,8 @@ def parse_to_list( def verbosity_console_handler(verbosity_level: int = 1) -> None: """ Sets verbosity level of console output - :param verbosity_level: int corresponding to verbosity level of console output (0 is quiet, 1 is normal, 2 is verbose) + :param verbosity_level: int corresponding to verbosity level of console output (0 is quiet, 1 is normal, 2 is + verbose) """ if verbosity_level not in range(3): raise ValueError( @@ -264,6 +323,32 @@ def verbosity_console_handler(verbosity_level: int = 1) -> None: verbose_console.quiet = False +def get_optional_netuid(netuid: Optional[int], all_netuids: bool) -> Optional[int]: + """ + Parses options to determine if the user wants to use a specific netuid or all netuids (None) + + Returns: + None if using all netuids, otherwise int for the netuid to use + """ + if netuid is None and all_netuids is True: + return None + elif netuid is None and all_netuids is False: + answer = Prompt.ask( + f"Enter the [{COLOR_PALETTE['GENERAL']['SUBHEADING_MAIN']}]netuid" + f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING_MAIN']}] to use. Leave blank for all netuids", + default=None, + show_default=False, + ) + if answer is None: + return None + if answer.lower() == "all": + return None + else: + return int(answer) + else: + return netuid + + def get_n_words(n_words: Optional[int]) -> int: """ Prompts the user to select the number of words used in the mnemonic if not supplied or not within the @@ -413,12 +498,21 @@ def version_callback(value: bool): raise typer.Exit() +def commands_callback(value: bool): + """ + Prints a tree of commands for the app + """ + if value: + cli = CLIManager() + console.print(cli.generate_command_tree()) + raise typer.Exit() + + class CLIManager: """ :var app: the main CLI Typer app :var config_app: the Typer app as it relates to config commands :var wallet_app: the Typer app as it relates to wallet commands - :var root_app: the Typer app as it relates to root commands :var stake_app: the Typer app as it relates to stake commands :var sudo_app: the Typer app as it relates to sudo commands :var subnets_app: the Typer app as it relates to subnets commands @@ -429,10 +523,10 @@ class CLIManager: app: typer.Typer config_app: typer.Typer wallet_app: typer.Typer - root_app: typer.Typer subnets_app: typer.Typer weights_app: typer.Typer utils_app = typer.Typer(epilog=_epilog) + asyncio_runner = asyncio def __init__(self): self.config = { @@ -441,23 +535,29 @@ def __init__(self): "wallet_hotkey": None, "network": None, "use_cache": True, - "metagraph_cols": { - "UID": True, - "STAKE": True, - "RANK": True, - "TRUST": True, - "CONSENSUS": True, - "INCENTIVE": True, - "DIVIDENDS": True, - "EMISSION": True, - "VTRUST": True, - "VAL": True, - "UPDATED": True, - "ACTIVE": True, - "AXON": True, - "HOTKEY": True, - "COLDKEY": True, - }, + "rate_tolerance": None, + "safe_staking": True, + "allow_partial_stake": False, + # Commenting this out as this needs to get updated + # "metagraph_cols": { + # "UID": True, + # "GLOBAL_STAKE": True, + # "LOCAL_STAKE": True, + # "STAKE_WEIGHT": True, + # "RANK": True, + # "TRUST": True, + # "CONSENSUS": True, + # "INCENTIVE": True, + # "DIVIDENDS": True, + # "EMISSION": True, + # "VTRUST": True, + # "VAL": True, + # "UPDATED": True, + # "ACTIVE": True, + # "AXON": True, + # "HOTKEY": True, + # "COLDKEY": True, + # }, } self.subtensor = None self.config_base_path = os.path.expanduser(defaults.config.base_path) @@ -471,7 +571,6 @@ def __init__(self): ) self.config_app = typer.Typer(epilog=_epilog) self.wallet_app = typer.Typer(epilog=_epilog) - self.root_app = typer.Typer(epilog=_epilog) self.stake_app = typer.Typer(epilog=_epilog) self.sudo_app = typer.Typer(epilog=_epilog) self.subnets_app = typer.Typer(epilog=_epilog) @@ -501,15 +600,6 @@ def __init__(self): self.wallet_app, name="wallets", hidden=True, no_args_is_help=True ) - # root aliases - self.app.add_typer( - self.root_app, - name="root", - short_help="Root commands, alias: `r`", - no_args_is_help=True, - ) - self.app.add_typer(self.root_app, name="r", hidden=True, no_args_is_help=True) - # stake aliases self.app.add_typer( self.stake_app, @@ -558,13 +648,15 @@ def __init__(self): ) # utils app - self.app.add_typer(self.utils_app, name="utils", no_args_is_help=True) + self.app.add_typer( + self.utils_app, name="utils", no_args_is_help=True, hidden=True + ) # config commands self.config_app.command("set")(self.set_config) self.config_app.command("get")(self.get_config) self.config_app.command("clear")(self.del_config) - self.config_app.command("metagraph")(self.metagraph_config) + self.config_app.command("metagraph", hidden=True)(self.metagraph_config) # wallet commands self.wallet_app.command( @@ -595,16 +687,21 @@ def __init__(self): "balance", rich_help_panel=HELP_PANELS["WALLET"]["INFORMATION"] )(self.wallet_balance) self.wallet_app.command( - "history", rich_help_panel=HELP_PANELS["WALLET"]["INFORMATION"] + "history", + rich_help_panel=HELP_PANELS["WALLET"]["INFORMATION"], + hidden=True, )(self.wallet_history) self.wallet_app.command( - "overview", rich_help_panel=HELP_PANELS["WALLET"]["INFORMATION"] + "overview", + rich_help_panel=HELP_PANELS["WALLET"]["INFORMATION"], )(self.wallet_overview) self.wallet_app.command( "transfer", rich_help_panel=HELP_PANELS["WALLET"]["OPERATIONS"] )(self.wallet_transfer) self.wallet_app.command( - "inspect", rich_help_panel=HELP_PANELS["WALLET"]["INFORMATION"] + "inspect", + rich_help_panel=HELP_PANELS["WALLET"]["INFORMATION"], + hidden=True, )(self.wallet_inspect) self.wallet_app.command( "faucet", rich_help_panel=HELP_PANELS["WALLET"]["OPERATIONS"] @@ -619,59 +716,25 @@ def __init__(self): "sign", rich_help_panel=HELP_PANELS["WALLET"]["OPERATIONS"] )(self.wallet_sign) - # root commands - self.root_app.command("list")(self.root_list) - self.root_app.command( - "set-weights", rich_help_panel=HELP_PANELS["ROOT"]["WEIGHT_MGMT"] - )(self.root_set_weights) - self.root_app.command( - "get-weights", rich_help_panel=HELP_PANELS["ROOT"]["WEIGHT_MGMT"] - )(self.root_get_weights) - self.root_app.command( - "boost", rich_help_panel=HELP_PANELS["ROOT"]["WEIGHT_MGMT"] - )(self.root_boost) - self.root_app.command( - "slash", rich_help_panel=HELP_PANELS["ROOT"]["WEIGHT_MGMT"] - )(self.root_slash) - self.root_app.command( - "senate", rich_help_panel=HELP_PANELS["ROOT"]["GOVERNANCE"] - )(self.root_senate) - self.root_app.command( - "senate-vote", rich_help_panel=HELP_PANELS["ROOT"]["GOVERNANCE"] - )(self.root_senate_vote) - self.root_app.command("register")(self.root_register) - self.root_app.command( - "proposals", rich_help_panel=HELP_PANELS["ROOT"]["GOVERNANCE"] - )(self.root_proposals) - self.root_app.command( - "set-take", rich_help_panel=HELP_PANELS["ROOT"]["DELEGATION"] - )(self.root_set_take) - self.root_app.command( - "delegate-stake", rich_help_panel=HELP_PANELS["ROOT"]["DELEGATION"] - )(self.root_delegate_stake) - self.root_app.command( - "undelegate-stake", rich_help_panel=HELP_PANELS["ROOT"]["DELEGATION"] - )(self.root_undelegate_stake) - self.root_app.command( - "my-delegates", rich_help_panel=HELP_PANELS["ROOT"]["DELEGATION"] - )(self.root_my_delegates) - self.root_app.command( - "list-delegates", rich_help_panel=HELP_PANELS["ROOT"]["DELEGATION"] - )(self.root_list_delegates) - self.root_app.command( - "nominate", rich_help_panel=HELP_PANELS["ROOT"]["GOVERNANCE"] - )(self.root_nominate) - # stake commands - self.stake_app.command( - "show", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] - )(self.stake_show) self.stake_app.command( "add", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] )(self.stake_add) self.stake_app.command( "remove", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] )(self.stake_remove) + self.stake_app.command( + "list", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] + )(self.stake_list) + self.stake_app.command( + "move", rich_help_panel=HELP_PANELS["STAKE"]["MOVEMENT"] + )(self.stake_move) + self.stake_app.command( + "transfer", rich_help_panel=HELP_PANELS["STAKE"]["MOVEMENT"] + )(self.stake_transfer) + self.stake_app.command( + "swap", rich_help_panel=HELP_PANELS["STAKE"]["MOVEMENT"] + )(self.stake_swap) # stake-children commands children_app = typer.Typer() @@ -697,6 +760,21 @@ def __init__(self): self.sudo_app.command("get", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"])( self.sudo_get ) + self.sudo_app.command( + "senate", rich_help_panel=HELP_PANELS["SUDO"]["GOVERNANCE"] + )(self.sudo_senate) + self.sudo_app.command( + "proposals", rich_help_panel=HELP_PANELS["SUDO"]["GOVERNANCE"] + )(self.sudo_proposals) + self.sudo_app.command( + "senate-vote", rich_help_panel=HELP_PANELS["SUDO"]["GOVERNANCE"] + )(self.sudo_senate_vote) + self.sudo_app.command("set-take", rich_help_panel=HELP_PANELS["SUDO"]["TAKE"])( + self.sudo_set_take + ) + self.sudo_app.command("get-take", rich_help_panel=HELP_PANELS["SUDO"]["TAKE"])( + self.sudo_get_take + ) # subnets commands self.subnets_app.command( @@ -706,8 +784,8 @@ def __init__(self): "list", rich_help_panel=HELP_PANELS["SUBNETS"]["INFO"] )(self.subnets_list) self.subnets_app.command( - "lock-cost", rich_help_panel=HELP_PANELS["SUBNETS"]["CREATION"] - )(self.subnets_lock_cost) + "burn-cost", rich_help_panel=HELP_PANELS["SUBNETS"]["CREATION"] + )(self.subnets_burn_cost) self.subnets_app.command( "create", rich_help_panel=HELP_PANELS["SUBNETS"]["CREATION"] )(self.subnets_create) @@ -718,8 +796,14 @@ def __init__(self): "register", rich_help_panel=HELP_PANELS["SUBNETS"]["REGISTER"] )(self.subnets_register) self.subnets_app.command( - "metagraph", rich_help_panel=HELP_PANELS["SUBNETS"]["INFO"] - )(self.subnets_metagraph) + "metagraph", rich_help_panel=HELP_PANELS["SUBNETS"]["INFO"], hidden=True + )(self.subnets_show) # Aliased to `s show` for now + self.subnets_app.command( + "show", rich_help_panel=HELP_PANELS["SUBNETS"]["INFO"] + )(self.subnets_show) + self.subnets_app.command( + "price", rich_help_panel=HELP_PANELS["SUBNETS"]["INFO"] + )(self.subnets_price) # weights commands self.weights_app.command( @@ -764,22 +848,49 @@ def __init__(self): hidden=True, )(self.wallet_get_id) - # Root - self.root_app.command("set_weights", hidden=True)(self.root_set_weights) - self.root_app.command("get_weights", hidden=True)(self.root_get_weights) - self.root_app.command("senate_vote", hidden=True)(self.root_senate_vote) - self.root_app.command("set_take", hidden=True)(self.root_set_take) - self.root_app.command("delegate_stake", hidden=True)(self.root_delegate_stake) - self.root_app.command("undelegate_stake", hidden=True)( - self.root_undelegate_stake - ) - self.root_app.command("my_delegates", hidden=True)(self.root_my_delegates) - self.root_app.command("list_delegates", hidden=True)(self.root_list_delegates) - # Subnets - self.subnets_app.command("lock_cost", hidden=True)(self.subnets_lock_cost) + self.subnets_app.command("burn_cost", hidden=True)(self.subnets_burn_cost) self.subnets_app.command("pow_register", hidden=True)(self.subnets_pow_register) + # Sudo + self.sudo_app.command("senate_vote", hidden=True)(self.sudo_senate_vote) + self.sudo_app.command("get_take", hidden=True)(self.sudo_get_take) + self.sudo_app.command("set_take", hidden=True)(self.sudo_set_take) + + def generate_command_tree(self) -> Tree: + """ + Generates a rich.Tree of the commands, subcommands, and groups of this app + """ + + def build_rich_tree(data: dict, parent: Tree): + for group, content in data.get("groups", {}).items(): + group_node = parent.add( + f"[bold cyan]{group}[/]" + ) # Add group to the tree + for command in content.get("commands", []): + group_node.add(f"[green]{command}[/]") # Add commands to the group + build_rich_tree(content, group_node) # Recurse for subgroups + + def traverse_group(group: typer.Typer) -> dict: + tree = {} + if commands := [ + cmd.name for cmd in group.registered_commands if not cmd.hidden + ]: + tree["commands"] = commands + for group in group.registered_groups: + if "groups" not in tree: + tree["groups"] = {} + if not group.hidden: + if group_transversal := traverse_group(group.typer_instance): + tree["groups"][group.name] = group_transversal + + return tree + + groups_and_commands = traverse_group(self.app) + root = Tree("[bold magenta]BTCLI Commands[/]") # Root node + build_rich_tree(groups_and_commands, root) + return root + def initialize_chain( self, network: Optional[list[str]] = None, @@ -810,13 +921,13 @@ def initialize_chain( elif self.config["network"]: self.subtensor = SubtensorInterface(self.config["network"]) console.print( - f"Using the specified network [dark_orange]{self.config['network']}[/dark_orange] from config" + f"Using the specified network [{COLOR_PALETTE['GENERAL']['LINKS']}]{self.config['network']}[/{COLOR_PALETTE['GENERAL']['LINKS']}] from config" ) else: self.subtensor = SubtensorInterface(defaults.subtensor.network) return self.subtensor - def _run_command(self, cmd: Coroutine): + def _run_command(self, cmd: Coroutine, exit_early: bool = True): """ Runs the supplied coroutine with `asyncio.run` """ @@ -832,16 +943,19 @@ async def _run(): initiated = True result = await cmd return result - except (ConnectionRefusedError, ssl.SSLError): + except (ConnectionRefusedError, ssl.SSLError, InvalidHandshake): err_console.print(f"Unable to connect to the chain: {self.subtensor}") verbose_console.print(traceback.format_exc()) except ( ConnectionClosed, SubstrateRequestException, KeyboardInterrupt, + RuntimeError, ) as e: if isinstance(e, SubstrateRequestException): err_console.print(str(e)) + elif isinstance(e, RuntimeError): + pass # Temporarily to handle loop bound issues verbose_console.print(traceback.format_exc()) except Exception as e: err_console.print(f"An unknown error has occurred: {e}") @@ -849,23 +963,35 @@ async def _run(): finally: if initiated is False: asyncio.create_task(cmd).cancel() - raise typer.Exit() + if ( + exit_early is True + ): # temporarily to handle multiple run commands in one session + try: + raise typer.Exit() + except Exception as e: # ensures we always exit cleanly + if not isinstance(e, (typer.Exit, RuntimeError)): + err_console.print(f"An unknown error has occurred: {e}") - if sys.version_info < (3, 10): - # For Python 3.9 or lower - return asyncio.get_event_loop().run_until_complete(_run()) - else: - # For Python 3.10 or higher - return asyncio.run(_run()) + return self.asyncio_runner(_run()) def main_callback( self, version: Annotated[ - Optional[bool], typer.Option("--version", callback=version_callback) + Optional[bool], + typer.Option( + "--version", callback=version_callback, help="Show BTCLI version" + ), + ] = None, + commands: Annotated[ + Optional[bool], + typer.Option( + "--commands", callback=commands_callback, help="Show BTCLI commands" + ), ] = None, ): """ - Command line interface (CLI) for Bittensor. Uses the values in the configuration file. These values can be overriden by passing them explicitly in the command line. + Command line interface (CLI) for Bittensor. Uses the values in the configuration file. These values can be + overriden by passing them explicitly in the command line. """ # Load or create the config file if os.path.exists(self.config_path): @@ -897,6 +1023,20 @@ def main_callback( if k in self.config.keys(): self.config[k] = v + if sys.version_info < (3, 10): + # For Python 3.9 or lower + self.asyncio_runner = asyncio.get_event_loop().run_until_complete + else: + try: + uvloop = importlib.import_module("uvloop") + if sys.version_info >= (3, 11): + self.asyncio_runner = uvloop.run + else: + uvloop.install() + self.asyncio_runner = asyncio.run + except ModuleNotFoundError: + self.asyncio_runner = asyncio.run + def verbosity_handler(self, quiet: bool, verbose: bool): if quiet and verbose: err_console.print("Cannot specify both `--quiet` and `--verbose`") @@ -953,9 +1093,44 @@ def set_config( help="Disable caching of some commands. This will disable the `--reuse-last` and `--html` flags on " "commands such as `subnets metagraph`, `stake show` and `subnets list`.", ), + rate_tolerance: Optional[float] = typer.Option( + None, + "--slippage", + "--slippage-tolerance", + "--tolerance", + help="Set the rate tolerance percentage for transactions (e.g. 0.1 for 0.1%).", + ), + safe_staking: Optional[bool] = typer.Option( + None, + "--safe-staking/--no-safe-staking", + "--safe/--unsafe", + help="Enable or disable safe staking mode.", + ), + allow_partial_stake: Optional[bool] = typer.Option( + None, + "--allow-partial-stake/--no-allow-partial-stake", + "--partial/--no-partial", + "--allow/--not-allow", + ), ): """ - Sets the values in the config file. To set the metagraph configuration, use the command `btcli config metagraph` + Sets or updates configuration values in the BTCLI config file. + + This command allows you to set default values that will be used across all BTCLI commands. + + USAGE + Interactive mode: + [green]$[/green] btcli config set + + Set specific values: + [green]$[/green] btcli config set --wallet-name default --network finney + [green]$[/green] btcli config set --safe-staking --rate-tolerance 0.1 + + [bold]NOTE[/bold]: + - Network values can be network names (e.g., 'finney', 'test') or websocket URLs + - Rate tolerance is specified as a decimal (e.g., 0.05 for 0.05%) + - Changes are saved to ~/.bittensor/btcli.yaml + - Use '[green]$[/green] btcli config get' to view current settings """ args = { "wallet_name": wallet_name, @@ -963,8 +1138,11 @@ def set_config( "wallet_hotkey": wallet_hotkey, "network": network, "use_cache": use_cache, + "rate_tolerance": rate_tolerance, + "safe_staking": safe_staking, + "allow_partial_stake": allow_partial_stake, } - bools = ["use_cache"] + bools = ["use_cache", "safe_staking", "allow_partial_stake"] if all(v is None for v in args.values()): # Print existing configs self.get_config() @@ -988,6 +1166,20 @@ def set_config( default=True, ) self.config[arg] = nc + + elif arg == "rate_tolerance": + while True: + val = FloatPrompt.ask( + f"What percentage would you like to set for [red]{arg}[/red]?\nValues are percentages (e.g. 0.05 for 5%)", + default=0.05, + ) + try: + validated_val = validate_rate_tolerance(val) + self.config[arg] = validated_val + break + except typer.BadParameter as e: + print_error(str(e)) + continue else: val = Prompt.ask( f"What value would you like to assign to [red]{arg}[/red]?" @@ -1045,6 +1237,18 @@ def del_config( wallet_hotkey: bool = typer.Option(False, *Options.wallet_hotkey.param_decls), network: bool = typer.Option(False, *Options.network.param_decls), use_cache: bool = typer.Option(False, "--cache"), + rate_tolerance: bool = typer.Option( + False, "--slippage", "--slippage-tolerance", "--tolerance" + ), + safe_staking: bool = typer.Option( + False, "--safe-staking/--no-safe-staking", "--safe/--unsafe" + ), + allow_partial_stake: bool = typer.Option( + False, + "--allow-partial-stake/--no-allow-partial-stake", + "--partial/--no-partial", + "--allow/--not-allow", + ), all_items: bool = typer.Option(False, "--all"), ): """ @@ -1074,6 +1278,9 @@ def del_config( "wallet_hotkey": wallet_hotkey, "network": network, "use_cache": use_cache, + "rate_tolerance": rate_tolerance, + "safe_staking": safe_staking, + "allow_partial_stake": allow_partial_stake, } # If no specific argument is provided, iterate over all @@ -1135,6 +1342,8 @@ def get_config(self): else: if value in Constants.networks: value = value + f" ({Constants.network_map[value]})" + if key == "rate_tolerance": + value = f"{value} ({value*100}%)" if value is not None else "None" elif key in deprecated_configs: continue @@ -1147,13 +1356,112 @@ def get_config(self): table.add_row(str(key), str(value), "") console.print(table) - console.print( - dedent( - """ - [red]Deprecation notice[/red]: The chain endpoint config is now deprecated. You can use the network config to pass chain endpoints. - """ + + def ask_rate_tolerance( + self, + rate_tolerance: Optional[float], + ) -> float: + """ + Gets rate tolerance from args, config, or default. + + Args: + rate_tolerance (Optional[float]): Explicitly provided slippage value + + Returns: + float: rate tolerance value + """ + if rate_tolerance is not None: + console.print( + f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{rate_tolerance} ({rate_tolerance*100}%)[/bold cyan]." ) - ) + return rate_tolerance + elif self.config.get("rate_tolerance") is not None: + config_slippage = self.config["rate_tolerance"] + console.print( + f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{config_slippage} ({config_slippage*100}%)[/bold cyan] (from config)." + ) + return config_slippage + else: + console.print( + "[dim][blue]Rate tolerance[/blue]: " + + f"[bold cyan]{defaults.rate_tolerance} ({defaults.rate_tolerance*100}%)[/bold cyan] " + + "by default. Set this using " + + "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] " + + "or " + + "[dark_sea_green3 italic]`--tolerance`[/dark_sea_green3 italic] flag[/dim]" + ) + return defaults.rate_tolerance + + def ask_safe_staking( + self, + safe_staking: Optional[bool], + ) -> bool: + """ + Gets safe staking setting from args, config, or default. + + Args: + safe_staking (Optional[bool]): Explicitly provided safe staking value + + Returns: + bool: Safe staking setting + """ + if safe_staking is not None: + console.print( + f"[dim][blue]Safe staking[/blue]: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan]." + ) + return safe_staking + elif self.config.get("safe_staking") is not None: + safe_staking = self.config["safe_staking"] + console.print( + f"[dim][blue]Safe staking[/blue]: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] (from config)." + ) + return safe_staking + else: + safe_staking = True + console.print( + "[dim][blue]Safe staking[/blue]: " + + f"[bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] " + + "by default. Set this using " + + "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] " + + "or " + + "[dark_sea_green3 italic]`--safe/--unsafe`[/dark_sea_green3 italic] flag[/dim]" + ) + return safe_staking + + def ask_partial_stake( + self, + allow_partial_stake: Optional[bool], + ) -> bool: + """ + Gets partial stake setting from args, config, or default. + + Args: + allow_partial_stake (Optional[bool]): Explicitly provided partial stake value + + Returns: + bool: Partial stake setting + """ + if allow_partial_stake is not None: + console.print( + f"[dim][blue]Partial staking[/blue]: [bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan]." + ) + return allow_partial_stake + elif self.config.get("allow_partial_stake") is not None: + config_partial = self.config["allow_partial_stake"] + console.print( + f"[dim][blue]Partial staking[/blue]: [bold cyan]{'enabled' if config_partial else 'disabled'}[/bold cyan] (from config)." + ) + return config_partial + else: + console.print( + "[dim][blue]Partial staking[/blue]: " + + f"[bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan] " + + "by default. Set this using " + + "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] " + + "or " + + "[dark_sea_green3 italic]`--partial/--no-partial`[/dark_sea_green3 italic] flag[/dim]" + ) + return False def wallet_ask( self, @@ -1173,39 +1481,16 @@ def wallet_ask( :return: created Wallet object """ # Prompt for missing attributes specified in ask_for - - if wallet_path: - if wallet_path == "default": - wallet_path = defaults.wallet.path - - elif self.config.get("wallet_path"): - wallet_path = self.config.get("wallet_path") - console.print( - f"Using the wallet path from config:[bold magenta] {wallet_path}" - ) - - if WO.PATH in ask_for and not wallet_path: - wallet_path = Prompt.ask( - "Enter the [blue]wallet path[/blue]" - + " [dark_sea_green3 italic](Hint: You can set this with `btcli config set --wallet-path`)[/dark_sea_green3 italic]", - default=defaults.wallet.path, - ) - if wallet_path: - wallet_path = os.path.expanduser(wallet_path) - else: - wallet_path = os.path.expanduser(defaults.wallet.path) - console.print(f"Using default wallet path: ({defaults.wallet.path})") - if WO.NAME in ask_for and not wallet_name: if self.config.get("wallet_name"): wallet_name = self.config.get("wallet_name") console.print( - f"Using the wallet name from config:[bold cyan] {wallet_name}" + f"Using the [blue]wallet name[/blue] from config:[bold cyan] {wallet_name}" ) else: wallet_name = Prompt.ask( "Enter the [blue]wallet name[/blue]" - + " [dark_sea_green3 italic](Hint: You can set this with `btcli config set --wallet-name`)[/dark_sea_green3 italic]", + + f" [{COLOR_PALETTE['GENERAL']['HINT']} italic](Hint: You can set this with `btcli config set --wallet-name`)", default=defaults.wallet.name, ) @@ -1213,7 +1498,7 @@ def wallet_ask( if self.config.get("wallet_hotkey"): wallet_hotkey = self.config.get("wallet_hotkey") console.print( - f"Using the wallet hotkey from config:[bold cyan] {wallet_hotkey}" + f"Using the [blue]wallet hotkey[/blue] from config:[bold cyan] {wallet_hotkey}" ) else: wallet_hotkey = Prompt.ask( @@ -1221,8 +1506,27 @@ def wallet_ask( + " [dark_sea_green3 italic](Hint: You can set this with `btcli config set --wallet-hotkey`)[/dark_sea_green3 italic]", default=defaults.wallet.hotkey, ) + if wallet_path: + if wallet_path == "default": + wallet_path = defaults.wallet.path + + elif self.config.get("wallet_path"): + wallet_path = self.config.get("wallet_path") + console.print( + f"Using the [blue]wallet path[/blue] from config:[bold magenta] {wallet_path}" + ) + else: + wallet_path = defaults.wallet.path + if WO.PATH in ask_for and not wallet_path: + wallet_path = Prompt.ask( + "Enter the [blue]wallet path[/blue]" + + " [dark_sea_green3 italic](Hint: You can set this with `btcli config set --wallet-path`)[/dark_sea_green3 italic]", + default=defaults.wallet.path, + ) # Create the Wallet object + if wallet_path: + wallet_path = os.path.expanduser(wallet_path) wallet = Wallet(name=wallet_name, path=wallet_path, hotkey=wallet_hotkey) # Validate the wallet if required @@ -1252,7 +1556,7 @@ def wallet_list( """ Displays all the wallets and their corresponding hotkeys that are located in the wallet path specified in the config. - The output display shows each wallet and its associated `ss58` addresses for the coldkey public key and any hotkeys. The output is presented in a hierarchical tree format, with each wallet as a root node and any associated hotkeys as child nodes. The `ss58` address is displayed for each coldkey and hotkey that is not encrypted and exists on the device. + The output display shows each wallet and its associated `ss58` addresses for the coldkey public key and any hotkeys. The output is presented in a hierarchical tree format, with each wallet as a root node and any associated hotkeys as child nodes. The `ss58` address (or an `` marker, for encrypted hotkeys) is displayed for each coldkey and hotkey that exists on the device. Upon invocation, the command scans the wallet directory and prints a list of all the wallets, indicating whether the public keys are available (`?` denotes unavailable or encrypted keys). @@ -1315,50 +1619,9 @@ def wallet_overview( USAGE - The command offers various options to customize the output. Users can filter the displayed data by specific - netuid, sort by different criteria, and choose to include all the wallets in the user's wallet path location. - The output is presented in a tabular format with the following columns: - - - COLDKEY: The SS58 address of the coldkey. - - - HOTKEY: The SS58 address of the hotkey. - - - UID: Unique identifier of the neuron. - - - ACTIVE: Indicates if the neuron is active. - - - STAKE(τ): Amount of stake in the neuron, in TAO. - - - RANK: The rank of the neuron within the network. - - - TRUST: Trust score of the neuron. - - - CONSENSUS: Consensus score of the neuron. - - - INCENTIVE: Incentive score of the neuron. - - - DIVIDENDS: Dividends earned by the neuron. - - - EMISSION(p): Emission received by the neuron, expressed in rho. - - - VTRUST: Validator trust score of the neuron. - - - VPERMIT: Indicates if the neuron has a validator permit. - - - UPDATED: Time since last update. - - - AXON: IP address and port of the neuron. - - - HOTKEY_SS58: Human-readable representation of the hotkey. - - - # EXAMPLE: - [green]$[/green] btcli wallet overview - [green]$[/green] btcli wallet overview --all --sort-by stake --sort-order descending - - [green]$[/green] btcli wallet overview -in hk1,hk2 --sort-by stake + [green]$[/green] btcli wallet overview --all [bold]NOTE[/bold]: This command is read-only and does not modify the blockchain state or account configuration. It provides a quick and comprehensive view of the user's network presence, making it useful for monitoring account status, @@ -1408,6 +1671,7 @@ def wallet_overview( include_hotkeys, exclude_hotkeys, netuids_filter=netuids, + verbose=verbose, ) ) @@ -1425,7 +1689,6 @@ def wallet_transfer( None, "--amount", "-a", - prompt=False, help="Amount (in TAO) to transfer.", ), transfer_all: bool = typer.Option( @@ -1523,7 +1786,7 @@ def wallet_swap_hotkey( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) if not destination_hotkey_name: @@ -1535,7 +1798,7 @@ def wallet_swap_hotkey( wallet_name, wallet_path, destination_hotkey_name, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) self.initialize_chain(network) @@ -1591,6 +1854,8 @@ def wallet_inspect( [bold]Note[/bold]: The `inspect` command is for displaying information only and does not perform any transactions or state changes on the blockchain. It is intended to be used with Bittensor CLI and not as a standalone function in user code. """ + print_error("This command is disabled on the 'rao' network.") + raise typer.Exit() self.verbosity_handler(quiet, verbose) if netuids: @@ -1601,11 +1866,12 @@ def wallet_inspect( ) # if all-wallets is entered, ask for path - ask_for = [WO.NAME] if not all_wallets else [] + ask_for = [WO.NAME, WO.PATH] if not all_wallets else [WO.PATH] validate = WV.WALLET if not all_wallets else WV.NONE wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, ask_for=ask_for, validate=validate ) + self.initialize_chain(network) return self._run_command( wallets.inspect( @@ -1691,7 +1957,7 @@ def wallet_faucet( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME], + ask_for=[WO.NAME, WO.PATH], validate=WV.WALLET, ) return self._run_command( @@ -1750,7 +2016,8 @@ def wallet_regen_coldkey( if not wallet_name: wallet_name = Prompt.ask( - "Enter the name of the new wallet", default=defaults.wallet.name + f"Enter the name of the [{COLOR_PALETTE['GENERAL']['COLDKEY']}]new wallet (coldkey)", + default=defaults.wallet.name, ) wallet = Wallet(wallet_name, wallet_hotkey, wallet_path) @@ -1806,7 +2073,8 @@ def wallet_regen_coldkey_pub( if not wallet_name: wallet_name = Prompt.ask( - "Enter the name of the new wallet", default=defaults.wallet.name + f"Enter the name of the [{COLOR_PALETTE['GENERAL']['COLDKEY']}]new wallet (coldkey)", + default=defaults.wallet.name, ) wallet = Wallet(wallet_name, wallet_hotkey, wallet_path) @@ -1898,6 +2166,7 @@ def wallet_new_hotkey( False, # Overriden to False help="Set to 'True' to protect the generated Bittensor key with a password.", ), + uri: Optional[str] = Options.uri, overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -1920,12 +2189,14 @@ def wallet_new_hotkey( if not wallet_name: wallet_name = Prompt.ask( - "Enter the wallet name", default=defaults.wallet.name + f"Enter the [{COLOR_PALETTE['GENERAL']['COLDKEY']}]wallet name", + default=defaults.wallet.name, ) if not wallet_hotkey: wallet_hotkey = Prompt.ask( - "Enter the name of the new hotkey", default=defaults.wallet.hotkey + f"Enter the name of the [{COLOR_PALETTE['GENERAL']['HOTKEY']}]new hotkey", + default=defaults.wallet.hotkey, ) wallet = self.wallet_ask( @@ -1935,9 +2206,10 @@ def wallet_new_hotkey( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET, ) - n_words = get_n_words(n_words) + if not uri: + n_words = get_n_words(n_words) return self._run_command( - wallets.new_hotkey(wallet, n_words, use_password, overwrite) + wallets.new_hotkey(wallet, n_words, use_password, uri, overwrite) ) def wallet_new_coldkey( @@ -1952,6 +2224,7 @@ def wallet_new_coldkey( help="The number of words used in the mnemonic. Options: [12, 15, 18, 21, 24]", ), use_password: Optional[bool] = Options.use_password, + uri: Optional[str] = Options.uri, overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -1978,7 +2251,8 @@ def wallet_new_coldkey( if not wallet_name: wallet_name = Prompt.ask( - "Enter the name of the new wallet", default=defaults.wallet.name + f"Enter the name of the [{COLOR_PALETTE['GENERAL']['COLDKEY']}]new wallet (coldkey)", + default=defaults.wallet.name, ) wallet = self.wallet_ask( @@ -1988,9 +2262,10 @@ def wallet_new_coldkey( ask_for=[WO.NAME, WO.PATH], validate=WV.NONE, ) - n_words = get_n_words(n_words) + if not uri: + n_words = get_n_words(n_words) return self._run_command( - wallets.new_coldkey(wallet, n_words, use_password, overwrite) + wallets.new_coldkey(wallet, n_words, use_password, uri, overwrite) ) def wallet_check_ck_swap( @@ -2025,6 +2300,7 @@ def wallet_create_wallet( wallet_hotkey: Optional[str] = Options.wallet_hotkey, n_words: Optional[int] = None, use_password: bool = Options.use_password, + uri: Optional[str] = Options.uri, overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -2049,12 +2325,13 @@ def wallet_create_wallet( if not wallet_name: wallet_name = Prompt.ask( - "Enter the name of the new wallet (coldkey)", + f"Enter the name of the [{COLOR_PALETTE['GENERAL']['COLDKEY']}]new wallet (coldkey)", default=defaults.wallet.name, ) if not wallet_hotkey: wallet_hotkey = Prompt.ask( - "Enter the the name of the new hotkey", default=defaults.wallet.hotkey + f"Enter the the name of the [{COLOR_PALETTE['GENERAL']['HOTKEY']}]new hotkey", + default=defaults.wallet.hotkey, ) self.verbosity_handler(quiet, verbose) @@ -2065,14 +2342,10 @@ def wallet_create_wallet( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.NONE, ) - n_words = get_n_words(n_words) + if not uri: + n_words = get_n_words(n_words) return self._run_command( - wallets.wallet_create( - wallet, - n_words, - use_password, - overwrite, - ) + wallets.wallet_create(wallet, n_words, use_password, uri, overwrite) ) def wallet_balance( @@ -2116,8 +2389,18 @@ def wallet_balance( """ self.verbosity_handler(quiet, verbose) - - if ss58_addresses: + wallet = None + if all_balances: + ask_for = [WO.PATH] + validate = WV.NONE + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=ask_for, + validate=validate, + ) + elif ss58_addresses: valid_ss58s = [ ss58 for ss58 in set(ss58_addresses) if is_valid_ss58_address(ss58) ] @@ -2127,20 +2410,45 @@ def wallet_balance( print_error(f"Incorrect ss58 address: {invalid_ss58}. Skipping.") if valid_ss58s: - wallet = None ss58_addresses = valid_ss58s else: raise typer.Exit() else: - ask_for = [] if all_balances else [WO.NAME] - validate = WV.NONE if all_balances else WV.WALLET - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=ask_for, - validate=validate, - ) + if wallet_name: + coldkey_or_ss58 = wallet_name + else: + coldkey_or_ss58 = Prompt.ask( + "Enter the [blue]wallet name[/blue] or [blue]coldkey ss58 addresses[/blue] (comma-separated)", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) + + # Split and validate ss58 addresses + coldkey_or_ss58_list = [x.strip() for x in coldkey_or_ss58.split(",")] + if any(is_valid_ss58_address(x) for x in coldkey_or_ss58_list): + valid_ss58s = [ + ss58 for ss58 in coldkey_or_ss58_list if is_valid_ss58_address(ss58) + ] + invalid_ss58s = set(coldkey_or_ss58_list) - set(valid_ss58s) + for invalid_ss58 in invalid_ss58s: + print_error(f"Incorrect ss58 address: {invalid_ss58}. Skipping.") + + if valid_ss58s: + ss58_addresses = valid_ss58s + else: + raise typer.Exit() + else: + wallet_name = ( + coldkey_or_ss58_list[0] if coldkey_or_ss58_list else wallet_name + ) + ask_for = [WO.NAME, WO.PATH] + validate = WV.WALLET + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=ask_for, + validate=validate, + ) subtensor = self.initialize_chain(network) return self._run_command( wallets.wallet_balance(wallet, subtensor, all_balances, ss58_addresses) @@ -2166,19 +2474,23 @@ def wallet_history( [green]$[/green] btcli wallet history """ + # TODO: Fetch effective network and redirect users accordingly - this only works on finney + # no_use_config_str = "Using the network [dark_orange]finney[/dark_orange] and ignoring network/chain configs" - no_use_config_str = "Using the network [dark_orange]finney[/dark_orange] and ignoring network/chain configs" + # if self.config.get("network"): + # if self.config.get("network") != "finney": + # console.print(no_use_config_str) - if self.config.get("network"): - if self.config.get("network") != "finney": - console.print(no_use_config_str) + # For Rao games + print_error("This command is disabled on the 'rao' network.") + raise typer.Exit() self.verbosity_handler(quiet, verbose) wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME], + ask_for=[WO.NAME, WO.PATH], validate=WV.WALLET, ) return self._run_command(wallets.wallet_history(wallet)) @@ -2189,69 +2501,42 @@ def wallet_set_id( wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, - display_name: str = typer.Option( + name: str = typer.Option( "", - "--display-name", - "--display", + "--name", help="The display name for the identity.", ), - legal_name: str = typer.Option( - "", - "--legal-name", - "--legal", - help="The legal name for the identity.", - ), web_url: str = typer.Option( "", "--web-url", "--web", help="The web URL for the identity.", ), - riot_handle: str = typer.Option( - "", - "--riot-handle", - "--riot", - help="The Riot handle for the identity.", - ), - email: str = typer.Option( - "", - help="The email address for the identity.", - ), - pgp_fingerprint: str = typer.Option( - "", - "--pgp-fingerprint", - "--pgp", - help="The PGP fingerprint for the identity.", - ), image_url: str = typer.Option( "", "--image-url", "--image", help="The image URL for the identity.", ), - info_: str = typer.Option( + discord: str = typer.Option( "", - "--info", - "-i", - help="The info for the identity.", + "--discord", + help="The Discord handle for the identity.", ), - twitter_url: str = typer.Option( + description: str = typer.Option( "", - "-x", - "-𝕏", - "--twitter-url", - "--twitter", - help="The 𝕏 (Twitter) URL for the identity.", + "--description", + help="The description for the identity.", ), - validator_id: Optional[bool] = typer.Option( - None, - "--validator/--not-validator", - help="Are you updating a validator hotkey identity?", + additional: str = typer.Option( + "", + "--additional", + help="Additional details for the identity.", ), - subnet_netuid: Optional[int] = typer.Option( - None, - "--netuid", - help="Netuid if you are updating identity of a subnet owner", + github_repo: str = typer.Option( + "", + "--github", + help="The GitHub repository for the identity.", ), quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -2279,94 +2564,67 @@ def wallet_set_id( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.HOTKEY, WO.NAME], - validate=WV.WALLET_AND_HOTKEY, + ask_for=[WO.NAME], + validate=WV.WALLET, ) - if not any( - [ - display_name, - legal_name, - web_url, - riot_handle, - email, - pgp_fingerprint, - image_url, - info_, - twitter_url, - ] - ): - console.print( - "[yellow]All fields are optional. Press Enter to skip a field.[/yellow]" - ) - text_rejection = partial( - retry_prompt, - rejection=lambda x: sys.getsizeof(x) > 113, - rejection_text="[red]Error:[/red] Identity field must be <= 64 raw bytes.", - ) + current_identity = self._run_command( + wallets.get_id( + self.initialize_chain(network), + wallet.coldkeypub.ss58_address, + "Current on-chain identity", + ), + exit_early=False, + ) - def pgp_check(s: str): - try: - if s.startswith("0x"): - s = s[2:] # Strip '0x' - pgp_fingerprint_encoded = binascii.unhexlify(s.replace(" ", "")) - except Exception: - return True - return True if len(pgp_fingerprint_encoded) != 20 else False - - display_name = display_name or text_rejection("Display name") - legal_name = legal_name or text_rejection("Legal name") - web_url = web_url or text_rejection("Web URL") - riot_handle = riot_handle or text_rejection("Riot handle") - email = email or text_rejection("Email address") - pgp_fingerprint = pgp_fingerprint or retry_prompt( - "PGP fingerprint (Eg: A1B2 C3D4 E5F6 7890 1234 5678 9ABC DEF0 1234 5678)", - lambda s: False if not s else pgp_check(s), - "[red]Error:[/red] PGP Fingerprint must be exactly 20 bytes.", - ) - image_url = image_url or text_rejection("Image URL") - info_ = info_ or text_rejection("Enter info") - twitter_url = twitter_url or text_rejection("𝕏 (Twitter) URL") - - validator_id = validator_id or Confirm.ask( - "Are you updating a [bold blue]validator hotkey[/bold blue] identity or a [bold blue]subnet " - "owner[/bold blue] identity?\n" - "Enter [bold green]Y[/bold green] for [bold]validator hotkey[/bold] or [bold red]N[/bold red] for " - "[bold]subnet owner[/bold]", - show_choices=True, - ) + if prompt: + if not Confirm.ask( + "Cost to register an [blue]Identity[/blue] is [blue]0.1 TAO[/blue]," + " are you sure you wish to continue?" + ): + console.print(":cross_mark: Aborted!") + raise typer.Exit() - if validator_id is False: - subnet_netuid = IntPrompt.ask("Enter the netuid of the subnet you own") + identity = prompt_for_identity( + current_identity, + name, + web_url, + image_url, + discord, + description, + additional, + github_repo, + ) return self._run_command( wallets.set_id( wallet, self.initialize_chain(network), - display_name, - legal_name, - web_url, - pgp_fingerprint, - riot_handle, - email, - image_url, - twitter_url, - info_, - validator_id, - subnet_netuid, + identity["name"], + identity["url"], + identity["image"], + identity["discord"], + identity["description"], + identity["additional"], + identity["github_repo"], prompt, ) ) def wallet_get_id( self, - target_ss58_address: str = typer.Option( - None, + wallet_name: Optional[str] = Options.wallet_name, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + wallet_path: Optional[str] = Options.wallet_path, + coldkey_ss58=typer.Option( + None, + "--ss58", + "--coldkey_ss58", + "--coldkey.ss58_address", + "--coldkey.ss58", "--key", "-k", - "--ss58", - help="The coldkey or hotkey ss58 address to query.", - prompt=True, + help="Coldkey address of the wallet", ), network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, @@ -2389,13 +2647,28 @@ def wallet_get_id( [bold]Note[/bold]: This command is primarily used for informational purposes and has no side effects on the blockchain network state. """ - if not is_valid_ss58_address(target_ss58_address): - print_error("You have entered an incorrect ss58 address. Please try again") - raise typer.Exit() + wallet = None + if coldkey_ss58: + if not is_valid_ss58_address(coldkey_ss58): + print_error("You entered an invalid ss58 address") + raise typer.Exit() + else: + coldkey_or_ss58 = Prompt.ask( + "Enter the [blue]wallet name[/blue] or [blue]coldkey ss58 address[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) + if is_valid_ss58_address(coldkey_or_ss58): + coldkey_ss58 = coldkey_or_ss58 + else: + wallet_name = coldkey_or_ss58 if coldkey_or_ss58 else wallet_name + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] + ) + coldkey_ss58 = wallet.coldkeypub.ss58_address self.verbosity_handler(quiet, verbose) return self._run_command( - wallets.get_id(self.initialize_chain(network), target_ss58_address) + wallets.get_id(self.initialize_chain(network), coldkey_ss58) ) def wallet_sign( @@ -2429,934 +2702,493 @@ def wallet_sign( self.verbosity_handler(quiet, verbose) if use_hotkey is None: use_hotkey = Confirm.ask( - "Would you like to sign the transaction using your [red]hotkey[/red]?" - "\n[Type [red]y[/red] for [red]hotkey[/red] and [blue]n[/blue] for [blue]coldkey[/blue]] " - "(default is [blue]coldkey[/blue])", + f"Would you like to sign the transaction using your [{COLOR_PALETTE['GENERAL']['HOTKEY']}]hotkey[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]?" + f"\n[Type [{COLOR_PALETTE['GENERAL']['HOTKEY']}]y[/{COLOR_PALETTE['GENERAL']['HOTKEY']}] for [{COLOR_PALETTE['GENERAL']['HOTKEY']}]hotkey[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" + f" and [{COLOR_PALETTE['GENERAL']['COLDKEY']}]n[/{COLOR_PALETTE['GENERAL']['COLDKEY']}] for [{COLOR_PALETTE['GENERAL']['COLDKEY']}]coldkey[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]] (default is [{COLOR_PALETTE['GENERAL']['COLDKEY']}]coldkey[/{COLOR_PALETTE['GENERAL']['COLDKEY']}])", default=False, ) - ask_for = [WO.HOTKEY, WO.NAME] if use_hotkey else [WO.NAME] + ask_for = [WO.HOTKEY, WO.PATH, WO.NAME] if use_hotkey else [WO.NAME, WO.PATH] validate = WV.WALLET_AND_HOTKEY if use_hotkey else WV.WALLET wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, ask_for=ask_for, validate=validate ) if not message: - message = typer.prompt("Enter the message to encode and sign") + message = Prompt.ask("Enter the [blue]message[/blue] to encode and sign") return self._run_command(wallets.sign(wallet, message, use_hotkey)) - def root_list( + def stake_list( self, network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + wallet_path: Optional[str] = Options.wallet_path, + coldkey_ss58=typer.Option( + None, + "--ss58", + "--coldkey_ss58", + "--coldkey.ss58_address", + "--coldkey.ss58", + help="Coldkey address of the wallet", + ), + live: bool = Options.live, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + no_prompt: bool = Options.prompt, + # TODO add: all-wallets, reuse_last, html_output ): """ - Show the neurons (root network validators) in the root network (netuid = 0). + Display detailed stake information for a wallet across all subnets. - USAGE + Shows stake allocations, exchange rates, and emissions for each hotkey. - The command fetches and lists the neurons (root network validators) in the root network, showing their unique identifiers (UIDs), names, addresses, stakes, and whether they are part of the senate (network governance body). + [bold]Common Examples:[/bold] - This command is useful for understanding the composition and governance structure of the Bittensor network's root network. It provides insights into which neurons hold significant influence and responsibility within the Bittensor network. + 1. Basic stake overview: + [green]$[/green] btcli stake list --wallet.name my_wallet - EXAMPLE + 2. Live updating view with refresh: + [green]$[/green] btcli stake list --wallet.name my_wallet --live - [green]$[/green] btcli root list + 3. View specific coldkey by address: + [green]$[/green] btcli stake list --ss58 5Dk...X3q + + 4. Verbose output with full values: + [green]$[/green] btcli stake list --wallet.name my_wallet --verbose """ self.verbosity_handler(quiet, verbose) + + wallet = None + if coldkey_ss58: + if not is_valid_ss58_address(coldkey_ss58): + print_error("You entered an invalid ss58 address") + raise typer.Exit() + else: + if wallet_name: + coldkey_or_ss58 = wallet_name + else: + coldkey_or_ss58 = Prompt.ask( + "Enter the [blue]wallet name[/blue] or [blue]coldkey ss58 address[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) + if is_valid_ss58_address(coldkey_or_ss58): + coldkey_ss58 = coldkey_or_ss58 + else: + wallet_name = coldkey_or_ss58 if coldkey_or_ss58 else wallet_name + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + ) + return self._run_command( - root.root_list(subtensor=self.initialize_chain(network)) + list_stake.stake_list( + wallet, + coldkey_ss58, + self.initialize_chain(network), + live, + verbose, + no_prompt, + ) ) - def root_set_weights( + def stake_add( self, - network: Optional[list[str]] = Options.network, + stake_all: bool = typer.Option( + False, + "--all-tokens", + "--all", + "-a", + help="When set, the command stakes all the available TAO from the coldkey.", + ), + amount: float = typer.Option( + 0.0, "--amount", help="The amount of TAO to stake" + ), + include_hotkeys: str = typer.Option( + "", + "--include-hotkeys", + "-in", + "--hotkey-ss58-address", + help="Specifies hotkeys by name or ss58 address to stake to. For example, `-in hk1,hk2`", + ), + exclude_hotkeys: str = typer.Option( + "", + "--exclude-hotkeys", + "-ex", + help="Specifies hotkeys by name or ss58 address to not to stake to (use this option only with `--all-hotkeys`)" + " i.e. `--all-hotkeys -ex hk3,hk4`", + ), + all_hotkeys: bool = typer.Option( + False, + help="When set, this command stakes to all hotkeys associated with the wallet. Do not use if specifying " + "hotkeys in `--include-hotkeys`.", + ), + netuid: Optional[int] = Options.netuid_not_req, + all_netuids: bool = Options.all_netuids, wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, - netuids=typer.Option( - None, - "--netuids", - "--netuid", - "-n", - help="Set the netuid(s) to set weights to. Separate multiple netuids with a comma, for example: `-n 0,1,2`.", - ), - weights: str = Options.weights, + network: Optional[list[str]] = Options.network, + rate_tolerance: Optional[float] = Options.rate_tolerance, + safe_staking: Optional[bool] = Options.safe_staking, + allow_partial_stake: Optional[bool] = Options.allow_partial_stake, prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): """ - Set the weights for different subnets, by setting them in the root network. + Stake TAO to one or more hotkeys on specific netuids with your coldkey. - To use this command, you should specify the netuids and corresponding weights you wish to assign. This command is used by validators registered to the root subnet to influence the distribution of subnet rewards and responsibilities. + Stake is always added through your coldkey's free balance. For stake movement, please see `[green]$[/green] btcli stake move` command. - You must have a comprehensive understanding of the dynamics of the subnets to use this command. It is a powerful tool that directly impacts the subnet's operational mechanics and reward distribution. + [bold]Common Examples:[/bold] - EXAMPLE + 1. Interactive staking (guided prompts): + [green]$[/green] btcli stake add + + 2. Safe staking with rate tolerance of 10% with partial transaction disabled: + [green]$[/green] btcli stake add --amount 100 --netuid 1 --safe --tolerance 0.1 --no-partial + + 3. Allow partial stake if rates change with tolerance of 10%: + [green]$[/green] btcli stake add --amount 300 --safe --partial --tolerance 0.1 - With no spaces between the passed values: + 4. Unsafe staking with no rate protection: + [green]$[/green] btcli stake add --amount 300 --netuid 1 --unsafe - [green]$[/green] btcli root set-weights --netuids 1,2 --weights 0.2,0.3 + 5. Stake to multiple hotkeys: + [green]$[/green] btcli stake add --amount 200 --include-hotkeys hk_ss58_1,hk_ss58_2,hk_ss58_3 - or + 6. Stake all balance to a subnet: + [green]$[/green] btcli stake add --all --netuid 3 - Include double quotes to include spaces between the passed values: + [bold]Safe Staking Parameters:[/bold] + • [blue]--safe[/blue]: Enables rate tolerance checks + • [blue]--tolerance[/blue]: Maximum % rate change allowed (0.05 = 5%) + • [blue]--partial[/blue]: Complete partial stake if rates exceed tolerance - [green]$[/green] btcli root set-weights --netuids "1, 2" --weights "0.2, 0.3" """ self.verbosity_handler(quiet, verbose) + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + rate_tolerance = self.ask_rate_tolerance(rate_tolerance) + allow_partial_stake = self.ask_partial_stake(allow_partial_stake) + console.print("\n") + netuid = get_optional_netuid(netuid, all_netuids) - if netuids: - netuids = parse_to_list( - netuids, - int, - "Netuids must be a comma-separated list of ints, e.g., `--netuid 1,2,3,4`.", + if stake_all and amount: + print_error( + "Cannot specify an amount and 'stake-all'. Choose one or the other." ) - else: - netuids = list_prompt(netuids, int, "Enter netuids (e.g: 1, 4, 6)") + raise typer.Exit() - if weights: - weights = parse_to_list( - weights, - float, - "Weights must be a comma-separated list of floats, e.g., `--weights 0.3,0.4,0.3`.", - ) - else: - weights = list_prompt( - weights, float, "Enter weights (e.g. 0.02, 0.03, 0.01)" - ) + if stake_all and not amount: + if not Confirm.ask("Stake all the available TAO tokens?", default=False): + raise typer.Exit() - if len(netuids) != len(weights): - raise typer.BadParameter( - "The number of netuids and weights must be the same." + if all_hotkeys and include_hotkeys: + print_error( + "You have specified hotkeys to include and also the `--all-hotkeys` flag. The flag" + "should only be used standalone (to use all hotkeys) or with `--exclude-hotkeys`." ) + raise typer.Exit() - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.HOTKEY, WO.NAME], - validate=WV.WALLET_AND_HOTKEY, - ) - self._run_command( - root.set_weights( - wallet, self.initialize_chain(network), netuids, weights, prompt + if include_hotkeys and exclude_hotkeys: + print_error( + "You have specified options for both including and excluding hotkeys. Select one or the other." ) - ) + raise typer.Exit() - def root_get_weights( - self, - network: Optional[list[str]] = Options.network, - limit_min_col: Optional[int] = typer.Option( - None, - "--limit-min-col", - "--min", - help="Limit the left display of the table to this column.", - ), - limit_max_col: Optional[int] = typer.Option( - None, - "--limit-max-col", - "--max", - help="Limit the right display of the table to this column.", - ), - reuse_last: bool = Options.reuse_last, - html_output: bool = Options.html_output, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Shows a table listing the weights assigned to each subnet in the root network. + if not wallet_hotkey and not all_hotkeys and not include_hotkeys: + if not wallet_name: + wallet_name = Prompt.ask( + "Enter the [blue]wallet name[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) + if netuid is not None: + hotkey_or_ss58 = Prompt.ask( + "Enter the [blue]wallet hotkey[/blue] name or [blue]ss58 address[/blue] to stake to [dim](or Press Enter to view delegates)[/dim]", + ) + else: + hotkey_or_ss58 = Prompt.ask( + "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to stake to", + default=self.config.get("wallet_hotkey") or defaults.wallet.hotkey, + ) - This command provides visibility into how network responsibilities and rewards are distributed among various subnets. This information is crucial for understanding the current influence and reward distribution across different subnets. Use this command if you are interested in the governance and operational dynamics of the Bittensor network. + if hotkey_or_ss58 == "": + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + ) + selected_hotkey = self._run_command( + subnets.show( + subtensor=self.initialize_chain(network), + netuid=netuid, + sort=False, + max_rows=12, + prompt=False, + delegate_selection=True, + ), + exit_early=False, + ) + if selected_hotkey is None: + print_error("No delegate selected. Exiting.") + raise typer.Exit() + include_hotkeys = selected_hotkey + elif is_valid_ss58_address(hotkey_or_ss58): + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + ) + include_hotkeys = hotkey_or_ss58 + else: + wallet_hotkey = hotkey_or_ss58 + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], + validate=WV.WALLET_AND_HOTKEY, + ) + include_hotkeys = wallet.hotkey.ss58_address - EXAMPLE + elif all_hotkeys or include_hotkeys or exclude_hotkeys: + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + ) + else: + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) - [green]$[/green] btcli root get_weights - """ - self.verbosity_handler(quiet, verbose) - if (reuse_last or html_output) and self.config.get("use_cache") is False: - err_console.print( - "Unable to use `--reuse-last` or `--html` when config 'no-cache' is set to 'True'." - "Change it to 'False' using `btcli config set`." + if include_hotkeys: + include_hotkeys = parse_to_list( + include_hotkeys, + str, + "Hotkeys must be a comma-separated list of ss58s, e.g., `--include-hotkeys 5Grw....,5Grw....`.", + is_ss58=True, ) - raise typer.Exit() - if not reuse_last: - subtensor = self.initialize_chain(network) else: - subtensor = None + include_hotkeys = [] + + if exclude_hotkeys: + exclude_hotkeys = parse_to_list( + exclude_hotkeys, + str, + "Hotkeys must be a comma-separated list of ss58s, e.g., `--exclude-hotkeys 5Grw....,5Grw....`.", + is_ss58=True, + ) + else: + exclude_hotkeys = [] + + # TODO: Ask amount for each subnet explicitly if more than one + if not stake_all and not amount: + free_balance = self._run_command( + wallets.wallet_balance( + wallet, self.initialize_chain(network), False, None + ), + exit_early=False, + ) + if free_balance == Balance.from_tao(0): + print_error("You dont have any balance to stake.") + raise typer.Exit() + if netuid is not None: + amount = FloatPrompt.ask( + f"Amount to [{COLOR_PALETTE['GENERAL']['SUBHEADING_MAIN']}]stake (TAO τ)" + ) + else: + amount = FloatPrompt.ask( + f"Amount to [{COLOR_PALETTE['GENERAL']['SUBHEADING_MAIN']}]stake to each netuid (TAO τ)" + ) + + if amount <= 0: + print_error(f"You entered an incorrect stake amount: {amount}") + raise typer.Exit() + if Balance.from_tao(amount) > free_balance: + print_error( + f"You dont have enough balance to stake. Current free Balance: {free_balance}." + ) + raise typer.Exit() + return self._run_command( - root.get_weights( - subtensor, - limit_min_col, - limit_max_col, - reuse_last, - html_output, - not self.config.get("use_cache", True), + add_stake.stake_add( + wallet, + self.initialize_chain(network), + netuid, + stake_all, + amount, + prompt, + all_hotkeys, + include_hotkeys, + exclude_hotkeys, + safe_staking, + rate_tolerance, + allow_partial_stake, ) ) - def root_boost( + def stake_remove( self, network: Optional[list[str]] = Options.network, wallet_name: str = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - netuid: int = Options.netuid, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + netuid: Optional[int] = Options.netuid_not_req, + all_netuids: bool = Options.all_netuids, + unstake_all: bool = typer.Option( + False, + "--unstake-all", + "--all", + hidden=True, + help="When set, this command unstakes all staked TAO + Alpha from the all hotkeys.", + ), + unstake_all_alpha: bool = typer.Option( + False, + "--unstake-all-alpha", + "--all-alpha", + hidden=True, + help="When set, this command unstakes all staked Alpha from the all hotkeys.", + ), amount: float = typer.Option( - None, - "--amount", - "--increase", - "-a", - prompt="Enter the boost amount (added to existing weight)", - help="Amount (float) to boost (added to the existing weight), (e.g. 0.01)", + 0.0, "--amount", "-a", help="The amount of TAO to unstake." + ), + hotkey_ss58_address: str = typer.Option( + "", + help="The ss58 address of the hotkey to unstake from.", + ), + include_hotkeys: str = typer.Option( + "", + "--include-hotkeys", + "-in", + help="Specifies the hotkeys by name or ss58 address to unstake from. For example, `-in hk1,hk2`", + ), + exclude_hotkeys: str = typer.Option( + "", + "--exclude-hotkeys", + "-ex", + help="Specifies the hotkeys by name or ss58 address not to unstake from (only use with `--all-hotkeys`)" + " i.e. `--all-hotkeys -ex hk3,hk4`", + ), + all_hotkeys: bool = typer.Option( + False, + help="When set, this command unstakes from all the hotkeys associated with the wallet. Do not use if specifying " + "hotkeys in `--include-hotkeys`.", ), + rate_tolerance: Optional[float] = Options.rate_tolerance, + safe_staking: Optional[bool] = Options.safe_staking, + allow_partial_stake: Optional[bool] = Options.allow_partial_stake, prompt: bool = Options.prompt, + interactive: bool = typer.Option( + False, + "--interactive", + "-i", + help="Enter interactive mode for unstaking.", + ), quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): """ - Increase (boost) the weights for a specific subnet in the root network. Any amount provided will be added to the subnet's existing weight. + Unstake TAO from one or more hotkeys and transfer them back to the user's coldkey wallet. - EXAMPLE + This command is used to withdraw TAO or Alpha stake from different hotkeys. - [green]$[/green] btcli root boost --netuid 1 --increase 0.01 + [bold]Common Examples:[/bold] + + 1. Interactive unstaking (guided prompts): + [green]$[/green] btcli stake remove + + 2. Safe unstaking with 10% rate tolerance: + [green]$[/green] btcli stake remove --amount 100 --netuid 1 --safe --tolerance 0.1 + + 3. Allow partial unstake if rates change: + [green]$[/green] btcli stake remove --amount 300 --safe --partial + + 4. Unstake from multiple hotkeys: + [green]$[/green] btcli stake remove --amount 200 --include-hotkeys hk1,hk2,hk3 + + 5. Unstake all from a hotkey: + [green]$[/green] btcli stake remove --all + + 6. Unstake all Alpha from a hotkey and stake to Root: + [green]$[/green] btcli stake remove --all-alpha + + [bold]Safe Staking Parameters:[/bold] + • [blue]--safe[/blue]: Enables rate tolerance checks during unstaking + • [blue]--tolerance[/blue]: Max allowed rate change (0.05 = 5%) + • [blue]--partial[/blue]: Complete partial unstake if rates exceed tolerance """ self.verbosity_handler(quiet, verbose) - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, - ) - return self._run_command( - root.set_boost( - wallet, self.initialize_chain(network), netuid, amount, prompt - ) - ) - - def root_slash( - self, - network: Optional[list[str]] = Options.network, - wallet_name: str = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - netuid: int = Options.netuid, - amount: float = typer.Option( - None, - "--amount", - "--decrease", - "-a", - prompt="Enter the slash amount (subtracted from the existing weight)", - help="Amount (float) to slash (subtract from the existing weight), (e.g. 0.01)", - ), - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Decrease (slash) the weights for a specific subnet in the root network. Any amount provided will be subtracted from the subnet's existing weight. - - EXAMPLE - - [green]$[/green] btcli root slash --netuid 1 --decrease 0.01 - - """ - self.verbosity_handler(quiet, verbose) - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, - ) - return self._run_command( - root.set_slash( - wallet, self.initialize_chain(network), netuid, amount, prompt - ) - ) - - def root_senate_vote( - self, - network: Optional[list[str]] = Options.network, - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - proposal: str = typer.Option( - None, - "--proposal", - "--proposal-hash", - prompt="Enter the proposal hash", - help="The hash of the proposal to vote on.", - ), - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - vote: bool = typer.Option( - None, - "--vote-aye/--vote-nay", - prompt="Enter y to vote Aye, or enter n to vote Nay", - help="The vote casted on the proposal", - ), - ): - """ - Cast a vote on an active proposal in Bittensor's governance protocol. - - This command is used by Senate members to vote on various proposals that shape the network's future. Use `btcli root proposals` to see the active proposals and their hashes. - - USAGE - - The user must specify the hash of the proposal they want to vote on. The command then allows the Senate member to cast a 'Yes' or 'No' vote, contributing to the decision-making process on the proposal. This command is crucial for Senate members to exercise their voting rights on key proposals. It plays a vital role in the governance and evolution of the Bittensor network. - - EXAMPLE - - [green]$[/green] btcli root senate_vote --proposal - """ - self.verbosity_handler(quiet, verbose) - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, - ) - return self._run_command( - root.senate_vote( - wallet, self.initialize_chain(network), proposal, vote, prompt - ) - ) - - def root_senate( - self, - network: Optional[list[str]] = Options.network, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Shows the Senate members of the Bittensor's governance protocol. - - This command lists the delegates involved in the decision-making process of the Bittensor network, showing their names and wallet addresses. This information is crucial for understanding who holds governance roles within the network. - - EXAMPLE - - [green]$[/green] btcli root senate - """ - self.verbosity_handler(quiet, verbose) - return self._run_command(root.get_senate(self.initialize_chain(network))) - - def root_register( - self, - network: Optional[list[str]] = Options.network, - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Register a neuron to the root subnet by recycling some TAO to cover for the registration cost. - - This command adds a new neuron as a validator on the root network. This will allow the neuron owner to set subnet weights. - - # Usage: - - Before registering, the command checks if the specified subnet exists and whether the TAO balance in the user's wallet is sufficient to cover the registration cost. The registration cost is determined by the current recycle amount for the specified subnet. If the balance is insufficient or the subnet does not exist, the command will exit with an appropriate error message. - - # Example usage: - - [green]$[/green] btcli subnets register --netuid 1 - """ - self.verbosity_handler(quiet, verbose) - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, - ) - return self._run_command( - root.register(wallet, self.initialize_chain(network), prompt) - ) - - def root_proposals( - self, - network: Optional[list[str]] = Options.network, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - View active proposals for the senate in the Bittensor's governance protocol. - - This command displays the details of ongoing proposals, including proposal hashes, votes, thresholds, and proposal data. - - EXAMPLE - - [green]$[/green] btcli root proposals - """ - self.verbosity_handler(quiet, verbose) - return self._run_command( - root.proposals(self.initialize_chain(network), verbose) - ) - - def root_set_take( - self, - network: Optional[list[str]] = Options.network, - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - take: float = typer.Option(None, help="The new take value."), - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Allows users to change their delegate take percentage. - - This command can be used to update the delegate takes individually for every subnet. To run the command, the user must have a configured wallet with both hotkey and coldkey. The command performs the below checks: - - 1. The provided hotkey is already a delegate. - 2. The new take value is within 0-18% range. - - EXAMPLE - - [green]$[/green] btcli root set_take --wallet-name my_wallet --wallet-hotkey my_hotkey - """ - max_value = 0.18 - min_value = 0.00 - self.verbosity_handler(quiet, verbose) - - if not take: - max_value_style = typer.style(f"Max: {max_value}", fg="magenta") - min_value_style = typer.style(f"Min: {min_value}", fg="magenta") - prompt_text = typer.style( - "Enter take value (0.18 for 18%)", fg="bright_cyan", bold=True - ) - take = FloatPrompt.ask(f"{prompt_text} {min_value_style} {max_value_style}") - - if not (min_value <= take <= max_value): - print_error( - f"Take value must be between {min_value} and {max_value}. Provided value: {take}" - ) - raise typer.Exit() - - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, - ) - - return self._run_command( - root.set_take(wallet, self.initialize_chain(network), take) - ) - - def root_delegate_stake( - self, - delegate_ss58key: str = typer.Option( - None, - help="The ss58 address of the delegate hotkey to stake TAO to.", - prompt="Enter the hotkey ss58 address you want to delegate TAO to.", - ), - amount: Optional[float] = typer.Option( - None, help="The amount of TAO to stake. Do no specify if using `--all`" - ), - stake_all: Optional[bool] = typer.Option( - False, - "--all", - "-a", - help="If specified, the command stakes all available TAO. Do not specify if using" - " `--amount`", - ), - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - network: Optional[list[str]] = Options.network, - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Stakes TAO to a specified delegate hotkey. - - This command allocates the user's TAO to the specified hotkey of a delegate, potentially earning staking rewards in return. If the - `--all` flag is used, it delegates the entire TAO balance available in the user's wallet. - - This command should be run by a TAO holder. Compare this command with "btcli stake add" that is typically run by a subnet validator to add stake to their own delegate hotkey. - - EXAMPLE - - [green]$[/green] btcli root delegate-stake --delegate_ss58key --amount - - [green]$[/green] btcli root delegate-stake --delegate_ss58key --all - - [blue bold]Note[/blue bold]: This command modifies the blockchain state and may incur transaction fees. The user should ensure the delegate's address and the amount to be staked are correct before executing the command. - """ - self.verbosity_handler(quiet, verbose) - if amount and stake_all: - err_console.print( - "Both `--amount` and `--all` are specified. Choose one or the other." - ) - if not stake_all and not amount: - while True: - amount = FloatPrompt.ask( - "Amount to [blue]stake (TAO τ)[/blue]", console=console - ) - confirmation = FloatPrompt.ask( - "Confirm the amount to stake [blue](TAO τ)[/blue]", - console=console, - ) - if amount == confirmation: - break - else: - err_console.print( - "[red]The amounts do not match. Please try again.[/red]" - ) - - wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] - ) - return self._run_command( - root.delegate_stake( - wallet, - self.initialize_chain(network), - amount, - delegate_ss58key, - prompt, - ) - ) - - def root_undelegate_stake( - self, - delegate_ss58key: str = typer.Option( - None, - help="The ss58 address of the delegate to undelegate from.", - prompt="Enter the hotkey ss58 address you want to undelegate from", - ), - amount: Optional[float] = typer.Option( - None, help="The amount of TAO to unstake. Do no specify if using `--all`" - ), - unstake_all: Optional[bool] = typer.Option( - False, - "--all", - "-a", - help="If specified, the command undelegates all staked TAO from the delegate. Do not specify if using" - " `--amount`", - ), - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - network: Optional[list[str]] = Options.network, - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Allows users to withdraw their staked TAO from a delegate. - - The user must provide the delegate hotkey's ss58 address and the amount of TAO to undelegate. The function will then send a transaction to the blockchain to process the undelegation. This command can result in a change to the blockchain state and may incur transaction fees. - - EXAMPLE - - [green]$[/green] btcli undelegate --delegate_ss58key --amount - - [green]$[/green] btcli undelegate --delegate_ss58key --all - """ - self.verbosity_handler(quiet, verbose) - if amount and unstake_all: - err_console.print( - "Both `--amount` and `--all` are specified. Choose one or the other." - ) - if not unstake_all and not amount: - while True: - amount = FloatPrompt.ask( - "Amount to [blue]unstake (TAO τ)[/blue]", console=console - ) - confirmation = FloatPrompt.ask( - "Confirm the amount to unstake [blue](TAO τ)[/blue]", - console=console, - ) - if amount == confirmation: - break - else: - err_console.print( - "[red]The amounts do not match. Please try again.[/red]" - ) - - wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] - ) - self._run_command( - root.delegate_unstake( - wallet, - self.initialize_chain(network), - amount, - delegate_ss58key, - prompt, - ) - ) - - def root_my_delegates( - self, - network: Optional[list[str]] = Options.network, - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - all_wallets: bool = typer.Option( - False, - "--all-wallets", - "--all", - "-a", - help="If specified, the command aggregates information across all the wallets.", - ), - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Shows a table with the details on the user's delegates. - - The table output includes the following columns: - - - Wallet: The name of the user's wallet (coldkey). - - - OWNER: The name of the delegate who owns the hotkey. - - - SS58: The truncated SS58 address of the delegate's hotkey. - - - Delegation: The amount of TAO staked by the user to the delegate. - - - τ/24h: The earnings from the delegate to the user over the past 24 hours. - - - NOMS: The number of nominators for the delegate. - - - OWNER STAKE(τ): The stake amount owned by the delegate. - - - TOTAL STAKE(τ): The total stake amount held by the delegate. - - - SUBNETS: The list of subnets the delegate is a part of. - - - VPERMIT: Validator permits held by the delegate for various subnets. - - - 24h/kτ: Earnings per 1000 TAO staked over the last 24 hours. - - - Desc: A description of the delegate. - - The command also sums and prints the total amount of TAO delegated across all wallets. - - EXAMPLE - - [green]$[/green] btcli root my-delegates - [green]$[/green] btcli root my-delegates --all - [green]$[/green] btcli root my-delegates --wallet-name my_wallet - - [blue bold]Note[/blue bold]: This command is not intended to be used directly in user code. - """ - self.verbosity_handler(quiet, verbose) - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=([WO.NAME] if not all_wallets else []), - validate=WV.WALLET if not all_wallets else WV.NONE, - ) - self._run_command( - root.my_delegates(wallet, self.initialize_chain(network), all_wallets) - ) - - def root_list_delegates( - self, - network: Optional[list[str]] = Options.network, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Displays a table of Bittensor network-wide delegates, providing a comprehensive overview of delegate statistics and information. - - This table helps users make informed decisions on which delegates to allocate their TAO stake. - - The table columns include: - - - INDEX: The delegate's index in the sorted list. - - - DELEGATE: The name of the delegate. - - - SS58: The delegate's unique ss58 address (truncated for display). - - - NOMINATORS: The count of nominators backing the delegate. - - - OWN STAKE(τ): The amount of delegate's own stake (not the TAO delegated from any nominators). - - - TOTAL STAKE(τ): The delegate's total stake, i.e., the sum of delegate's own stake and nominators' stakes. - - - CHANGE/(4h): The percentage change in the delegate's stake over the last four hours. - - - SUBNETS: The subnets in which the delegate is registered. - - - VPERMIT: Indicates the subnets in which the delegate has validator permits. - - - NOMINATOR/(24h)/kτ: The earnings per 1000 τ staked by nominators in the last 24 hours. - - - DELEGATE/(24h): The total earnings of the delegate in the last 24 hours. - - - DESCRIPTION: A brief description of the delegate's purpose and operations. - - [blue bold]NOTES:[/blue bold] - - - Sorting is done based on the `TOTAL STAKE` column in descending order. - - Changes in stake are shown as: increases in green and decreases in red. - - Entries with no previous data are marked with `NA`. - - Each delegate's name is a hyperlink to more information, if available. - - EXAMPLE - - [green]$[/green] btcli root list_delegates - - [green]$[/green] btcli root list_delegates --subtensor.network finney # can also be `test` or `local` - - [blue bold]NOTE[/blue bold]: This command is intended for use within a - console application. It prints directly to the console and does not return any value. - """ - self.verbosity_handler(quiet, verbose) - - if network: - if "finney" in network: - network = ["wss://archive.chain.opentensor.ai:443"] - elif (conf_net := self.config.get("network")) == "finney": - network = ["wss://archive.chain.opentensor.ai:443"] - elif conf_net: - network = [conf_net] - else: - network = ["wss://archive.chain.opentensor.ai:443"] - - sub = self.initialize_chain(network) - return self._run_command(root.list_delegates(sub)) - - # TODO: Confirm if we need a command for this - currently registering to root auto makes u delegate - def root_nominate( - self, - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - network: Optional[list[str]] = Options.network, - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Enables a wallet's hotkey to become a delegate. - - This command handles the nomination process, including wallet unlocking and verification of the hotkey's current delegate status. - - The command performs several checks: - - - Verifies that the hotkey is not already a delegate to prevent redundant nominations. - - - Tries to nominate the wallet and reports success or failure. - - Upon success, the wallet's hotkey is registered as a delegate on the network. - - To run the command, the user must have a configured wallet with both hotkey and coldkey. If the wallet is not already nominated, this command will initiate the process. - - EXAMPLE - - [green]$[/green] btcli root nominate - - [green]$[/green] btcli root nominate --wallet-name my_wallet --wallet-hotkey my_hotkey - - [blue bold]Note[/blue bold]: This command prints the output directly to the console. It should not be called programmatically in user code due to its interactive nature and side effects on the network state. - """ - self.verbosity_handler(quiet, verbose) - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, - ) - return self._run_command( - root.nominate(wallet, self.initialize_chain(network), prompt) - ) - - def stake_show( - self, - all_wallets: bool = typer.Option( - False, - "--all", - "--all-wallets", - "-a", - help="When set, the command checks all the coldkey wallets of the user instead of just the specified wallet.", - ), - network: Optional[list[str]] = Options.network, - wallet_name: Optional[str] = Options.wallet_name, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - wallet_path: Optional[str] = Options.wallet_path, - reuse_last: bool = Options.reuse_last, - html_output: bool = Options.html_output, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Lists all the stake accounts associated with a user's wallet. - - This command provides a comprehensive view of the stakes associated with the user's coldkeys. It shows both the user's own hotkeys and also the hotkeys of the delegates to which this user has staked. - - The command lists all the stake accounts for a specified wallet or all wallets in the user's configuration directory. It displays the coldkey, balance, hotkey details (own hotkey and delegate hotkey), stake amount, and the rate of return. - - The command shows a table with the below columns: - - - Coldkey: The coldkey associated with the wallet. - - - Balance: The balance of the coldkey. - - - Hotkey: The names of the coldkey's own hotkeys and the delegate hotkeys to which this coldkey has staked. - - - Stake: The amount of TAO staked to all the hotkeys. - - - Rate: The rate of return on the stake, shown in TAO per day. - - EXAMPLE - - [green]$[/green] btcli stake show --all - """ - self.verbosity_handler(quiet, verbose) - if (reuse_last or html_output) and self.config.get("use_cache") is False: - err_console.print( - "Unable to use `--reuse-last` or `--html` when config 'no-cache' is set to 'True'. " - "Please change the config to 'False' using `btcli config set`" + if not unstake_all and not unstake_all_alpha: + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + rate_tolerance = self.ask_rate_tolerance(rate_tolerance) + allow_partial_stake = self.ask_partial_stake(allow_partial_stake) + console.print("\n") + + if interactive and any( + [hotkey_ss58_address, include_hotkeys, exclude_hotkeys, all_hotkeys] + ): + print_error( + "Interactive mode cannot be used with hotkey selection options like --include-hotkeys, --exclude-hotkeys, --all-hotkeys, or --hotkey." ) raise typer.Exit() - if not reuse_last: - subtensor = self.initialize_chain(network) - else: - subtensor = None - - if all_wallets: - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[], - validate=WV.NONE, - ) - else: - wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] - ) - - return self._run_command( - stake.show( - wallet, - subtensor, - all_wallets, - reuse_last, - html_output, - not self.config.get("use_cache", True), - ) - ) - - def stake_add( - self, - stake_all: bool = typer.Option( - False, - "--all-tokens", - "--all", - "-a", - help="When set, the command stakes all the available TAO from the coldkey.", - ), - amount: float = typer.Option( - 0.0, "--amount", help="The amount of TAO to stake" - ), - max_stake: float = typer.Option( - 0.0, - "--max-stake", - "-m", - help="Stake is sent to a hotkey only until the hotkey's total stake is less than or equal to this maximum staked TAO. If a hotkey already has stake greater than this amount, then stake is not added to this hotkey.", - ), - hotkey_ss58_address: str = typer.Option( - "", - help="The ss58 address of the hotkey to stake to.", - ), - include_hotkeys: str = typer.Option( - "", - "--include-hotkeys", - "-in", - help="Specifies hotkeys by name or ss58 address to stake to. For example, `-in hk1,hk2`", - ), - exclude_hotkeys: str = typer.Option( - "", - "--exclude-hotkeys", - "-ex", - help="Specifies hotkeys by name or ss58 address to not to stake to (use this option only with `--all-hotkeys`)" - " i.e. `--all-hotkeys -ex hk3,hk4`", - ), - all_hotkeys: bool = typer.Option( - False, - help="When set, this command stakes to all hotkeys associated with the wallet. Do not use if specifying " - "hotkeys in `--include-hotkeys`.", - ), - wallet_name: str = Options.wallet_name, - wallet_path: str = Options.wallet_path, - wallet_hotkey: str = Options.wallet_hotkey, - network: Optional[list[str]] = Options.network, - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - ): - """ - Stake TAO to one or more hotkeys associated with the user's coldkey. - - This command is used by a subnet validator to stake to their own hotkey. Compare this command with "btcli root delegate" that is typically run by a TAO holder to delegate their TAO to a delegate's hotkey. - - This command is used by a subnet validator to allocate stake TAO to their different hotkeys, securing their position and influence on the network. - - EXAMPLE - - [green]$[/green] btcli stake add --amount 100 --wallet-name --wallet-hotkey - """ - self.verbosity_handler(quiet, verbose) - if stake_all and amount: - print_error( - "Cannot specify an amount and 'stake-all'. Choose one or the other." - ) + if unstake_all and unstake_all_alpha: + print_error("Cannot specify both unstake-all and unstake-all-alpha.") raise typer.Exit() - if not stake_all and not amount and not max_stake: - amount = FloatPrompt.ask("Amount to [blue]stake (TAO τ)[/blue]") + if not interactive and not unstake_all and not unstake_all_alpha: + netuid = get_optional_netuid(netuid, all_netuids) + if all_hotkeys and include_hotkeys: + print_error( + "You have specified hotkeys to include and also the `--all-hotkeys` flag. The flag" + " should only be used standalone (to use all hotkeys) or with `--exclude-hotkeys`." + ) + raise typer.Exit() - if stake_all and not amount: - if not Confirm.ask("Stake all the available TAO tokens?", default=False): + if include_hotkeys and exclude_hotkeys: + print_error( + "You have specified both including and excluding hotkeys options. Select one or the other." + ) raise typer.Exit() - if all_hotkeys and include_hotkeys: - err_console.print( - "You have specified hotkeys to include and also the `--all-hotkeys` flag. The flag" - "should only be used standalone (to use all hotkeys) or with `--exclude-hotkeys`." - ) - raise typer.Exit() + if unstake_all and amount: + print_error( + "Cannot specify both a specific amount and 'unstake-all'. Choose one or the other." + ) + raise typer.Exit() - if include_hotkeys and exclude_hotkeys: - err_console.print( - "You have specified options for both including and excluding hotkeys. Select one or the other." - ) - raise typer.Exit() + if amount and amount <= 0: + print_error(f"You entered an incorrect unstake amount: {amount}") + raise typer.Exit() if ( not wallet_hotkey + and not hotkey_ss58_address and not all_hotkeys and not include_hotkeys - and not hotkey_ss58_address + and not interactive + and not unstake_all + and not unstake_all_alpha ): + if not wallet_name: + wallet_name = Prompt.ask( + "Enter the [blue]wallet name[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) hotkey_or_ss58 = Prompt.ask( - "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to stake to", + "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to unstake from [dim](or Press Enter to view existing staked hotkeys)[/dim]", ) - if is_valid_ss58_address(hotkey_or_ss58): + if hotkey_or_ss58 == "": + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + ) + interactive = True + elif is_valid_ss58_address(hotkey_or_ss58): hotkey_ss58_address = hotkey_or_ss58 wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] ) else: wallet_hotkey = hotkey_or_ss58 @@ -3364,214 +3196,478 @@ def stake_add( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - elif all_hotkeys or include_hotkeys or exclude_hotkeys or hotkey_ss58_address: + elif unstake_all or unstake_all_alpha: + if not wallet_name: + wallet_name = Prompt.ask( + "Enter the [blue]wallet name[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) + if include_hotkeys: + if len(include_hotkeys) > 1: + print_error("Cannot unstake_all from multiple hotkeys at once.") + raise typer.Exit() + elif is_valid_ss58_address(include_hotkeys[0]): + hotkey_ss58_address = include_hotkeys[0] + else: + print_error("Invalid hotkey ss58 address.") + raise typer.Exit() + else: + hotkey_or_ss58 = Prompt.ask( + "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to unstake all from", + default=self.config.get("wallet_hotkey") or defaults.wallet.hotkey, + ) + if is_valid_ss58_address(hotkey_or_ss58): + hotkey_ss58_address = hotkey_or_ss58 + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + ) + else: + wallet_hotkey = hotkey_or_ss58 + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + return self._run_command( + remove_stake.unstake_all( + wallet=wallet, + subtensor=self.initialize_chain(network), + hotkey_ss58_address=hotkey_ss58_address, + unstake_all_alpha=unstake_all_alpha, + prompt=prompt, + ) + ) + elif ( + all_hotkeys + or include_hotkeys + or exclude_hotkeys + or hotkey_ss58_address + or interactive + or unstake_all + or unstake_all_alpha + ): wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] ) else: wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) if include_hotkeys: - included_hotkeys = parse_to_list( + include_hotkeys = parse_to_list( include_hotkeys, str, - "Hotkeys must be a comma-separated list of ss58s or hotkey names, e.g., " - "`--include-hotkeys 5Grw....,5Grw....`.", + "Hotkeys must be a comma-separated list of ss58s or names, e.g., `--include-hotkeys hk1,hk2`.", + is_ss58=False, ) - else: - included_hotkeys = [] if exclude_hotkeys: - excluded_hotkeys = parse_to_list( + exclude_hotkeys = parse_to_list( exclude_hotkeys, str, - "Hotkeys must be a comma-separated list of ss58s or hotkey names, e.g., " - "`--exclude-hotkeys 5Grw....,5Grw....`.", + "Hotkeys must be a comma-separated list of ss58s or names, e.g., `--exclude-hotkeys hk3,hk4`.", + is_ss58=False, ) - else: - excluded_hotkeys = [] return self._run_command( - stake.stake_add( - wallet, - self.initialize_chain(network), - amount, - stake_all, - max_stake, - included_hotkeys, - excluded_hotkeys, - all_hotkeys, - prompt, - hotkey_ss58_address, + remove_stake.unstake( + wallet=wallet, + subtensor=self.initialize_chain(network), + hotkey_ss58_address=hotkey_ss58_address, + all_hotkeys=all_hotkeys, + include_hotkeys=include_hotkeys, + exclude_hotkeys=exclude_hotkeys, + amount=amount, + prompt=prompt, + interactive=interactive, + netuid=netuid, + safe_staking=safe_staking, + rate_tolerance=rate_tolerance, + allow_partial_stake=allow_partial_stake, ) ) - def stake_remove( + def stake_move( self, network: Optional[list[str]] = Options.network, - wallet_name: str = Options.wallet_name, - wallet_path: str = Options.wallet_path, - wallet_hotkey: str = Options.wallet_hotkey, - unstake_all: bool = typer.Option( - False, - "--unstake-all", - "--all", - help="When set, this commmand unstakes all staked TAO from the specified hotkeys.", - ), - amount: float = typer.Option( - 0.0, "--amount", "-a", help="The amount of TAO to unstake." + wallet_name=Options.wallet_name, + wallet_path=Options.wallet_path, + wallet_hotkey=Options.wallet_hotkey, + origin_netuid: Optional[int] = typer.Option( + None, "--origin-netuid", help="Origin netuid" ), - hotkey_ss58_address: str = typer.Option( - "", - help="The ss58 address of the hotkey to unstake from.", - ), - keep_stake: float = typer.Option( - 0.0, - "--keep-stake", - "--keep", - help="Sets the maximum amount of TAO to remain staked in each hotkey.", + destination_netuid: Optional[int] = typer.Option( + None, "--dest-netuid", help="Destination netuid" ), - include_hotkeys: str = typer.Option( - "", - "--include-hotkeys", - "-in", - help="Specifies the hotkeys by name or ss58 address to unstake from. For example, `-in hk1,hk2`", + destination_hotkey: Optional[str] = typer.Option( + None, "--dest-ss58", "--dest", help="Destination hotkey", prompt=False ), - exclude_hotkeys: str = typer.Option( - "", - "--exclude-hotkeys", - "-ex", - help="Specifies the hotkeys by name or ss58 address not to unstake from (only use with `--all-hotkeys`)" - " i.e. `--all-hotkeys -ex hk3,hk4`", + amount: float = typer.Option( + None, + "--amount", + help="The amount of TAO to stake", + prompt=False, ), - all_hotkeys: bool = typer.Option( - False, - help="When set, this command unstakes from all the hotkeys associated with the wallet. Do not use if specifying " - "hotkeys in `--include-hotkeys`.", + stake_all: bool = typer.Option( + False, "--stake-all", "--all", help="Stake all", prompt=False ), prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, ): """ - Unstake TAO from one or more hotkeys and transfer them back to the user's coldkey. + Move staked TAO between hotkeys while keeping the same coldkey ownership. - This command is used to withdraw TAO previously staked to different hotkeys. + This command allows you to: + - Move stake from one hotkey to another hotkey + - Move stake between different subnets + - Keep the same coldkey ownership - EXAMPLE + You can specify: + - The origin subnet (--origin-netuid) + - The destination subnet (--dest-netuid) + - The destination hotkey (--dest-hotkey) + - The amount to move (--amount) - [green]$[/green] btcli stake remove --amount 100 -in hk1,hk2 + If no arguments are provided, an interactive selection menu will be shown. - [blue bold]Note[/blue bold]: This command is for users who wish to reallocate their stake or withdraw them from the network. It allows for flexible management of TAO stake across different neurons (hotkeys) on the network. - """ - self.verbosity_handler(quiet, verbose) + EXAMPLE - if all_hotkeys and include_hotkeys: - err_console.print( - "You have specified hotkeys to include and also the `--all-hotkeys` flag. The flag" - "should only be used standalone (to use all hotkeys) or with `--exclude-hotkeys`." + [green]$[/green] btcli stake move + """ + console.print( + "[dim]This command moves stake from one hotkey to another hotkey while keeping the same coldkey.[/dim]" + ) + if not destination_hotkey: + dest_wallet_or_ss58 = Prompt.ask( + "Enter the [blue]destination wallet[/blue] where destination hotkey is located or [blue]ss58 address[/blue]" ) - raise typer.Exit() + if is_valid_ss58_address(dest_wallet_or_ss58): + destination_hotkey = dest_wallet_or_ss58 + else: + dest_wallet = self.wallet_ask( + dest_wallet_or_ss58, + wallet_path, + None, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + destination_hotkey = Prompt.ask( + "Enter the [blue]destination hotkey[/blue] name", + default=dest_wallet.hotkey_str, + ) + destination_wallet = self.wallet_ask( + dest_wallet_or_ss58, + wallet_path, + destination_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + destination_hotkey = destination_wallet.hotkey.ss58_address + else: + if is_valid_ss58_address(destination_hotkey): + destination_hotkey = destination_hotkey + else: + print_error( + "Invalid destination hotkey ss58 address. Please enter a valid ss58 address or wallet name." + ) + raise typer.Exit() - if include_hotkeys and exclude_hotkeys: - err_console.print( - "You have specified both including and excluding hotkeys options. Select one or the other." + if not wallet_name: + wallet_name = Prompt.ask( + "Enter the [blue]origin wallet name[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, ) - raise typer.Exit() + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + ) - if unstake_all and amount: - err_console.print( - "Cannot specify both a specific amount and 'unstake-all'. Choose one or the other." + interactive_selection = False + if not wallet_hotkey: + origin_hotkey = Prompt.ask( + "Enter the [blue]origin hotkey[/blue] name or " + "[blue]ss58 address[/blue] where the stake will be moved from " + "[dim](or Press Enter to view existing stakes)[/dim]" ) - raise typer.Exit() - - if not unstake_all and not amount and not keep_stake: - amount = FloatPrompt.ask("Amount to [blue]unstake (TAO τ)[/blue]") + if origin_hotkey == "": + interactive_selection = True - if unstake_all and not amount and prompt: - if not Confirm.ask("Unstake all staked TAO tokens?", default=False): - raise typer.Exit() - - if ( - not wallet_hotkey - and not hotkey_ss58_address - and not all_hotkeys - and not include_hotkeys - ): - hotkey_or_ss58 = Prompt.ask( - "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to unstake from" - ) - if is_valid_ss58_address(hotkey_or_ss58): - hotkey_ss58_address = hotkey_or_ss58 + elif is_valid_ss58_address(origin_hotkey): + origin_hotkey = origin_hotkey + else: wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] + wallet_name, + wallet_path, + origin_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, ) + origin_hotkey = wallet.hotkey.ss58_address + else: + if is_valid_ss58_address(wallet_hotkey): + origin_hotkey = wallet_hotkey else: - wallet_hotkey = hotkey_or_ss58 wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[], validate=WV.WALLET_AND_HOTKEY, ) + origin_hotkey = wallet.hotkey.ss58_address - elif all_hotkeys or include_hotkeys or exclude_hotkeys or hotkey_ss58_address: - wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] + if not interactive_selection: + if origin_netuid is None: + origin_netuid = IntPrompt.ask( + "Enter the [blue]origin subnet[/blue] (netuid) to move stake from" + ) + + if destination_netuid is None: + destination_netuid = IntPrompt.ask( + "Enter the [blue]destination subnet[/blue] (netuid) to move stake to" + ) + + return self._run_command( + move_stake.move_stake( + subtensor=self.initialize_chain(network), + wallet=wallet, + origin_netuid=origin_netuid, + origin_hotkey=origin_hotkey, + destination_netuid=destination_netuid, + destination_hotkey=destination_hotkey, + amount=amount, + stake_all=stake_all, + interactive_selection=interactive_selection, + prompt=prompt, + ) + ) + + def stake_transfer( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + origin_netuid: Optional[int] = typer.Option( + None, + "--origin-netuid", + help="The netuid to transfer stake from", + ), + dest_netuid: Optional[int] = typer.Option( + None, + "--dest-netuid", + help="The netuid to transfer stake to", + ), + dest_ss58: Optional[str] = typer.Option( + None, + "--dest-ss58", + "--dest", + "--dest-coldkey", + help="The destination wallet name or SS58 address to transfer stake to", + ), + amount: float = typer.Option( + None, + "--amount", + "-a", + help="Amount of stake to transfer", + ), + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Transfer stake between coldkeys while keeping the same hotkey ownership. + + This command allows you to: + - Transfer stake from one coldkey to another coldkey + - Keep the same hotkey ownership + - Transfer stake between different subnets + + You can specify: + - The origin subnet (--origin-netuid) + - The destination subnet (--dest-netuid) + - The destination wallet/address (--dest) + - The amount to transfer (--amount) + + If no arguments are provided, an interactive selection menu will be shown. + + EXAMPLE + + Transfer 100 TAO from subnet 1 to subnet 2: + [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --dest wallet2 --amount 100 + + Using SS58 address: + [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --dest 5FrLxJsyJ5x9n2rmxFwosFraxFCKcXZDngEP9H7qjkKgHLcK --amount 100 + """ + console.print( + "[dim]This command transfers stake from one coldkey to another while keeping the same hotkey.[/dim]" + ) + self.verbosity_handler(quiet, verbose) + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + + if not dest_ss58: + dest_ss58 = Prompt.ask( + "Enter the [blue]destination wallet name[/blue] or [blue]coldkey SS58 address[/blue]" ) + if is_valid_ss58_address(dest_ss58): + dest_ss58 = dest_ss58 else: - wallet = self.wallet_ask( - wallet_name, + dest_wallet = self.wallet_ask( + dest_ss58, wallet_path, - wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, + None, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, ) + dest_ss58 = dest_wallet.coldkeypub.ss58_address - if include_hotkeys: - included_hotkeys = parse_to_list( - include_hotkeys, - str, - "Hotkeys must be a comma-separated list of ss58s or hotkey names, e.g., " - "`--include-hotkeys 5Grw....,5Grw....`.", - ) + interactive_selection = False + if origin_netuid is None and dest_netuid is None and not amount: + interactive_selection = True else: - included_hotkeys = [] + if origin_netuid is None: + origin_netuid = IntPrompt.ask( + "Enter the [blue]origin subnet[/blue] (netuid)" + ) + if not amount: + amount = FloatPrompt.ask("Enter the [blue]amount[/blue] to transfer") - if exclude_hotkeys: - excluded_hotkeys = parse_to_list( - exclude_hotkeys, - str, - "Hotkeys must be a comma-separated list of ss58s or hotkey names, e.g., " - "`--exclude-hotkeys 5Grw....,5Grw....`.", + if dest_netuid is None: + dest_netuid = IntPrompt.ask( + "Enter the [blue]destination subnet[/blue] (netuid)" + ) + + return self._run_command( + move_stake.transfer_stake( + wallet=wallet, + subtensor=self.initialize_chain(network), + origin_netuid=origin_netuid, + dest_netuid=dest_netuid, + dest_coldkey_ss58=dest_ss58, + amount=amount, + interactive_selection=interactive_selection, + prompt=prompt, ) + ) + + def stake_swap( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + origin_netuid: Optional[int] = typer.Option( + None, + "--origin-netuid", + "-o", + "--origin", + help="The netuid to swap stake from", + ), + dest_netuid: Optional[int] = typer.Option( + None, + "--dest-netuid", + "-d", + "--dest", + help="The netuid to swap stake to", + ), + amount: float = typer.Option( + None, + "--amount", + "-a", + help="Amount of stake to swap", + ), + swap_all: bool = typer.Option( + False, + "--swap-all", + "--all", + help="Swap all available stake", + ), + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Swap stake between different subnets while keeping the same coldkey-hotkey pair ownership. + + This command allows you to: + - Move stake from one subnet to another subnet + - Keep the same coldkey ownership + - Keep the same hotkey ownership + + You can specify: + - The origin subnet (--origin-netuid) + - The destination subnet (--dest-netuid) + - The amount to swap (--amount) + + If no arguments are provided, an interactive selection menu will be shown. + + EXAMPLE + + Swap 100 TAO from subnet 1 to subnet 2: + [green]$[/green] btcli stake swap --wallet-name default --wallet-hotkey default --origin-netuid 1 --dest-netuid 2 --amount 100 + """ + console.print( + "[dim]This command moves stake from one subnet to another subnet while keeping the same coldkey-hotkey pair.[/dim]" + ) + self.verbosity_handler(quiet, verbose) + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + + interactive_selection = False + if origin_netuid is None and dest_netuid is None and not amount: + interactive_selection = True else: - excluded_hotkeys = [] + if origin_netuid is None: + origin_netuid = IntPrompt.ask( + "Enter the [blue]origin subnet[/blue] (netuid)" + ) + if dest_netuid is None: + dest_netuid = IntPrompt.ask( + "Enter the [blue]destination subnet[/blue] (netuid)" + ) + if not amount and not swap_all: + amount = FloatPrompt.ask("Enter the [blue]amount[/blue] to swap") return self._run_command( - stake.unstake( - wallet, - self.initialize_chain(network), - hotkey_ss58_address, - all_hotkeys, - included_hotkeys, - excluded_hotkeys, - amount, - keep_stake, - unstake_all, - prompt, + move_stake.swap_stake( + wallet=wallet, + subtensor=self.initialize_chain(network), + origin_netuid=origin_netuid, + destination_netuid=dest_netuid, + amount=amount, + swap_all=swap_all, + interactive_selection=interactive_selection, + prompt=prompt, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, ) ) @@ -3611,7 +3707,7 @@ def stake_get_children( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) @@ -3642,23 +3738,12 @@ def stake_set_children( wallet_hotkey: str = Options.wallet_hotkey, wallet_path: str = Options.wallet_path, network: Optional[list[str]] = Options.network, - netuid: Optional[int] = typer.Option( - None, - help="The netuid of the subnet, (e.g. 4)", - prompt=False, - ), - all_netuids: bool = typer.Option( - False, - "--all-netuids", - "--all", - "--allnetuids", - help="When this flag is used it sets child hotkeys on all subnets.", - ), + netuid: Optional[int] = Options.netuid_not_req, + all_netuids: bool = Options.all_netuids, proportions: list[float] = typer.Option( [], "--proportions", "--prop", - "-p", help="Enter the stake weight proportions for the child hotkeys (sum should be less than or equal to 1)", prompt=False, ), @@ -3678,17 +3763,10 @@ def stake_set_children( EXAMPLE [green]$[/green] btcli stake child set -c 5FCL3gmjtQV4xxxxuEPEFQVhyyyyqYgNwX7drFLw7MSdBnxP -c 5Hp5dxxxxtGg7pu8dN2btyyyyVA1vELmM9dy8KQv3LxV8PA7 --hotkey default --netuid 1 -p 0.3 -p 0.7 - """ - self.verbosity_handler(quiet, verbose) - if all_netuids and netuid: - err_console.print("Specify either a netuid or `--all`, not both.") - raise typer.Exit() - if all_netuids: - netuid = None - elif not netuid: - netuid = IntPrompt.ask( - "Enter a netuid (leave blank for all)", default=None, show_default=True - ) + """ + self.verbosity_handler(quiet, verbose) + netuid = get_optional_netuid(netuid, all_netuids) + children = list_prompt( children, str, @@ -3896,7 +3974,8 @@ def sudo_set( if not param_name or not param_value: hyperparams = self._run_command( - sudo.get_hyperparameters(self.initialize_chain(network), netuid) + sudo.get_hyperparameters(self.initialize_chain(network), netuid), + exit_early=False, ) if not hyperparams: raise typer.Exit() @@ -3926,11 +4005,11 @@ def sudo_set( if not param_value: param_value = Prompt.ask( - f"Enter the new value for [dark_orange]{param_name}[/dark_orange] in the VALUE column format" + f"Enter the new value for [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{param_name}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] in the VALUE column format" ) wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME] + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] ) return self._run_command( sudo.sudo_set_hyperparameter( @@ -3952,8 +4031,6 @@ def sudo_get( """ Shows a list of the hyperparameters for the specified subnet. - The output of this command is the same as that of `btcli subnets hyperparameters`. - EXAMPLE [green]$[/green] btcli sudo get --netuid 1 @@ -3963,98 +4040,448 @@ def sudo_get( sudo.get_hyperparameters(self.initialize_chain(network), netuid) ) - def subnets_list( + def sudo_senate( self, network: Optional[list[str]] = Options.network, - reuse_last: bool = Options.reuse_last, - html_output: bool = Options.html_output, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): """ - List all subnets and their detailed information. + Shows the Senate members of the Bittensor's governance protocol. + + This command lists the delegates involved in the decision-making process of the Bittensor network, showing their names and wallet addresses. This information is crucial for understanding who holds governance roles within the network. + + EXAMPLE + [green]$[/green] btcli sudo senate + """ + self.verbosity_handler(quiet, verbose) + return self._run_command(sudo.get_senate(self.initialize_chain(network))) - This command displays a table with the below columns: + def sudo_proposals( + self, + network: Optional[list[str]] = Options.network, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + View active proposals for the senate in the Bittensor's governance protocol. - - NETUID: The subnet's netuid. - - N: The number of neurons (subnet validators and subnet miners) in the subnet. - - MAX_N: The maximum allowed number of neurons in the subnet. - - EMISSION: The percentage of emissions to the subnet as of the last tempo. - - TEMPO: The subnet's tempo, expressed in number of blocks. - - RECYCLE: The recycle register cost for this subnet. - - POW: The proof of work (PoW) difficulty. - - SUDO: The subnet owner's name or the owner's ss58 address. + This command displays the details of ongoing proposals, including proposal hashes, votes, thresholds, and proposal data. EXAMPLE + [green]$[/green] btcli sudo proposals + """ + self.verbosity_handler(quiet, verbose) + return self._run_command( + sudo.proposals(self.initialize_chain(network), verbose) + ) - [green]$[/green] btcli subnets list + def sudo_senate_vote( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + proposal: str = typer.Option( + None, + "--proposal", + "--proposal-hash", + prompt="Enter the proposal hash", + help="The hash of the proposal to vote on.", + ), + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + vote: bool = typer.Option( + None, + "--vote-aye/--vote-nay", + prompt="Enter y to vote Aye, or enter n to vote Nay", + help="The vote casted on the proposal", + ), + ): + """ + Cast a vote on an active proposal in Bittensor's governance protocol. + + This command is used by Senate members to vote on various proposals that shape the network's future. Use `btcli sudo proposals` to see the active proposals and their hashes. + + USAGE + The user must specify the hash of the proposal they want to vote on. The command then allows the Senate member to cast a 'Yes' or 'No' vote, contributing to the decision-making process on the proposal. This command is crucial for Senate members to exercise their voting rights on key proposals. It plays a vital role in the governance and evolution of the Bittensor network. + + EXAMPLE + [green]$[/green] btcli sudo senate_vote --proposal """ self.verbosity_handler(quiet, verbose) - if (reuse_last or html_output) and self.config.get("use_cache") is False: - err_console.print( - "Unable to use `--reuse-last` or `--html` when config 'no-cache' is set to 'True'. " - "Change the config to 'False' using `btcli config set`." + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + return self._run_command( + sudo.senate_vote( + wallet, self.initialize_chain(network), proposal, vote, prompt + ) + ) + + def sudo_set_take( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + take: float = typer.Option(None, help="The new take value."), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Allows users to change their delegate take percentage. + + This command can be used to update the delegate takes. To run the command, the user must have a configured wallet with both hotkey and coldkey. + The command makes sure the new take value is within 0-18% range. + + EXAMPLE + [green]$[/green] btcli sudo set-take --wallet-name my_wallet --wallet-hotkey my_hotkey + """ + max_value = 0.18 + min_value = 0.00 + self.verbosity_handler(quiet, verbose) + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + + self._run_command( + sudo.display_current_take(self.initialize_chain(network), wallet), + exit_early=False, + ) + + if not take: + take = FloatPrompt.ask( + f"Enter [blue]take value[/blue] (0.18 for 18%) [blue]Min: {min_value} Max: {max_value}" + ) + if not (min_value <= take <= max_value): + print_error( + f"Take value must be between {min_value} and {max_value}. Provided value: {take}" ) raise typer.Exit() - if reuse_last: - subtensor = None - else: - subtensor = self.initialize_chain(network) + + return self._run_command( + sudo.set_take(wallet, self.initialize_chain(network), take) + ) + + def sudo_get_take( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Allows users to check their delegate take percentage. + + This command can be used to fetch the delegate take of your hotkey. + + EXAMPLE + [green]$[/green] btcli sudo get-take --wallet-name my_wallet --wallet-hotkey my_hotkey + """ + self.verbosity_handler(quiet, verbose) + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + + self._run_command( + sudo.display_current_take(self.initialize_chain(network), wallet) + ) + + def subnets_list( + self, + network: Optional[list[str]] = Options.network, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + live_mode: bool = Options.live, + ): + """ + List all subnets and their detailed information. + + [bold]Common Examples:[/bold] + + 1. List all subnets: + [green]$[/green] btcli subnets list + + 2. List all subnets in live mode: + [green]$[/green] btcli subnets list --live + + [bold]Output Columns:[/bold] + • [white]Netuid[/white] - Subnet identifier number + • [white]Name[/white] - Subnet name with currency symbol (τ/α/β etc) + • [white]Price (τ_in/α_in)[/white] - Exchange rate (TAO per alpha token) + • [white]Market Cap (α * Price)[/white] - Total value in TAO (alpha tokens × price) + • [white]Emission (τ)[/white] - TAO rewards emitted per block to subnet + • [white]P (τ_in, α_in)[/white] - Pool reserves (Tao reserves, alpha reserves) in liquidity pool + • [white]Stake (α_out)[/white] - Total staked alpha tokens across all hotkeys (alpha outstanding) + • [white]Supply (α)[/white] - Circulating alpha token supply + • [white]Tempo (k/n)[/white] - Block interval for subnet updates + + EXAMPLE + + [green]$[/green] btcli subnets list + """ + self.verbosity_handler(quiet, verbose) + subtensor = self.initialize_chain(network) return self._run_command( subnets.subnets_list( subtensor, - reuse_last, - html_output, + False, # reuse-last + False, # html-output not self.config.get("use_cache", True), + verbose, + live_mode, + ) + ) + + def subnets_price( + self, + network: Optional[list[str]] = Options.network, + netuids: str = typer.Option( + None, + "--netuids", + "--netuid", + "-n", + help="Netuid(s) to show the price for.", + ), + interval_hours: int = typer.Option( + 24, + "--interval-hours", + "--interval", + help="The number of hours to show the historical price for.", + ), + all_netuids: bool = typer.Option( + False, + "--all-netuids", + "--all", + help="Show the price for all subnets.", + ), + log_scale: bool = typer.Option( + False, + "--log-scale", + "--log", + help="Show the price in log scale.", + ), + html_output: bool = Options.html_output, + ): + """ + Shows the historical price of a subnet for the past 24 hours. + + This command displays the historical price of a subnet for the past 24 hours. + If the `--all` flag is used, the command will display the price for all subnets in html format. + If the `--html` flag is used, the command will display the price in an HTML chart. + If the `--log-scale` flag is used, the command will display the price in log scale. + If no html flag is used, the command will display the price in the cli. + + EXAMPLE + + [green]$[/green] btcli subnets price --netuid 1 + [green]$[/green] btcli subnets price --netuid 1 --html --log + [green]$[/green] btcli subnets price --all --html + [green]$[/green] btcli subnets price --netuids 1,2,3,4 --html + """ + if netuids: + netuids = parse_to_list( + netuids, + int, + "Netuids must be a comma-separated list of ints, e.g., `--netuids 1,2,3,4`.", + ) + if all_netuids and netuids: + print_error("Cannot specify both --netuid and --all-netuids") + raise typer.Exit() + + if not netuids and not all_netuids: + netuids = Prompt.ask( + "Enter the [blue]netuid(s)[/blue] to view the price of in comma-separated format [dim](or Press Enter to view all subnets)[/dim]", + ) + if not netuids: + all_netuids = True + html_output = True + else: + netuids = parse_to_list( + netuids, + int, + "Netuids must be a comma-separated list of ints, e.g., `--netuids 1,2,3,4`.", + ) + + if all_netuids: + html_output = True + + if html_output and is_linux(): + print_linux_dependency_message() + + return self._run_command( + price.price( + self.initialize_chain(network), + netuids, + all_netuids, + interval_hours, + html_output, + log_scale, + ) + ) + + def subnets_show( + self, + network: Optional[list[str]] = Options.network, + netuid: int = Options.netuid, + sort: bool = typer.Option( + False, + "--sort", + help="Sort the subnets by uid.", + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + prompt: bool = Options.prompt, + ): + """ + Displays detailed information about a subnet including participants and their state. + + EXAMPLE + + [green]$[/green] btcli subnets list + """ + self.verbosity_handler(quiet, verbose) + subtensor = self.initialize_chain(network) + return self._run_command( + subnets.show( + subtensor=subtensor, + netuid=netuid, + sort=sort, + max_rows=None, + delegate_selection=False, + verbose=verbose, + prompt=prompt, ) ) - def subnets_lock_cost( + def subnets_burn_cost( self, network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): """ - Shows the required amount of TAO to be locked for creating a new subnet, i.e., cost of registering a new subnet. + Shows the required amount of TAO to be recycled for creating a new subnet, i.e., cost of registering a new subnet. The current implementation anneals the cost of creating a subnet over a period of two days. If the displayed cost is unappealing to you, check back in a day or two to see if it has decreased to a more affordable level. EXAMPLE - [green]$[/green] btcli subnets lock_cost + [green]$[/green] btcli subnets burn_cost """ self.verbosity_handler(quiet, verbose) - return self._run_command(subnets.lock_cost(self.initialize_chain(network))) + return self._run_command(subnets.burn_cost(self.initialize_chain(network))) def subnets_create( self, wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, + subnet_name: Optional[str] = typer.Option( + None, "--subnet-name", "--name", help="Name of the subnet" + ), + github_repo: Optional[str] = typer.Option( + None, "--github-repo", "--repo", help="GitHub repository URL" + ), + subnet_contact: Optional[str] = typer.Option( + None, + "--subnet-contact", + "--contact", + "--email", + help="Contact email for subnet", + ), + subnet_url: Optional[str] = typer.Option( + None, "--subnet-url", "--url", help="Subnet URL" + ), + discord: Optional[str] = typer.Option( + None, "--discord-handle", "--discord", help="Discord handle" + ), + description: Optional[str] = typer.Option( + None, "--description", help="Description" + ), + additional_info: Optional[str] = typer.Option( + None, "--additional-info", help="Additional information" + ), prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): """ - Registers a new subnet. + Registers a new subnet on the network. - EXAMPLE + This command allows you to create a new subnet and set the subnet's identity. + You also have the option to set your own identity after the registration is complete. + + [bold]Common Examples:[/bold] + 1. Interactive subnet creation: [green]$[/green] btcli subnets create + + 2. Create with GitHub repo and contact email: + [green]$[/green] btcli subnets create --subnet-name MySubnet --github-repo https://github.com/myorg/mysubnet --subnet-contact team@mysubnet.net """ self.verbosity_handler(quiet, verbose) wallet = self.wallet_ask( wallet_name, wallet_path, - None, - ask_for=[WO.NAME], - validate=WV.WALLET, + wallet_hotkey, + ask_for=[ + WO.NAME, + WO.HOTKEY, + WO.PATH, + ], + validate=WV.WALLET_AND_HOTKEY, ) - return self._run_command( - subnets.create(wallet, self.initialize_chain(network), prompt) + identity = prompt_for_subnet_identity( + subnet_name=subnet_name, + github_repo=github_repo, + subnet_contact=subnet_contact, + subnet_url=subnet_url, + discord=discord, + description=description, + additional=additional_info, + ) + success = self._run_command( + subnets.create(wallet, self.initialize_chain(network), identity, prompt), + exit_early=False, ) + if success and prompt: + set_id = Confirm.ask( + "[dark_sea_green3]Do you want to set/update your identity?", + default=False, + show_default=True, + ) + if set_id: + self.wallet_set_id( + wallet_name=wallet.name, + wallet_hotkey=wallet.hotkey, + wallet_path=wallet.path, + network=network, + prompt=prompt, + quiet=quiet, + verbose=verbose, + ) + def subnets_pow_register( self, wallet_name: Optional[str] = Options.wallet_name, @@ -4128,7 +4555,7 @@ def subnets_pow_register( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ), self.initialize_chain(network), @@ -4257,6 +4684,12 @@ def subnets_metagraph( ) raise typer.Exit() + # For Rao games + effective_network = get_effective_network(self.config, network) + if is_rao_network(effective_network): + print_error("This command is disabled on the 'rao' network.") + raise typer.Exit() + if reuse_last: if netuid is not None: console.print("Cannot specify netuid when using `--reuse-last`") @@ -4359,7 +4792,7 @@ def weights_reveal( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) @@ -4457,7 +4890,7 @@ def weights_commit( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY], + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) return self._run_command( diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 0d3e67de..23a9a114 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -4,17 +4,33 @@ class Constants: - networks = ["local", "finney", "test", "archive", "subvortex"] + networks = [ + "local", + "finney", + "test", + "archive", + "subvortex", + "rao", + "dev", + "latent-lite", + ] finney_entrypoint = "wss://entrypoint-finney.opentensor.ai:443" finney_test_entrypoint = "wss://test.finney.opentensor.ai:443" archive_entrypoint = "wss://archive.chain.opentensor.ai:443" subvortex_entrypoint = "ws://subvortex.info:9944" local_entrypoint = "ws://127.0.0.1:9944" + rao_entrypoint = "wss://rao.chain.opentensor.ai:443" + dev_entrypoint = "wss://dev.chain.opentensor.ai:443 " + local_entrypoint = "ws://127.0.0.1:9944" + latent_lite_entrypoint = "wss://lite.sub.latent.to:443" network_map = { "finney": finney_entrypoint, "test": finney_test_entrypoint, "archive": archive_entrypoint, "local": local_entrypoint, + "dev": dev_entrypoint, + "rao": rao_entrypoint, + "latent-lite": latent_lite_entrypoint, "subvortex": subvortex_entrypoint, } delegates_detail_url = "https://raw.githubusercontent.com/opentensor/bittensor-delegates/main/public/delegates.json" @@ -63,6 +79,7 @@ def decode(key: str, default=""): class Defaults: netuid = 1 + rate_tolerance = 0.005 class config: base_path = "~/.bittensor" @@ -75,7 +92,9 @@ class config: "use_cache": True, "metagraph_cols": { "UID": True, - "STAKE": True, + "GLOBAL_STAKE": True, + "LOCAL_STAKE": True, + "STAKE_WEIGHT": True, "RANK": True, "TRUST": True, "CONSENSUS": True, @@ -139,170 +158,476 @@ class WalletValidationTypes(Enum): "types": { "Balance": "u64", # Need to override default u128 }, - "runtime_api": { - "DelegateInfoRuntimeApi": { - "methods": { - "get_delegated": { - "params": [ - { - "name": "coldkey", - "type": "Vec", - }, - ], - "type": "Vec", - }, - "get_delegates": { - "params": [], - "type": "Vec", - }, - } - }, - "NeuronInfoRuntimeApi": { - "methods": { - "get_neuron_lite": { - "params": [ - { - "name": "netuid", - "type": "u16", - }, - { - "name": "uid", - "type": "u16", - }, - ], - "type": "Vec", - }, - "get_neurons_lite": { - "params": [ - { - "name": "netuid", - "type": "u16", - }, - ], - "type": "Vec", - }, - "get_neuron": { - "params": [ - { - "name": "netuid", - "type": "u16", - }, - { - "name": "uid", - "type": "u16", - }, - ], - "type": "Vec", - }, - "get_neurons": { - "params": [ - { - "name": "netuid", - "type": "u16", - }, - ], - "type": "Vec", - }, - } - }, - "StakeInfoRuntimeApi": { - "methods": { - "get_stake_info_for_coldkey": { - "params": [ - { - "name": "coldkey_account_vec", - "type": "Vec", - }, - ], - "type": "Vec", - }, - "get_stake_info_for_coldkeys": { - "params": [ - { - "name": "coldkey_account_vecs", - "type": "Vec>", - }, - ], - "type": "Vec", - }, - }, - }, - "ValidatorIPRuntimeApi": { - "methods": { - "get_associated_validator_ip_info_for_subnet": { - "params": [ - { - "name": "netuid", - "type": "u16", - }, - ], - "type": "Vec", - }, - }, - }, - "SubnetInfoRuntimeApi": { - "methods": { - "get_subnet_hyperparams": { - "params": [ - { - "name": "netuid", - "type": "u16", - }, - ], - "type": "Vec", - }, - "get_subnet_info": { - "params": [ - { - "name": "netuid", - "type": "u16", - }, - ], - "type": "Vec", - }, - "get_subnets_info": { - "params": [], - "type": "Vec", - }, - } - }, - "SubnetRegistrationRuntimeApi": { - "methods": {"get_network_registration_cost": {"params": [], "type": "u64"}} - }, - "ColdkeySwapRuntimeApi": { - "methods": { - "get_scheduled_coldkey_swap": { - "params": [ - { - "name": "coldkey_account_vec", - "type": "Vec", - }, - ], - "type": "Vec", - }, - "get_remaining_arbitration_period": { - "params": [ - { - "name": "coldkey_account_vec", - "type": "Vec", - }, - ], - "type": "Vec", - }, - "get_coldkey_swap_destinations": { - "params": [ - { - "name": "coldkey_account_vec", - "type": "Vec", - }, - ], - "type": "Vec", - }, - } - }, - }, } +UNITS = [ + # Greek Alphabet (0-24) + "\u03c4", # τ (tau, 0) + "\u03b1", # α (alpha, 1) + "\u03b2", # β (beta, 2) + "\u03b3", # γ (gamma, 3) + "\u03b4", # δ (delta, 4) + "\u03b5", # ε (epsilon, 5) + "\u03b6", # ζ (zeta, 6) + "\u03b7", # η (eta, 7) + "\u03b8", # θ (theta, 8) + "\u03b9", # ι (iota, 9) + "\u03ba", # κ (kappa, 10) + "\u03bb", # λ (lambda, 11) + "\u03bc", # μ (mu, 12) + "\u03bd", # ν (nu, 13) + "\u03be", # ξ (xi, 14) + "\u03bf", # ο (omicron, 15) + "\u03c0", # π (pi, 16) + "\u03c1", # ρ (rho, 17) + "\u03c3", # σ (sigma, 18) + "t", # t (tau, 19) + "\u03c5", # υ (upsilon, 20) + "\u03c6", # φ (phi, 21) + "\u03c7", # χ (chi, 22) + "\u03c8", # ψ (psi, 23) + "\u03c9", # ω (omega, 24) + # Hebrew Alphabet (25-51) + "\u05d0", # א (aleph, 25) + "\u05d1", # ב (bet, 26) + "\u05d2", # ג (gimel, 27) + "\u05d3", # ד (dalet, 28) + "\u05d4", # ה (he, 29) + "\u05d5", # ו (vav, 30) + "\u05d6", # ז (zayin, 31) + "\u05d7", # ח (het, 32) + "\u05d8", # ט (tet, 33) + "\u05d9", # י (yod, 34) + "\u05da", # ך (final kaf, 35) + "\u05db", # כ (kaf, 36) + "\u05dc", # ל (lamed, 37) + "\u05dd", # ם (final mem, 38) + "\u05de", # מ (mem, 39) + "\u05df", # ן (final nun, 40) + "\u05e0", # נ (nun, 41) + "\u05e1", # ס (samekh, 42) + "\u05e2", # ע (ayin, 43) + "\u05e3", # ף (final pe, 44) + "\u05e4", # פ (pe, 45) + "\u05e5", # ץ (final tsadi, 46) + "\u05e6", # צ (tsadi, 47) + "\u05e7", # ק (qof, 48) + "\u05e8", # ר (resh, 49) + "\u05e9", # ש (shin, 50) + "\u05ea", # ת (tav, 51) + # Arabic Alphabet (52-81) + "\u0627", # ا (alif, 52) + "\u0628", # ب (ba, 53) + "\u062a", # ت (ta, 54) + "\u062b", # ث (tha, 55) + "\u062c", # ج (jeem, 56) + "\u062d", # ح (ha, 57) + "\u062e", # خ (kha, 58) + "\u062f", # د (dal, 59) + "\u0630", # ذ (dhal, 60) + "\u0631", # ر (ra, 61) + "\u0632", # ز (zay, 62) + "\u0633", # س (seen, 63) + "\u0634", # ش (sheen, 64) + "\u0635", # ص (sad, 65) + "\u0636", # ض (dad, 66) + "\u0637", # ط (ta, 67) + "\u0638", # ظ (dha, 68) + "\u0639", # ع (ain, 69) + "\u063a", # غ (ghain, 70) + "\u0641", # ف (fa, 71) + "\u0642", # ق (qaf, 72) + "\u0643", # ك (kaf, 73) + "\u0644", # ل (lam, 74) + "\u0645", # م (meem, 75) + "\u0646", # ن (noon, 76) + "\u0647", # ه (ha, 77) + "\u0648", # و (waw, 78) + "\u064a", # ي (ya, 79) + "\u0649", # ى (alef maksura, 80) + "\u064a", # ي (ya, 81) + # Runic Alphabet (82-90) + "\u16a0", # ᚠ (fehu, 82) + "\u16a2", # ᚢ (uruz, 83) + "\u16a6", # ᚦ (thurisaz, 84) + "\u16a8", # ᚨ (ansuz, 85) + "\u16b1", # ᚱ (raidho, 86) + "\u16b3", # ᚲ (kaunan, 87) + "\u16c7", # ᛇ (eihwaz, 88) + "\u16c9", # ᛉ (algiz, 89) + "\u16d2", # ᛒ (berkanan, 90) + # Ogham Alphabet (91-97) + "\u1680", #   (Space, 91) + "\u1681", # ᚁ (Beith, 92) + "\u1682", # ᚂ (Luis, 93) + "\u1683", # ᚃ (Fearn, 94) + "\u1684", # ᚄ (Sail, 95) + "\u1685", # ᚅ (Nion, 96) + "\u169b", # ᚛ (Forfeda, 97) + # Georgian Alphabet (98-103) + "\u10d0", # ა (ani, 98) + "\u10d1", # ბ (bani, 99) + "\u10d2", # გ (gani, 100) + "\u10d3", # დ (doni, 101) + "\u10d4", # ე (eni, 102) + "\u10d5", # ვ (vini, 103) + # Armenian Alphabet (104-110) + "\u0531", # Ա (Ayp, 104) + "\u0532", # Բ (Ben, 105) + "\u0533", # Գ (Gim, 106) + "\u0534", # Դ (Da, 107) + "\u0535", # Ե (Ech, 108) + "\u0536", # Զ (Za, 109) + "\u055e", # ՞ (Question mark, 110) + # Cyrillic Alphabet (111-116) + "\u0400", # Ѐ (Ie with grave, 111) + "\u0401", # Ё (Io, 112) + "\u0402", # Ђ (Dje, 113) + "\u0403", # Ѓ (Gje, 114) + "\u0404", # Є (Ukrainian Ie, 115) + "\u0405", # Ѕ (Dze, 116) + # Coptic Alphabet (117-122) + "\u2c80", # Ⲁ (Alfa, 117) + "\u2c81", # ⲁ (Small Alfa, 118) + "\u2c82", # Ⲃ (Vida, 119) + "\u2c83", # ⲃ (Small Vida, 120) + "\u2c84", # Ⲅ (Gamma, 121) + "\u2c85", # ⲅ (Small Gamma, 122) + # Brahmi Script (123-127) + "\U00011000", # 𑀀 (A, 123) + "\U00011001", # 𑀁 (Aa, 124) + "\U00011002", # 𑀂 (I, 125) + "\U00011003", # 𑀃 (Ii, 126) + "\U00011005", # 𑀅 (U, 127) + # Tifinagh Alphabet (128-133) + "\u2d30", # ⴰ (Ya, 128) + "\u2d31", # ⴱ (Yab, 129) + "\u2d32", # ⴲ (Yabh, 130) + "\u2d33", # ⴳ (Yag, 131) + "\u2d34", # ⴴ (Yagh, 132) + "\u2d35", # ⴵ (Yaj, 133) + # Glagolitic Alphabet (134-166) + "\u2c00", # Ⰰ (Az, 134) + "\u2c01", # Ⰱ (Buky, 135) + "\u2c02", # Ⰲ (Vede, 136) + "\u2c03", # Ⰳ (Glagoli, 137) + "\u2c04", # Ⰴ (Dobro, 138) + "\u2c05", # Ⰵ (Yest, 139) + "\u2c06", # Ⰶ (Zhivete, 140) + "\u2c07", # Ⰷ (Zemlja, 141) + "\u2c08", # Ⰸ (Izhe, 142) + "\u2c09", # Ⰹ (Initial Izhe, 143) + "\u2c0a", # Ⰺ (I, 144) + "\u2c0b", # Ⰻ (Djerv, 145) + "\u2c0c", # Ⰼ (Kako, 146) + "\u2c0d", # Ⰽ (Ljudije, 147) + "\u2c0e", # Ⰾ (Myse, 148) + "\u2c0f", # Ⰿ (Nash, 149) + "\u2c10", # Ⱀ (On, 150) + "\u2c11", # Ⱁ (Pokoj, 151) + "\u2c12", # Ⱂ (Rtsy, 152) + "\u2c13", # Ⱃ (Slovo, 153) + "\u2c14", # Ⱄ (Tvrido, 154) + "\u2c15", # Ⱅ (Uku, 155) + "\u2c16", # Ⱆ (Fert, 156) + "\u2c17", # Ⱇ (Xrivi, 157) + "\u2c18", # Ⱈ (Ot, 158) + "\u2c19", # Ⱉ (Cy, 159) + "\u2c1a", # Ⱊ (Shcha, 160) + "\u2c1b", # Ⱋ (Er, 161) + "\u2c1c", # Ⱌ (Yeru, 162) + "\u2c1d", # Ⱍ (Small Yer, 163) + "\u2c1e", # Ⱎ (Yo, 164) + "\u2c1f", # Ⱏ (Yu, 165) + "\u2c20", # Ⱐ (Ja, 166) + # Thai Alphabet (167-210) + "\u0e01", # ก (Ko Kai, 167) + "\u0e02", # ข (Kho Khai, 168) + "\u0e03", # ฃ (Kho Khuat, 169) + "\u0e04", # ค (Kho Khon, 170) + "\u0e05", # ฅ (Kho Rakhang, 171) + "\u0e06", # ฆ (Kho Khwai, 172) + "\u0e07", # ง (Ngo Ngu, 173) + "\u0e08", # จ (Cho Chan, 174) + "\u0e09", # ฉ (Cho Ching, 175) + "\u0e0a", # ช (Cho Chang, 176) + "\u0e0b", # ซ (So So, 177) + "\u0e0c", # ฌ (Cho Choe, 178) + "\u0e0d", # ญ (Yo Ying, 179) + "\u0e0e", # ฎ (Do Chada, 180) + "\u0e0f", # ฏ (To Patak, 181) + "\u0e10", # ฐ (Tho Than, 182) + "\u0e11", # ฑ (Tho Nangmontho, 183) + "\u0e12", # ฒ (Tho Phuthao, 184) + "\u0e13", # ณ (No Nen, 185) + "\u0e14", # ด (Do Dek, 186) + "\u0e15", # ต (To Tao, 187) + "\u0e16", # ถ (Tho Thung, 188) + "\u0e17", # ท (Tho Thahan, 189) + "\u0e18", # ธ (Tho Thong, 190) + "\u0e19", # น (No Nu, 191) + "\u0e1a", # บ (Bo Baimai, 192) + "\u0e1b", # ป (Po Pla, 193) + "\u0e1c", # ผ (Pho Phung, 194) + "\u0e1d", # ฝ (Fo Fa, 195) + "\u0e1e", # พ (Pho Phan, 196) + "\u0e1f", # ฟ (Fo Fan, 197) + "\u0e20", # ภ (Pho Samphao, 198) + "\u0e21", # ม (Mo Ma, 199) + "\u0e22", # ย (Yo Yak, 200) + "\u0e23", # ร (Ro Rua, 201) + "\u0e25", # ล (Lo Ling, 202) + "\u0e27", # ว (Wo Waen, 203) + "\u0e28", # ศ (So Sala, 204) + "\u0e29", # ษ (So Rusi, 205) + "\u0e2a", # ส (So Sua, 206) + "\u0e2b", # ห (Ho Hip, 207) + "\u0e2c", # ฬ (Lo Chula, 208) + "\u0e2d", # อ (O Ang, 209) + "\u0e2e", # ฮ (Ho Nokhuk, 210) + # Hangul Consonants (211-224) + "\u1100", # ㄱ (Giyeok, 211) + "\u1101", # ㄴ (Nieun, 212) + "\u1102", # ㄷ (Digeut, 213) + "\u1103", # ㄹ (Rieul, 214) + "\u1104", # ㅁ (Mieum, 215) + "\u1105", # ㅂ (Bieup, 216) + "\u1106", # ㅅ (Siot, 217) + "\u1107", # ㅇ (Ieung, 218) + "\u1108", # ㅈ (Jieut, 219) + "\u1109", # ㅊ (Chieut, 220) + "\u110a", # ㅋ (Kieuk, 221) + "\u110b", # ㅌ (Tieut, 222) + "\u110c", # ㅍ (Pieup, 223) + "\u110d", # ㅎ (Hieut, 224) + # Hangul Vowels (225-245) + "\u1161", # ㅏ (A, 225) + "\u1162", # ㅐ (Ae, 226) + "\u1163", # ㅑ (Ya, 227) + "\u1164", # ㅒ (Yae, 228) + "\u1165", # ㅓ (Eo, 229) + "\u1166", # ㅔ (E, 230) + "\u1167", # ㅕ (Yeo, 231) + "\u1168", # ㅖ (Ye, 232) + "\u1169", # ㅗ (O, 233) + "\u116a", # ㅘ (Wa, 234) + "\u116b", # ㅙ (Wae, 235) + "\u116c", # ㅚ (Oe, 236) + "\u116d", # ㅛ (Yo, 237) + "\u116e", # ㅜ (U, 238) + "\u116f", # ㅝ (Weo, 239) + "\u1170", # ㅞ (We, 240) + "\u1171", # ㅟ (Wi, 241) + "\u1172", # ㅠ (Yu, 242) + "\u1173", # ㅡ (Eu, 243) + "\u1174", # ㅢ (Ui, 244) + "\u1175", # ㅣ (I, 245) + # Ethiopic Alphabet (246-274) + "\u12a0", # አ (Glottal A, 246) + "\u12a1", # ኡ (Glottal U, 247) + "\u12a2", # ኢ (Glottal I, 248) + "\u12a3", # ኣ (Glottal Aa, 249) + "\u12a4", # ኤ (Glottal E, 250) + "\u12a5", # እ (Glottal Ie, 251) + "\u12a6", # ኦ (Glottal O, 252) + "\u12a7", # ኧ (Glottal Wa, 253) + "\u12c8", # ወ (Wa, 254) + "\u12c9", # ዉ (Wu, 255) + "\u12ca", # ዊ (Wi, 256) + "\u12cb", # ዋ (Waa, 257) + "\u12cc", # ዌ (We, 258) + "\u12cd", # ው (Wye, 259) + "\u12ce", # ዎ (Wo, 260) + "\u12b0", # ኰ (Ko, 261) + "\u12b1", # ኱ (Ku, 262) + "\u12b2", # ኲ (Ki, 263) + "\u12b3", # ኳ (Kua, 264) + "\u12b4", # ኴ (Ke, 265) + "\u12b5", # ኵ (Kwe, 266) + "\u12b6", # ኶ (Ko, 267) + "\u12a0", # ጐ (Go, 268) + "\u12a1", # ጑ (Gu, 269) + "\u12a2", # ጒ (Gi, 270) + "\u12a3", # መ (Gua, 271) + "\u12a4", # ጔ (Ge, 272) + "\u12a5", # ጕ (Gwe, 273) + "\u12a6", # ጖ (Go, 274) + # Devanagari Alphabet (275-318) + "\u0905", # अ (A, 275) + "\u0906", # आ (Aa, 276) + "\u0907", # इ (I, 277) + "\u0908", # ई (Ii, 278) + "\u0909", # उ (U, 279) + "\u090a", # ऊ (Uu, 280) + "\u090b", # ऋ (R, 281) + "\u090f", # ए (E, 282) + "\u0910", # ऐ (Ai, 283) + "\u0913", # ओ (O, 284) + "\u0914", # औ (Au, 285) + "\u0915", # क (Ka, 286) + "\u0916", # ख (Kha, 287) + "\u0917", # ग (Ga, 288) + "\u0918", # घ (Gha, 289) + "\u0919", # ङ (Nga, 290) + "\u091a", # च (Cha, 291) + "\u091b", # छ (Chha, 292) + "\u091c", # ज (Ja, 293) + "\u091d", # झ (Jha, 294) + "\u091e", # ञ (Nya, 295) + "\u091f", # ट (Ta, 296) + "\u0920", # ठ (Tha, 297) + "\u0921", # ड (Da, 298) + "\u0922", # ढ (Dha, 299) + "\u0923", # ण (Na, 300) + "\u0924", # त (Ta, 301) + "\u0925", # थ (Tha, 302) + "\u0926", # द (Da, 303) + "\u0927", # ध (Dha, 304) + "\u0928", # न (Na, 305) + "\u092a", # प (Pa, 306) + "\u092b", # फ (Pha, 307) + "\u092c", # ब (Ba, 308) + "\u092d", # भ (Bha, 309) + "\u092e", # म (Ma, 310) + "\u092f", # य (Ya, 311) + "\u0930", # र (Ra, 312) + "\u0932", # ल (La, 313) + "\u0935", # व (Va, 314) + "\u0936", # श (Sha, 315) + "\u0937", # ष (Ssa, 316) + "\u0938", # स (Sa, 317) + "\u0939", # ह (Ha, 318) + # Katakana Alphabet (319-364) + "\u30a2", # ア (A, 319) + "\u30a4", # イ (I, 320) + "\u30a6", # ウ (U, 321) + "\u30a8", # エ (E, 322) + "\u30aa", # オ (O, 323) + "\u30ab", # カ (Ka, 324) + "\u30ad", # キ (Ki, 325) + "\u30af", # ク (Ku, 326) + "\u30b1", # ケ (Ke, 327) + "\u30b3", # コ (Ko, 328) + "\u30b5", # サ (Sa, 329) + "\u30b7", # シ (Shi, 330) + "\u30b9", # ス (Su, 331) + "\u30bb", # セ (Se, 332) + "\u30bd", # ソ (So, 333) + "\u30bf", # タ (Ta, 334) + "\u30c1", # チ (Chi, 335) + "\u30c4", # ツ (Tsu, 336) + "\u30c6", # テ (Te, 337) + "\u30c8", # ト (To, 338) + "\u30ca", # ナ (Na, 339) + "\u30cb", # ニ (Ni, 340) + "\u30cc", # ヌ (Nu, 341) + "\u30cd", # ネ (Ne, 342) + "\u30ce", # ノ (No, 343) + "\u30cf", # ハ (Ha, 344) + "\u30d2", # ヒ (Hi, 345) + "\u30d5", # フ (Fu, 346) + "\u30d8", # ヘ (He, 347) + "\u30db", # ホ (Ho, 348) + "\u30de", # マ (Ma, 349) + "\u30df", # ミ (Mi, 350) + "\u30e0", # ム (Mu, 351) + "\u30e1", # メ (Me, 352) + "\u30e2", # モ (Mo, 353) + "\u30e4", # ヤ (Ya, 354) + "\u30e6", # ユ (Yu, 355) + "\u30e8", # ヨ (Yo, 356) + "\u30e9", # ラ (Ra, 357) + "\u30ea", # リ (Ri, 358) + "\u30eb", # ル (Ru, 359) + "\u30ec", # レ (Re, 360) + "\u30ed", # ロ (Ro, 361) + "\u30ef", # ワ (Wa, 362) + "\u30f2", # ヲ (Wo, 363) + "\u30f3", # ン (N, 364) + # Tifinagh Alphabet (365-400) + "\u2d30", # ⴰ (Ya, 365) + "\u2d31", # ⴱ (Yab, 366) + "\u2d32", # ⴲ (Yabh, 367) + "\u2d33", # ⴳ (Yag, 368) + "\u2d34", # ⴴ (Yagh, 369) + "\u2d35", # ⴵ (Yaj, 370) + "\u2d36", # ⴶ (Yach, 371) + "\u2d37", # ⴷ (Yad, 372) + "\u2d38", # ⴸ (Yadh, 373) + "\u2d39", # ⴹ (Yadh, emphatic, 374) + "\u2d3a", # ⴺ (Yaz, 375) + "\u2d3b", # ⴻ (Yazh, 376) + "\u2d3c", # ⴼ (Yaf, 377) + "\u2d3d", # ⴽ (Yak, 378) + "\u2d3e", # ⴾ (Yak, variant, 379) + "\u2d3f", # ⴿ (Yaq, 380) + "\u2d40", # ⵀ (Yah, 381) + "\u2d41", # ⵁ (Yahh, 382) + "\u2d42", # ⵂ (Yahl, 383) + "\u2d43", # ⵃ (Yahm, 384) + "\u2d44", # ⵄ (Yayn, 385) + "\u2d45", # ⵅ (Yakh, 386) + "\u2d46", # ⵆ (Yakl, 387) + "\u2d47", # ⵇ (Yahq, 388) + "\u2d48", # ⵈ (Yash, 389) + "\u2d49", # ⵉ (Yi, 390) + "\u2d4a", # ⵊ (Yij, 391) + "\u2d4b", # ⵋ (Yizh, 392) + "\u2d4c", # ⵌ (Yink, 393) + "\u2d4d", # ⵍ (Yal, 394) + "\u2d4e", # ⵎ (Yam, 395) + "\u2d4f", # ⵏ (Yan, 396) + "\u2d50", # ⵐ (Yang, 397) + "\u2d51", # ⵑ (Yany, 398) + "\u2d52", # ⵒ (Yap, 399) + "\u2d53", # ⵓ (Yu, 400) + # Sinhala Alphabet (401-444) + "\u0d85", # අ (A, 401) + "\u0d86", # ආ (Aa, 402) + "\u0d87", # ඉ (I, 403) + "\u0d88", # ඊ (Ii, 404) + "\u0d89", # උ (U, 405) + "\u0d8a", # ඌ (Uu, 406) + "\u0d8b", # ඍ (R, 407) + "\u0d8c", # ඎ (Rr, 408) + "\u0d8f", # ඏ (L, 409) + "\u0d90", # ඐ (Ll, 410) + "\u0d91", # එ (E, 411) + "\u0d92", # ඒ (Ee, 412) + "\u0d93", # ඓ (Ai, 413) + "\u0d94", # ඔ (O, 414) + "\u0d95", # ඕ (Oo, 415) + "\u0d96", # ඖ (Au, 416) + "\u0d9a", # ක (Ka, 417) + "\u0d9b", # ඛ (Kha, 418) + "\u0d9c", # ග (Ga, 419) + "\u0d9d", # ඝ (Gha, 420) + "\u0d9e", # ඞ (Nga, 421) + "\u0d9f", # ච (Cha, 422) + "\u0da0", # ඡ (Chha, 423) + "\u0da1", # ජ (Ja, 424) + "\u0da2", # ඣ (Jha, 425) + "\u0da3", # ඤ (Nya, 426) + "\u0da4", # ට (Ta, 427) + "\u0da5", # ඥ (Tha, 428) + "\u0da6", # ඦ (Da, 429) + "\u0da7", # ට (Dha, 430) + "\u0da8", # ඨ (Na, 431) + "\u0daa", # ඪ (Pa, 432) + "\u0dab", # ණ (Pha, 433) + "\u0dac", # ඬ (Ba, 434) + "\u0dad", # ත (Bha, 435) + "\u0dae", # ථ (Ma, 436) + "\u0daf", # ද (Ya, 437) + "\u0db0", # ධ (Ra, 438) + "\u0db1", # ඲ (La, 439) + "\u0db2", # ඳ (Va, 440) + "\u0db3", # ප (Sha, 441) + "\u0db4", # ඵ (Ssa, 442) + "\u0db5", # බ (Sa, 443) + "\u0db6", # භ (Ha, 444) +] + NETWORK_EXPLORER_MAP = { "opentensor": { "local": "https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fentrypoint-finney.opentensor.ai%3A443#/explorer", @@ -318,7 +643,7 @@ class WalletValidationTypes(Enum): HYPERPARAMS = { - # btcli name: (subtensor method, sudo bool) + # btcli name: (subtensor method, root-only bool) "rho": ("sudo_set_rho", False), "kappa": ("sudo_set_kappa", False), "immunity_period": ("sudo_set_immunity_period", False), @@ -332,7 +657,7 @@ class WalletValidationTypes(Enum): "adjustment_interval": ("sudo_set_adjustment_interval", True), "activity_cutoff": ("sudo_set_activity_cutoff", False), "target_regs_per_interval": ("sudo_set_target_registrations_per_interval", True), - "min_burn": ("sudo_set_min_burn", False), + "min_burn": ("sudo_set_min_burn", True), "max_burn": ("sudo_set_max_burn", False), "bonds_moving_avg": ("sudo_set_bonds_moving_average", False), "max_regs_per_block": ("sudo_set_max_registrations_per_block", True), @@ -340,14 +665,18 @@ class WalletValidationTypes(Enum): "max_validators": ("sudo_set_max_allowed_validators", True), "adjustment_alpha": ("sudo_set_adjustment_alpha", False), "difficulty": ("sudo_set_difficulty", False), - "commit_reveal_weights_interval": ( + "commit_reveal_period": ( "sudo_set_commit_reveal_weights_interval", False, ), "commit_reveal_weights_enabled": ("sudo_set_commit_reveal_weights_enabled", False), "alpha_values": ("sudo_set_alpha_values", False), "liquid_alpha_enabled": ("sudo_set_liquid_alpha_enabled", False), - "registration_allowed": ("sudo_set_network_registration_allowed", False), + "network_registration_allowed": ("sudo_set_network_registration_allowed", False), + "network_pow_registration_allowed": ( + "sudo_set_network_pow_registration_allowed", + False, + ), } # Help Panels for cli help @@ -370,9 +699,12 @@ class WalletValidationTypes(Enum): "STAKE": { "STAKE_MGMT": "Stake Management", "CHILD": "Child Hotkeys", + "MOVEMENT": "Stake Movement", }, "SUDO": { "CONFIG": "Subnet Configuration", + "GOVERNANCE": "Governance", + "TAKE": "Delegate take configuration", }, "SUBNETS": { "INFO": "Subnet Information", @@ -381,3 +713,126 @@ class WalletValidationTypes(Enum): }, "WEIGHTS": {"COMMIT_REVEAL": "Commit / Reveal"}, } + +COLOR_PALETTE = { + "GENERAL": { + "HEADER": "#4196D6", # Light Blue + "LINKS": "#8CB9E9", # Sky Blue + "HINT": "#A2E5B8", # Mint Green + "COLDKEY": "#9EF5E4", # Aqua + "HOTKEY": "#ECC39D", # Light Orange/Peach + "SUBHEADING_MAIN": "#7ECFEC", # Light Cyan + "SUBHEADING": "#AFEFFF", # Pale Blue + "SUBHEADING_EXTRA_1": "#96A3C5", # Grayish Blue + "SUBHEADING_EXTRA_2": "#6D7BAF", # Slate Blue + "CONFIRMATION_Y_N_Q": "#EE8DF8", # Light Purple/Pink + "SYMBOL": "#E7CC51", # Gold + "BALANCE": "#4F91C6", # Medium Blue + "COST": "#53B5A0", # Teal + "SUCCESS": "#53B5A0", # Teal + "NETUID": "#CBA880", # Tan + "NETUID_EXTRA": "#DDD5A9", # Light Khaki + "TEMPO": "#67A3A5", # Grayish Teal + }, + "STAKE": { + "STAKE_AMOUNT": "#53B5A0", # Teal + "STAKE_ALPHA": "#53B5A0", # Teal + "STAKE_SWAP": "#67A3A5", # Grayish Teal + "TAO": "#4F91C6", # Medium Blue + "SLIPPAGE_TEXT": "#C25E7C", # Rose + "SLIPPAGE_PERCENT": "#E7B195", # Light Coral + "NOT_REGISTERED": "#EB6A6C", # Salmon Red + "EXTRA_1": "#D781BB", # Pink + }, + "POOLS": { + "TAO": "#4F91C6", # Medium Blue + "ALPHA_IN": "#D09FE9", # Light Purple + "ALPHA_OUT": "#AB7CC8", # Medium Purple + "RATE": "#F8D384", # Light Orange + "TAO_EQUIV": "#8CB9E9", # Sky Blue + "EMISSION": "#F8D384", # Light Orange + "EXTRA_1": "#CAA8FB", # Lavender + "EXTRA_2": "#806DAF", # Dark Purple + }, + "GREY": { + "GREY_100": "#F8F9FA", # Almost White + "GREY_200": "#F1F3F4", # Very Light Grey + "GREY_300": "#DBDDE1", # Light Grey + "GREY_400": "#BDC1C6", # Medium Light Grey + "GREY_500": "#5F6368", # Medium Grey + "GREY_600": "#2E3134", # Medium Dark Grey + "GREY_700": "#282A2D", # Dark Grey + "GREY_800": "#17181B", # Very Dark Grey + "GREY_900": "#0E1013", # Almost Black + "BLACK": "#000000", # Pure Black + }, + "SUDO": { + "HYPERPARAMETER": "#4F91C6", # Medium Blue + "VALUE": "#D09FE9", # Light Purple + "NORMALIZED": "#AB7CC8", # Medium Purple + }, +} + + +SUBNETS = { + 0: "root", + 1: "apex", + 2: "omron", + 3: "templar", + 4: "targon", + 5: "kaito", + 6: "infinite", + 7: "subvortex", + 8: "ptn", + 9: "pretrain", + 10: "sturday", + 11: "dippy", + 12: "horde", + 13: "dataverse", + 14: "palaidn", + 15: "deval", + 16: "bitads", + 17: "3gen", + 18: "cortex", + 19: "inference", + 20: "bitagent", + 21: "any-any", + 22: "meta", + 23: "social", + 24: "omega", + 25: "protein", + 26: "alchemy", + 27: "compute", + 28: "oracle", + 29: "coldint", + 30: "bet", + 31: "naschain", + 32: "itsai", + 33: "ready", + 34: "mind", + 35: "logic", + 36: "automata", + 37: "tuning", + 38: "distributed", + 39: "edge", + 40: "chunk", + 41: "sportsensor", + 42: "masa", + 43: "graphite", + 44: "score", + 45: "gen42", + 46: "neural", + 47: "condense", + 48: "nextplace", + 49: "automl", + 50: "audio", + 51: "celium", + 52: "dojo", + 53: "frontier", + 54: "docs-insight", + 56: "gradients", + 57: "gaia", + 58: "dippy-speech", + 59: "agent-arena", + 61: "red-team", +} diff --git a/bittensor_cli/src/bittensor/async_substrate_interface.py b/bittensor_cli/src/bittensor/async_substrate_interface.py deleted file mode 100644 index af54b531..00000000 --- a/bittensor_cli/src/bittensor/async_substrate_interface.py +++ /dev/null @@ -1,2748 +0,0 @@ -import asyncio -import json -import random -from collections import defaultdict -from dataclasses import dataclass -from hashlib import blake2b -from typing import Optional, Any, Union, Callable, Awaitable, cast, TYPE_CHECKING - -from async_property import async_property -from bt_decode import PortableRegistry, decode as decode_by_type_string, MetadataV15 -from bittensor_wallet import Keypair -from scalecodec import GenericExtrinsic -from scalecodec.base import ScaleBytes, ScaleType, RuntimeConfigurationObject -from scalecodec.type_registry import load_type_registry_preset -from scalecodec.types import GenericCall -from substrateinterface.exceptions import ( - SubstrateRequestException, - ExtrinsicNotFound, - BlockNotFound, -) -from substrateinterface.storage import StorageKey -from websockets.asyncio.client import connect -from websockets.exceptions import ConnectionClosed - -from bittensor_cli.src.bittensor.utils import hex_to_bytes - -if TYPE_CHECKING: - from websockets.asyncio.client import ClientConnection - -ResultHandler = Callable[[dict, Any], Awaitable[tuple[dict, bool]]] - - -class TimeoutException(Exception): - pass - - -def timeout_handler(signum, frame): - raise TimeoutException("Operation timed out") - - -class ExtrinsicReceipt: - """ - Object containing information of submitted extrinsic. Block hash where extrinsic is included is required - when retrieving triggered events or determine if extrinsic was successful - """ - - def __init__( - self, - substrate: "AsyncSubstrateInterface", - extrinsic_hash: Optional[str] = None, - block_hash: Optional[str] = None, - block_number: Optional[int] = None, - extrinsic_idx: Optional[int] = None, - finalized=None, - ): - """ - Object containing information of submitted extrinsic. Block hash where extrinsic is included is required - when retrieving triggered events or determine if extrinsic was successful - - Parameters - ---------- - substrate - extrinsic_hash - block_hash - finalized - """ - self.substrate = substrate - self.extrinsic_hash = extrinsic_hash - self.block_hash = block_hash - self.block_number = block_number - self.finalized = finalized - - self.__extrinsic_idx = extrinsic_idx - self.__extrinsic = None - - self.__triggered_events: Optional[list] = None - self.__is_success: Optional[bool] = None - self.__error_message = None - self.__weight = None - self.__total_fee_amount = None - - async def get_extrinsic_identifier(self) -> str: - """ - Returns the on-chain identifier for this extrinsic in format "[block_number]-[extrinsic_idx]" e.g. 134324-2 - Returns - ------- - str - """ - if self.block_number is None: - if self.block_hash is None: - raise ValueError( - "Cannot create extrinsic identifier: block_hash is not set" - ) - - self.block_number = await self.substrate.get_block_number(self.block_hash) - - if self.block_number is None: - raise ValueError( - "Cannot create extrinsic identifier: unknown block_hash" - ) - - return f"{self.block_number}-{await self.extrinsic_idx}" - - async def retrieve_extrinsic(self): - if not self.block_hash: - raise ValueError( - "ExtrinsicReceipt can't retrieve events because it's unknown which block_hash it is " - "included, manually set block_hash or use `wait_for_inclusion` when sending extrinsic" - ) - # Determine extrinsic idx - - block = await self.substrate.get_block(block_hash=self.block_hash) - - extrinsics = block["extrinsics"] - - if len(extrinsics) > 0: - if self.__extrinsic_idx is None: - self.__extrinsic_idx = self.__get_extrinsic_index( - block_extrinsics=extrinsics, extrinsic_hash=self.extrinsic_hash - ) - - if self.__extrinsic_idx >= len(extrinsics): - raise ExtrinsicNotFound() - - self.__extrinsic = extrinsics[self.__extrinsic_idx] - - @async_property - async def extrinsic_idx(self) -> int: - """ - Retrieves the index of this extrinsic in containing block - - Returns - ------- - int - """ - if self.__extrinsic_idx is None: - await self.retrieve_extrinsic() - return self.__extrinsic_idx - - @async_property - async def triggered_events(self) -> list: - """ - Gets triggered events for submitted extrinsic. block_hash where extrinsic is included is required, manually - set block_hash or use `wait_for_inclusion` when submitting extrinsic - - Returns - ------- - list - """ - if self.__triggered_events is None: - if not self.block_hash: - raise ValueError( - "ExtrinsicReceipt can't retrieve events because it's unknown which block_hash it is " - "included, manually set block_hash or use `wait_for_inclusion` when sending extrinsic" - ) - - if await self.extrinsic_idx is None: - await self.retrieve_extrinsic() - - self.__triggered_events = [] - - for event in await self.substrate.get_events(block_hash=self.block_hash): - if event["extrinsic_idx"] == await self.extrinsic_idx: - self.__triggered_events.append(event) - - return cast(list, self.__triggered_events) - - async def process_events(self): - if await self.triggered_events: - self.__total_fee_amount = 0 - - # Process fees - has_transaction_fee_paid_event = False - - for event in await self.triggered_events: - if ( - event["event"]["module_id"] == "TransactionPayment" - and event["event"]["event_id"] == "TransactionFeePaid" - ): - self.__total_fee_amount = event["event"]["attributes"]["actual_fee"] - has_transaction_fee_paid_event = True - - # Process other events - for event in await self.triggered_events: - # Check events - if ( - event["event"]["module_id"] == "System" - and event["event"]["event_id"] == "ExtrinsicSuccess" - ): - self.__is_success = True - self.__error_message = None - - if "dispatch_info" in event["event"]["attributes"]: - self.__weight = event["event"]["attributes"]["dispatch_info"][ - "weight" - ] - else: - # Backwards compatibility - self.__weight = event["event"]["attributes"]["weight"] - - elif ( - event["event"]["module_id"] == "System" - and event["event"]["event_id"] == "ExtrinsicFailed" - ): - self.__is_success = False - - dispatch_info = event["event"]["attributes"]["dispatch_info"] - dispatch_error = event["event"]["attributes"]["dispatch_error"] - - self.__weight = dispatch_info["weight"] - - if "Module" in dispatch_error: - module_index = dispatch_error["Module"][0]["index"] - error_index = int.from_bytes( - bytes(dispatch_error["Module"][0]["error"]), - byteorder="little", - signed=False, - ) - - if isinstance(error_index, str): - # Actual error index is first u8 in new [u8; 4] format - error_index = int(error_index[2:4], 16) - module_error = self.substrate.metadata.get_module_error( - module_index=module_index, error_index=error_index - ) - self.__error_message = { - "type": "Module", - "name": module_error.name, - "docs": module_error.docs, - } - elif "BadOrigin" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "BadOrigin", - "docs": "Bad origin", - } - elif "CannotLookup" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "CannotLookup", - "docs": "Cannot lookup", - } - elif "Other" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "Other", - "docs": "Unspecified error occurred", - } - - elif not has_transaction_fee_paid_event: - if ( - event["event"]["module_id"] == "Treasury" - and event["event"]["event_id"] == "Deposit" - ): - self.__total_fee_amount += event["event"]["attributes"]["value"] - elif ( - event["event"]["module_id"] == "Balances" - and event["event"]["event_id"] == "Deposit" - ): - self.__total_fee_amount += event.value["attributes"]["amount"] - - @async_property - async def is_success(self) -> bool: - """ - Returns `True` if `ExtrinsicSuccess` event is triggered, `False` in case of `ExtrinsicFailed` - In case of False `error_message` will contain more details about the error - - - Returns - ------- - bool - """ - if self.__is_success is None: - await self.process_events() - - return cast(bool, self.__is_success) - - @async_property - async def error_message(self) -> Optional[dict]: - """ - Returns the error message if the extrinsic failed in format e.g.: - - `{'type': 'System', 'name': 'BadOrigin', 'docs': 'Bad origin'}` - - Returns - ------- - dict - """ - if self.__error_message is None: - if await self.is_success: - return None - await self.process_events() - return self.__error_message - - @async_property - async def weight(self) -> Union[int, dict]: - """ - Contains the actual weight when executing this extrinsic - - Returns - ------- - int (WeightV1) or dict (WeightV2) - """ - if self.__weight is None: - await self.process_events() - return self.__weight - - @async_property - async def total_fee_amount(self) -> int: - """ - Contains the total fee costs deducted when executing this extrinsic. This includes fee for the validator ( - (`Balances.Deposit` event) and the fee deposited for the treasury (`Treasury.Deposit` event) - - Returns - ------- - int - """ - if self.__total_fee_amount is None: - await self.process_events() - return cast(int, self.__total_fee_amount) - - # Helper functions - @staticmethod - def __get_extrinsic_index(block_extrinsics: list, extrinsic_hash: str) -> int: - """ - Returns the index of a provided extrinsic - """ - for idx, extrinsic in enumerate(block_extrinsics): - if ( - extrinsic.extrinsic_hash - and f"0x{extrinsic.extrinsic_hash.hex()}" == extrinsic_hash - ): - return idx - raise ExtrinsicNotFound() - - # Backwards compatibility methods - def __getitem__(self, item): - return getattr(self, item) - - def __iter__(self): - for item in self.__dict__.items(): - yield item - - def get(self, name): - return self[name] - - -class QueryMapResult: - def __init__( - self, - records: list, - page_size: int, - substrate: "AsyncSubstrateInterface", - module: Optional[str] = None, - storage_function: Optional[str] = None, - params: Optional[list] = None, - block_hash: Optional[str] = None, - last_key: Optional[str] = None, - max_results: Optional[int] = None, - ignore_decoding_errors: bool = False, - ): - self.records = records - self.page_size = page_size - self.module = module - self.storage_function = storage_function - self.block_hash = block_hash - self.substrate = substrate - self.last_key = last_key - self.max_results = max_results - self.params = params - self.ignore_decoding_errors = ignore_decoding_errors - self.loading_complete = False - self._buffer = iter(self.records) # Initialize the buffer with initial records - - async def retrieve_next_page(self, start_key) -> list: - result = await self.substrate.query_map( - module=self.module, - storage_function=self.storage_function, - params=self.params, - page_size=self.page_size, - block_hash=self.block_hash, - start_key=start_key, - max_results=self.max_results, - ignore_decoding_errors=self.ignore_decoding_errors, - ) - - # Update last key from new result set to use as offset for next page - self.last_key = result.last_key - return result.records - - def __aiter__(self): - return self - - async def __anext__(self): - try: - # Try to get the next record from the buffer - return next(self._buffer) - except StopIteration: - # If no more records in the buffer, try to fetch the next page - if self.loading_complete: - raise StopAsyncIteration - - next_page = await self.retrieve_next_page(self.last_key) - if not next_page: - self.loading_complete = True - raise StopAsyncIteration - - # Update the buffer with the newly fetched records - self._buffer = iter(next_page) - return next(self._buffer) - - def __getitem__(self, item): - return self.records[item] - - -@dataclass -class Preprocessed: - queryable: str - method: str - params: list - value_scale_type: str - storage_item: ScaleType - - -class RuntimeCache: - blocks: dict[int, "Runtime"] - block_hashes: dict[str, "Runtime"] - - def __init__(self): - self.blocks = {} - self.block_hashes = {} - - def add_item( - self, block: Optional[int], block_hash: Optional[str], runtime: "Runtime" - ): - if block is not None: - self.blocks[block] = runtime - if block_hash is not None: - self.block_hashes[block_hash] = runtime - - def retrieve( - self, block: Optional[int] = None, block_hash: Optional[str] = None - ) -> Optional["Runtime"]: - if block is not None: - return self.blocks.get(block) - elif block_hash is not None: - return self.block_hashes.get(block_hash) - else: - return None - - -class Runtime: - block_hash: str - block_id: int - runtime_version = None - transaction_version = None - cache_region = None - metadata = None - type_registry_preset = None - - def __init__(self, chain, runtime_config, metadata, type_registry): - self.runtime_config = RuntimeConfigurationObject() - self.config = {} - self.chain = chain - self.type_registry = type_registry - self.runtime_config = runtime_config - self.metadata = metadata - - def __str__(self): - return f"Runtime: {self.chain} | {self.config}" - - @property - def implements_scaleinfo(self) -> bool: - """ - Returns True if current runtime implementation a `PortableRegistry` (`MetadataV14` and higher) - """ - if self.metadata: - return self.metadata.portable_registry is not None - else: - return False - - def reload_type_registry( - self, use_remote_preset: bool = True, auto_discover: bool = True - ): - """ - Reload type registry and preset used to instantiate the SubstrateInterface object. Useful to periodically apply - changes in type definitions when a runtime upgrade occurred - - Parameters - ---------- - use_remote_preset: When True preset is downloaded from Github master, otherwise use files from local installed - scalecodec package - auto_discover - - Returns - ------- - - """ - self.runtime_config.clear_type_registry() - - self.runtime_config.implements_scale_info = self.implements_scaleinfo - - # Load metadata types in runtime configuration - self.runtime_config.update_type_registry(load_type_registry_preset(name="core")) - self.apply_type_registry_presets( - use_remote_preset=use_remote_preset, auto_discover=auto_discover - ) - - def apply_type_registry_presets( - self, - use_remote_preset: bool = True, - auto_discover: bool = True, - ): - """ - Applies type registry presets to the runtime - :param use_remote_preset: bool, whether to use presets from remote - :param auto_discover: bool, whether to use presets from local installed scalecodec package - """ - if self.type_registry_preset is not None: - # Load type registry according to preset - type_registry_preset_dict = load_type_registry_preset( - name=self.type_registry_preset, use_remote_preset=use_remote_preset - ) - - if not type_registry_preset_dict: - raise ValueError( - f"Type registry preset '{self.type_registry_preset}' not found" - ) - - elif auto_discover: - # Try to auto discover type registry preset by chain name - type_registry_name = self.chain.lower().replace(" ", "-") - try: - type_registry_preset_dict = load_type_registry_preset( - type_registry_name - ) - self.type_registry_preset = type_registry_name - except ValueError: - type_registry_preset_dict = None - - else: - type_registry_preset_dict = None - - if type_registry_preset_dict: - # Load type registries in runtime configuration - if self.implements_scaleinfo is False: - # Only runtime with no embedded types in metadata need the default set of explicit defined types - self.runtime_config.update_type_registry( - load_type_registry_preset( - "legacy", use_remote_preset=use_remote_preset - ) - ) - - if self.type_registry_preset != "legacy": - self.runtime_config.update_type_registry(type_registry_preset_dict) - - if self.type_registry: - # Load type registries in runtime configuration - self.runtime_config.update_type_registry(self.type_registry) - - -class RequestManager: - RequestResults = dict[Union[str, int], list[Union[ScaleType, dict]]] - - def __init__(self, payloads): - self.response_map = {} - self.responses = defaultdict(lambda: {"complete": False, "results": []}) - self.payloads_count = len(payloads) - - def add_request(self, item_id: int, request_id: Any): - """ - Adds an outgoing request to the responses map for later retrieval - """ - self.response_map[item_id] = request_id - - def overwrite_request(self, item_id: int, request_id: Any): - """ - Overwrites an existing request in the responses map with a new request_id. This is used - for multipart responses that generate a subscription id we need to watch, rather than the initial - request_id. - """ - self.response_map[request_id] = self.response_map.pop(item_id) - return request_id - - def add_response(self, item_id: int, response: dict, complete: bool): - """ - Maps a response to the request for later retrieval - """ - request_id = self.response_map[item_id] - self.responses[request_id]["results"].append(response) - self.responses[request_id]["complete"] = complete - - @property - def is_complete(self) -> bool: - """ - Returns whether all requests in the manager have completed - """ - return ( - all(info["complete"] for info in self.responses.values()) - and len(self.responses) == self.payloads_count - ) - - def get_results(self) -> RequestResults: - """ - Generates a dictionary mapping the requests initiated to the responses received. - """ - return { - request_id: info["results"] for request_id, info in self.responses.items() - } - - -class Websocket: - def __init__( - self, - ws_url: str, - max_subscriptions=1024, - max_connections=100, - shutdown_timer=5, - options: Optional[dict] = None, - ): - """ - Websocket manager object. Allows for the use of a single websocket connection by multiple - calls. - - :param ws_url: Websocket URL to connect to - :param max_subscriptions: Maximum number of subscriptions per websocket connection - :param max_connections: Maximum number of connections total - :param shutdown_timer: Number of seconds to shut down websocket connection after last use - """ - # TODO allow setting max concurrent connections and rpc subscriptions per connection - # TODO reconnection logic - self.ws_url = ws_url - self.ws: Optional["ClientConnection"] = None - self.id = 0 - self.max_subscriptions = max_subscriptions - self.max_connections = max_connections - self.shutdown_timer = shutdown_timer - self._received = {} - self._in_use = 0 - self._receiving_task = None - self._attempts = 0 - self._initialized = False - self._lock = asyncio.Lock() - self._exit_task = None - self._open_subscriptions = 0 - self._options = options if options else {} - - async def __aenter__(self): - async with self._lock: - self._in_use += 1 - if self._exit_task: - self._exit_task.cancel() - if not self._initialized: - self._initialized = True - self.ws = await asyncio.wait_for( - connect(self.ws_url, **self._options), timeout=10 - ) - self._receiving_task = asyncio.create_task(self._start_receiving()) - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - async with self._lock: - self._in_use -= 1 - if self._exit_task is not None: - self._exit_task.cancel() - try: - await self._exit_task - except asyncio.CancelledError: - pass - if self._in_use == 0 and self.ws is not None: - self.id = 0 - self._open_subscriptions = 0 - self._exit_task = asyncio.create_task(self._exit_with_timer()) - - async def _exit_with_timer(self): - """ - Allows for graceful shutdown of websocket connection after specified number of seconds, allowing - for reuse of the websocket connection. - """ - try: - await asyncio.sleep(self.shutdown_timer) - await self.shutdown() - except asyncio.CancelledError: - pass - - async def shutdown(self): - async with self._lock: - try: - self._receiving_task.cancel() - await self._receiving_task - await self.ws.close() - except (AttributeError, asyncio.CancelledError): - pass - self.ws = None - self._initialized = False - self._receiving_task = None - self.id = 0 - - async def _recv(self) -> None: - try: - response = json.loads(await self.ws.recv()) - async with self._lock: - self._open_subscriptions -= 1 - if "id" in response: - self._received[response["id"]] = response - elif "params" in response: - self._received[response["params"]["subscription"]] = response - else: - raise KeyError(response) - except ConnectionClosed: - raise - except KeyError as e: - raise e - - async def _start_receiving(self): - try: - while True: - await self._recv() - except asyncio.CancelledError: - pass - except ConnectionClosed: - # TODO try reconnect, but only if it's needed - raise - - async def send(self, payload: dict) -> int: - """ - Sends a payload to the websocket connection. - - :param payload: payload, generate a payload with the AsyncSubstrateInterface.make_payload method - """ - async with self._lock: - original_id = self.id - self.id += 1 - self._open_subscriptions += 1 - try: - await self.ws.send(json.dumps({**payload, **{"id": original_id}})) - return original_id - except ConnectionClosed: - raise - - async def retrieve(self, item_id: int) -> Optional[dict]: - """ - Retrieves a single item from received responses dict queue - - :param item_id: id of the item to retrieve - - :return: retrieved item - """ - while True: - async with self._lock: - if item_id in self._received: - return self._received.pop(item_id) - await asyncio.sleep(0.1) - - -class AsyncSubstrateInterface: - runtime = None - registry: Optional[PortableRegistry] = None - - def __init__( - self, - chain_endpoint: str, - use_remote_preset=False, - auto_discover=True, - auto_reconnect=True, - ss58_format=None, - type_registry=None, - chain_name=None, - ): - """ - The asyncio-compatible version of the subtensor interface commands we use in bittensor - """ - self.chain_endpoint = chain_endpoint - self.__chain = chain_name - self.ws = Websocket( - chain_endpoint, - options={ - "max_size": 2**32, - "write_limit": 2**16, - }, - ) - self._lock = asyncio.Lock() - self.last_block_hash: Optional[str] = None - self.config = { - "use_remote_preset": use_remote_preset, - "auto_discover": auto_discover, - "auto_reconnect": auto_reconnect, - "rpc_methods": None, - "strict_scale_decode": True, - } - self.initialized = False - self._forgettable_task = None - self.ss58_format = ss58_format - self.type_registry = type_registry - self.runtime_cache = RuntimeCache() - self.block_id: Optional[int] = None - self.runtime_version = None - self.runtime_config = RuntimeConfigurationObject() - self.__metadata_cache = {} - self.type_registry_preset = None - self.transaction_version = None - self.metadata = None - self.metadata_version_hex = "0x0f000000" # v15 - - async def __aenter__(self): - await self.initialize() - - async def initialize(self): - """ - Initialize the connection to the chain. - """ - async with self._lock: - if not self.initialized: - if not self.__chain: - chain = await self.rpc_request("system_chain", []) - self.__chain = chain.get("result") - self.reload_type_registry() - await asyncio.gather(self.load_registry(), self.init_runtime(None)) - self.initialized = True - - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - @property - def chain(self): - """ - Returns the substrate chain currently associated with object - """ - return self.__chain - - async def get_storage_item(self, module: str, storage_function: str): - if not self.metadata: - await self.init_runtime() - metadata_pallet = self.metadata.get_metadata_pallet(module) - storage_item = metadata_pallet.get_storage_function(storage_function) - return storage_item - - async def _get_current_block_hash( - self, block_hash: Optional[str], reuse: bool - ) -> Optional[str]: - if block_hash: - self.last_block_hash = block_hash - return block_hash - elif reuse: - if self.last_block_hash: - return self.last_block_hash - return block_hash - - async def load_registry(self): - metadata_rpc_result = await self.rpc_request( - "state_call", - ["Metadata_metadata_at_version", self.metadata_version_hex], - ) - metadata_option_hex_str = metadata_rpc_result["result"] - metadata_option_bytes = bytes.fromhex(metadata_option_hex_str[2:]) - metadata_v15 = MetadataV15.decode_from_metadata_option(metadata_option_bytes) - self.registry = PortableRegistry.from_metadata_v15(metadata_v15) - - async def decode_scale( - self, type_string, scale_bytes: bytes, return_scale_obj=False - ): - """ - Helper function to decode arbitrary SCALE-bytes (e.g. 0x02000000) according to given RUST type_string - (e.g. BlockNumber). The relevant versioning information of the type (if defined) will be applied if block_hash - is set - - Parameters - ---------- - type_string - scale_bytes - block_hash - return_scale_obj: if True the SCALE object itself is returned, otherwise the serialized dict value of the object - - Returns - ------- - - """ - if scale_bytes == b"\x00": - obj = None - else: - obj = decode_by_type_string(type_string, self.registry, scale_bytes) - return obj - - async def init_runtime( - self, block_hash: Optional[str] = None, block_id: Optional[int] = None - ) -> Runtime: - """ - This method is used by all other methods that deals with metadata and types defined in the type registry. - It optionally retrieves the block_hash when block_id is given and sets the applicable metadata for that - block_hash. Also, it applies all the versioned types at the time of the block_hash. - - Because parsing of metadata and type registry is quite heavy, the result will be cached per runtime id. - In the future there could be support for caching backends like Redis to make this cache more persistent. - - :param block_hash: optional block hash, should not be specified if block_id is - :param block_id: optional block id, should not be specified if block_hash is - - :returns: Runtime object - """ - - async def get_runtime(block_hash, block_id) -> Runtime: - # Check if runtime state already set to current block - if ( - (block_hash and block_hash == self.last_block_hash) - or (block_id and block_id == self.block_id) - ) and self.metadata is not None: - return Runtime( - self.chain, - self.runtime_config, - self.metadata, - self.type_registry, - ) - - if block_id is not None: - block_hash = await self.get_block_hash(block_id) - - if not block_hash: - block_hash = await self.get_chain_head() - - self.last_block_hash = block_hash - self.block_id = block_id - - # In fact calls and storage functions are decoded against runtime of previous block, therefor retrieve - # metadata and apply type registry of runtime of parent block - block_header = await self.rpc_request( - "chain_getHeader", [self.last_block_hash] - ) - - if block_header["result"] is None: - raise SubstrateRequestException( - f'Block not found for "{self.last_block_hash}"' - ) - - parent_block_hash: str = block_header["result"]["parentHash"] - - if ( - parent_block_hash - == "0x0000000000000000000000000000000000000000000000000000000000000000" - ): - runtime_block_hash = self.last_block_hash - else: - runtime_block_hash = parent_block_hash - - runtime_info = await self.get_block_runtime_version( - block_hash=runtime_block_hash - ) - - if runtime_info is None: - raise SubstrateRequestException( - f"No runtime information for block '{block_hash}'" - ) - # Check if runtime state already set to current block - if ( - runtime_info.get("specVersion") == self.runtime_version - and self.metadata is not None - ): - return Runtime( - self.chain, - self.runtime_config, - self.metadata, - self.type_registry, - ) - - self.runtime_version = runtime_info.get("specVersion") - self.transaction_version = runtime_info.get("transactionVersion") - - if not self.metadata: - if self.runtime_version in self.__metadata_cache: - # Get metadata from cache - # self.debug_message('Retrieved metadata for {} from memory'.format(self.runtime_version)) - metadata = self.metadata = self.__metadata_cache[ - self.runtime_version - ] - else: - metadata = self.metadata = await self.get_block_metadata( - block_hash=runtime_block_hash, decode=True - ) - # self.debug_message('Retrieved metadata for {} from Substrate node'.format(self.runtime_version)) - - # Update metadata cache - self.__metadata_cache[self.runtime_version] = self.metadata - else: - metadata = self.metadata - # Update type registry - self.reload_type_registry(use_remote_preset=False, auto_discover=True) - - if self.implements_scaleinfo: - # self.debug_message('Add PortableRegistry from metadata to type registry') - self.runtime_config.add_portable_registry(self.metadata) - - # Set active runtime version - self.runtime_config.set_active_spec_version_id(self.runtime_version) - - # Check and apply runtime constants - ss58_prefix_constant = await self.get_constant( - "System", "SS58Prefix", block_hash=block_hash - ) - - if ss58_prefix_constant: - self.ss58_format = ss58_prefix_constant - - # Set runtime compatibility flags - try: - _ = self.runtime_config.create_scale_object( - "sp_weights::weight_v2::Weight" - ) - self.config["is_weight_v2"] = True - self.runtime_config.update_type_registry_types( - {"Weight": "sp_weights::weight_v2::Weight"} - ) - except NotImplementedError: - self.config["is_weight_v2"] = False - self.runtime_config.update_type_registry_types({"Weight": "WeightV1"}) - return Runtime( - self.chain, - self.runtime_config, - self.metadata, - self.type_registry, - ) - - if block_id and block_hash: - raise ValueError("Cannot provide block_hash and block_id at the same time") - - if ( - not (runtime := self.runtime_cache.retrieve(block_id, block_hash)) - or runtime.metadata is None - ): - runtime = await get_runtime(block_hash, block_id) - self.runtime_cache.add_item(block_id, block_hash, runtime) - return runtime - - def reload_type_registry( - self, use_remote_preset: bool = True, auto_discover: bool = True - ): - """ - Reload type registry and preset used to instantiate the SubtrateInterface object. Useful to periodically apply - changes in type definitions when a runtime upgrade occurred - - Parameters - ---------- - use_remote_preset: When True preset is downloaded from Github master, otherwise use files from local installed scalecodec package - auto_discover - - Returns - ------- - - """ - self.runtime_config.clear_type_registry() - - self.runtime_config.implements_scale_info = self.implements_scaleinfo - - # Load metadata types in runtime configuration - self.runtime_config.update_type_registry(load_type_registry_preset(name="core")) - self.apply_type_registry_presets( - use_remote_preset=use_remote_preset, auto_discover=auto_discover - ) - - def apply_type_registry_presets( - self, use_remote_preset: bool = True, auto_discover: bool = True - ): - if self.type_registry_preset is not None: - # Load type registry according to preset - type_registry_preset_dict = load_type_registry_preset( - name=self.type_registry_preset, use_remote_preset=use_remote_preset - ) - - if not type_registry_preset_dict: - raise ValueError( - f"Type registry preset '{self.type_registry_preset}' not found" - ) - - elif auto_discover: - # Try to auto discover type registry preset by chain name - type_registry_name = self.chain.lower().replace(" ", "-") - try: - type_registry_preset_dict = load_type_registry_preset( - type_registry_name - ) - # self.debug_message(f"Auto set type_registry_preset to {type_registry_name} ...") - self.type_registry_preset = type_registry_name - except ValueError: - type_registry_preset_dict = None - - else: - type_registry_preset_dict = None - - if type_registry_preset_dict: - # Load type registries in runtime configuration - if self.implements_scaleinfo is False: - # Only runtime with no embedded types in metadata need the default set of explicit defined types - self.runtime_config.update_type_registry( - load_type_registry_preset( - "legacy", use_remote_preset=use_remote_preset - ) - ) - - if self.type_registry_preset != "legacy": - self.runtime_config.update_type_registry(type_registry_preset_dict) - - if self.type_registry: - # Load type registries in runtime configuration - self.runtime_config.update_type_registry(self.type_registry) - - @property - def implements_scaleinfo(self) -> Optional[bool]: - """ - Returns True if current runtime implementation a `PortableRegistry` (`MetadataV14` and higher) - - Returns - ------- - bool - """ - if self.metadata: - return self.metadata.portable_registry is not None - else: - return None - - async def create_storage_key( - self, - pallet: str, - storage_function: str, - params: Optional[list] = None, - block_hash: str = None, - ) -> StorageKey: - """ - Create a `StorageKey` instance providing storage function details. See `subscribe_storage()`. - - Parameters - ---------- - pallet: name of pallet - storage_function: name of storage function - params: Optional list of parameters in case of a Mapped storage function - - Returns - ------- - StorageKey - """ - await self.init_runtime(block_hash=block_hash) - - return StorageKey.create_from_storage_function( - pallet, - storage_function, - params, - runtime_config=self.runtime_config, - metadata=self.metadata, - ) - - async def _get_block_handler( - self, - block_hash: str, - ignore_decoding_errors: bool = False, - include_author: bool = False, - header_only: bool = False, - finalized_only: bool = False, - subscription_handler: Optional[Callable] = None, - ): - try: - await self.init_runtime(block_hash=block_hash) - except BlockNotFound: - return None - - async def decode_block(block_data, block_data_hash=None): - if block_data: - if block_data_hash: - block_data["header"]["hash"] = block_data_hash - - if type(block_data["header"]["number"]) is str: - # Convert block number from hex (backwards compatibility) - block_data["header"]["number"] = int( - block_data["header"]["number"], 16 - ) - - extrinsic_cls = self.runtime_config.get_decoder_class("Extrinsic") - - if "extrinsics" in block_data: - for idx, extrinsic_data in enumerate(block_data["extrinsics"]): - extrinsic_decoder = extrinsic_cls( - data=ScaleBytes(extrinsic_data), - metadata=self.metadata, - runtime_config=self.runtime_config, - ) - try: - extrinsic_decoder.decode(check_remaining=True) - block_data["extrinsics"][idx] = extrinsic_decoder - - except Exception as e: - if not ignore_decoding_errors: - raise - block_data["extrinsics"][idx] = None - - for idx, log_data in enumerate(block_data["header"]["digest"]["logs"]): - if type(log_data) is str: - # Convert digest log from hex (backwards compatibility) - try: - log_digest_cls = self.runtime_config.get_decoder_class( - "sp_runtime::generic::digest::DigestItem" - ) - - if log_digest_cls is None: - raise NotImplementedError( - "No decoding class found for 'DigestItem'" - ) - - log_digest = log_digest_cls(data=ScaleBytes(log_data)) - log_digest.decode( - check_remaining=self.config.get("strict_scale_decode") - ) - - block_data["header"]["digest"]["logs"][idx] = log_digest - - if include_author and "PreRuntime" in log_digest.value: - if self.implements_scaleinfo: - engine = bytes(log_digest[1][0]) - # Retrieve validator set - parent_hash = block_data["header"]["parentHash"] - validator_set = await self.query( - "Session", "Validators", block_hash=parent_hash - ) - - if engine == b"BABE": - babe_predigest = ( - self.runtime_config.create_scale_object( - type_string="RawBabePreDigest", - data=ScaleBytes( - bytes(log_digest[1][1]) - ), - ) - ) - - babe_predigest.decode( - check_remaining=self.config.get( - "strict_scale_decode" - ) - ) - - rank_validator = babe_predigest[1].value[ - "authority_index" - ] - - block_author = validator_set[rank_validator] - block_data["author"] = block_author.value - - elif engine == b"aura": - aura_predigest = ( - self.runtime_config.create_scale_object( - type_string="RawAuraPreDigest", - data=ScaleBytes( - bytes(log_digest[1][1]) - ), - ) - ) - - aura_predigest.decode(check_remaining=True) - - rank_validator = aura_predigest.value[ - "slot_number" - ] % len(validator_set) - - block_author = validator_set[rank_validator] - block_data["author"] = block_author.value - else: - raise NotImplementedError( - f"Cannot extract author for engine {log_digest.value['PreRuntime'][0]}" - ) - else: - if ( - log_digest.value["PreRuntime"]["engine"] - == "BABE" - ): - validator_set = await self.query( - "Session", - "Validators", - block_hash=block_hash, - ) - rank_validator = log_digest.value["PreRuntime"][ - "data" - ]["authority_index"] - - block_author = validator_set.elements[ - rank_validator - ] - block_data["author"] = block_author.value - else: - raise NotImplementedError( - f"Cannot extract author for engine {log_digest.value['PreRuntime']['engine']}" - ) - - except Exception: - if not ignore_decoding_errors: - raise - block_data["header"]["digest"]["logs"][idx] = None - - return block_data - - if callable(subscription_handler): - rpc_method_prefix = "Finalized" if finalized_only else "New" - - async def result_handler(message, update_nr, subscription_id): - new_block = await decode_block({"header": message["params"]["result"]}) - - subscription_result = subscription_handler( - new_block, update_nr, subscription_id - ) - - if subscription_result is not None: - # Handler returned end result: unsubscribe from further updates - self._forgettable_task = asyncio.create_task( - self.rpc_request( - f"chain_unsubscribe{rpc_method_prefix}Heads", - [subscription_id], - ) - ) - - return subscription_result - - result = await self._make_rpc_request( - [ - self.make_payload( - "_get_block_handler", - f"chain_subscribe{rpc_method_prefix}Heads", - [], - ) - ], - result_handler=result_handler, - ) - - return result - - else: - if header_only: - response = await self.rpc_request("chain_getHeader", [block_hash]) - return await decode_block( - {"header": response["result"]}, block_data_hash=block_hash - ) - - else: - response = await self.rpc_request("chain_getBlock", [block_hash]) - return await decode_block( - response["result"]["block"], block_data_hash=block_hash - ) - - async def get_block( - self, - block_hash: Optional[str] = None, - block_number: Optional[int] = None, - ignore_decoding_errors: bool = False, - include_author: bool = False, - finalized_only: bool = False, - ) -> Optional[dict]: - """ - Retrieves a block and decodes its containing extrinsics and log digest items. If `block_hash` and `block_number` - is omitted the chain tip will be retrieve, or the finalized head if `finalized_only` is set to true. - - Either `block_hash` or `block_number` should be set, or both omitted. - - Parameters - ---------- - block_hash: the hash of the block to be retrieved - block_number: the block number to retrieved - ignore_decoding_errors: When set this will catch all decoding errors, set the item to None and continue decoding - include_author: This will retrieve the block author from the validator set and add to the result - finalized_only: when no `block_hash` or `block_number` is set, this will retrieve the finalized head - - Returns - ------- - A dict containing the extrinsic and digest logs data - """ - if block_hash and block_number: - raise ValueError("Either block_hash or block_number should be be set") - - if block_number is not None: - block_hash = await self.get_block_hash(block_number) - - if block_hash is None: - return - - if block_hash and finalized_only: - raise ValueError( - "finalized_only cannot be True when block_hash is provided" - ) - - if block_hash is None: - # Retrieve block hash - if finalized_only: - block_hash = await self.get_chain_finalised_head() - else: - block_hash = await self.get_chain_head() - - return await self._get_block_handler( - block_hash=block_hash, - ignore_decoding_errors=ignore_decoding_errors, - header_only=False, - include_author=include_author, - ) - - async def get_events(self, block_hash: Optional[str] = None) -> list: - """ - Convenience method to get events for a certain block (storage call for module 'System' and function 'Events') - - Parameters - ---------- - block_hash - - Returns - ------- - list - """ - - def convert_event_data(data): - # Extract phase information - phase_key, phase_value = next(iter(data["phase"].items())) - try: - extrinsic_idx = phase_value[0] - except IndexError: - extrinsic_idx = None - - # Extract event details - module_id, event_data = next(iter(data["event"].items())) - event_id, attributes_data = next(iter(event_data[0].items())) - - # Convert class and pays_fee dictionaries to their string equivalents if they exist - attributes = attributes_data - if isinstance(attributes, dict): - for key, value in attributes.items(): - if isinstance(value, dict): - # Convert nested single-key dictionaries to their keys as strings - sub_key = next(iter(value.keys())) - if value[sub_key] == (): - attributes[key] = sub_key - - # Create the converted dictionary - converted = { - "phase": phase_key, - "extrinsic_idx": extrinsic_idx, - "event": { - "module_id": module_id, - "event_id": event_id, - "attributes": attributes, - }, - "topics": list(data["topics"]), # Convert topics tuple to a list - } - - return converted - - events = [] - - if not block_hash: - block_hash = await self.get_chain_head() - - storage_obj = await self.query( - module="System", storage_function="Events", block_hash=block_hash - ) - if storage_obj: - for item in list(storage_obj): - # print("item!", item) - events.append(convert_event_data(item)) - # events += list(storage_obj) - return events - - async def get_block_runtime_version(self, block_hash: str) -> dict: - """ - Retrieve the runtime version id of given block_hash - """ - response = await self.rpc_request("state_getRuntimeVersion", [block_hash]) - return response.get("result") - - async def get_block_metadata( - self, block_hash: Optional[str] = None, decode: bool = True - ) -> Union[dict, ScaleType]: - """ - A pass-though to existing JSONRPC method `state_getMetadata`. - - Parameters - ---------- - block_hash - decode: True for decoded version - - Returns - ------- - - """ - params = None - if decode and not self.runtime_config: - raise ValueError( - "Cannot decode runtime configuration without a supplied runtime_config" - ) - - if block_hash: - params = [block_hash] - response = await self.rpc_request("state_getMetadata", params) - - if "error" in response: - raise SubstrateRequestException(response["error"]["message"]) - - if response.get("result") and decode: - metadata_decoder = self.runtime_config.create_scale_object( - "MetadataVersioned", data=ScaleBytes(response.get("result")) - ) - metadata_decoder.decode() - - return metadata_decoder - - return response - - async def _preprocess( - self, - query_for: Optional[list], - block_hash: Optional[str], - storage_function: str, - module: str, - ) -> Preprocessed: - """ - Creates a Preprocessed data object for passing to `_make_rpc_request` - """ - params = query_for if query_for else [] - # Search storage call in metadata - metadata_pallet = self.metadata.get_metadata_pallet(module) - - if not metadata_pallet: - raise SubstrateRequestException(f'Pallet "{module}" not found') - - storage_item = metadata_pallet.get_storage_function(storage_function) - - if not metadata_pallet or not storage_item: - raise SubstrateRequestException( - f'Storage function "{module}.{storage_function}" not found' - ) - - # SCALE type string of value - param_types = storage_item.get_params_type_string() - value_scale_type = storage_item.get_value_type_string() - - if len(params) != len(param_types): - raise ValueError( - f"Storage function requires {len(param_types)} parameters, {len(params)} given" - ) - - storage_key = StorageKey.create_from_storage_function( - module, - storage_item.value["name"], - params, - runtime_config=self.runtime_config, - metadata=self.metadata, - ) - method = "state_getStorageAt" - return Preprocessed( - str(query_for), - method, - [storage_key.to_hex(), block_hash], - value_scale_type, - storage_item, - ) - - async def _process_response( - self, - response: dict, - subscription_id: Union[int, str], - value_scale_type: Optional[str] = None, - storage_item: Optional[ScaleType] = None, - runtime: Optional[Runtime] = None, - result_handler: Optional[ResultHandler] = None, - ) -> tuple[Union[ScaleType, dict], bool]: - """ - Processes the RPC call response by decoding it, returning it as is, or setting a handler for subscriptions, - depending on the specific call. - - :param response: the RPC call response - :param subscription_id: the subscription id for subscriptions, used only for subscriptions with a result handler - :param value_scale_type: Scale Type string used for decoding ScaleBytes results - :param storage_item: The ScaleType object used for decoding ScaleBytes results - :param runtime: the runtime object, used for decoding ScaleBytes results - :param result_handler: the result handler coroutine used for handling longer-running subscriptions - - :return: (decoded response, completion) - """ - result: Union[dict, ScaleType] = response - if value_scale_type and isinstance(storage_item, ScaleType): - if not runtime: - async with self._lock: - runtime = Runtime( - self.chain, - self.runtime_config, - self.metadata, - self.type_registry, - ) - if response.get("result") is not None: - query_value = response.get("result") - elif storage_item.value["modifier"] == "Default": - # Fallback to default value of storage function if no result - query_value = storage_item.value_object["default"].value_object - else: - # No result is interpreted as an Option<...> result - value_scale_type = f"Option<{value_scale_type}>" - query_value = storage_item.value_object["default"].value_object - if isinstance(query_value, str): - q = bytes.fromhex(query_value[2:]) - elif isinstance(query_value, bytearray): - q = bytes(query_value) - else: - q = query_value - obj = await self.decode_scale(value_scale_type, q, True) - result = obj - if asyncio.iscoroutinefunction(result_handler): - # For multipart responses as a result of subscriptions. - message, bool_result = await result_handler(response, subscription_id) - return message, bool_result - return result, True - - async def _make_rpc_request( - self, - payloads: list[dict], - value_scale_type: Optional[str] = None, - storage_item: Optional[ScaleType] = None, - runtime: Optional[Runtime] = None, - result_handler: Optional[ResultHandler] = None, - ) -> RequestManager.RequestResults: - request_manager = RequestManager(payloads) - - subscription_added = False - - async with self.ws as ws: - for item in payloads: - item_id = await ws.send(item["payload"]) - request_manager.add_request(item_id, item["id"]) - - while True: - for item_id in request_manager.response_map.keys(): - if ( - item_id not in request_manager.responses - or asyncio.iscoroutinefunction(result_handler) - ): - if response := await ws.retrieve(item_id): - if ( - asyncio.iscoroutinefunction(result_handler) - and not subscription_added - ): - # handles subscriptions, overwrites the previous mapping of {item_id : payload_id} - # with {subscription_id : payload_id} - try: - item_id = request_manager.overwrite_request( - item_id, response["result"] - ) - except KeyError: - raise SubstrateRequestException(str(response)) - decoded_response, complete = await self._process_response( - response, - item_id, - value_scale_type, - storage_item, - runtime, - result_handler, - ) - request_manager.add_response( - item_id, decoded_response, complete - ) - if ( - asyncio.iscoroutinefunction(result_handler) - and not subscription_added - ): - subscription_added = True - break - - if request_manager.is_complete: - break - - return request_manager.get_results() - - @staticmethod - def make_payload(id_: str, method: str, params: list) -> dict: - """ - Creates a payload for making an rpc_request with _make_rpc_request - - :param id_: a unique name you would like to give to this request - :param method: the method in the RPC request - :param params: the params in the RPC request - - :return: the payload dict - """ - return { - "id": id_, - "payload": {"jsonrpc": "2.0", "method": method, "params": params}, - } - - async def rpc_request( - self, - method: str, - params: Optional[list], - block_hash: Optional[str] = None, - reuse_block_hash: bool = False, - ) -> Any: - """ - Makes an RPC request to the subtensor. Use this only if ``self.query`` and ``self.query_multiple`` and - ``self.query_map`` do not meet your needs. - - :param method: str the method in the RPC request - :param params: list of the params in the RPC request - :param block_hash: optional str, the hash of the block — only supply this if not supplying the block - hash in the params, and not reusing the block hash - :param reuse_block_hash: optional bool, whether to reuse the block hash in the params — only mark as True - if not supplying the block hash in the params, or via the `block_hash` parameter - - :return: the response from the RPC request - """ - block_hash = await self._get_current_block_hash(block_hash, reuse_block_hash) - params = params or [] - payload_id = f"{method}{random.randint(0, 7000)}" - payloads = [ - self.make_payload( - payload_id, - method, - params + [block_hash] if block_hash else params, - ) - ] - runtime = Runtime( - self.chain, - self.runtime_config, - self.metadata, - self.type_registry, - ) - result = await self._make_rpc_request(payloads, runtime=runtime) - if "error" in result[payload_id][0]: - raise SubstrateRequestException(result[payload_id][0]["error"]["message"]) - if "result" in result[payload_id][0]: - return result[payload_id][0] - else: - raise SubstrateRequestException(result[payload_id][0]) - - async def get_block_hash(self, block_id: int) -> str: - return (await self.rpc_request("chain_getBlockHash", [block_id]))["result"] - - async def get_chain_head(self) -> str: - result = await self._make_rpc_request( - [ - self.make_payload( - "rpc_request", - "chain_getHead", - [], - ) - ], - runtime=Runtime( - self.chain, - self.runtime_config, - self.metadata, - self.type_registry, - ), - ) - self.last_block_hash = result["rpc_request"][0]["result"] - return result["rpc_request"][0]["result"] - - async def compose_call( - self, - call_module: str, - call_function: str, - call_params: Optional[dict] = None, - block_hash: Optional[str] = None, - ) -> GenericCall: - """ - Composes a call payload which can be used in an extrinsic. - - :param call_module: Name of the runtime module e.g. Balances - :param call_function: Name of the call function e.g. transfer - :param call_params: This is a dict containing the params of the call. e.g. - `{'dest': 'EaG2CRhJWPb7qmdcJvy3LiWdh26Jreu9Dx6R1rXxPmYXoDk', 'value': 1000000000000}` - :param block_hash: Use metadata at given block_hash to compose call - - :return: A composed call - """ - if call_params is None: - call_params = {} - - await self.init_runtime(block_hash=block_hash) - call = self.runtime_config.create_scale_object( - type_string="Call", metadata=self.metadata - ) - - call.encode( - { - "call_module": call_module, - "call_function": call_function, - "call_args": call_params, - } - ) - - return call - - async def query_multiple( - self, - params: list, - storage_function: str, - module: str, - block_hash: Optional[str] = None, - reuse_block_hash: bool = False, - ) -> dict[str, ScaleType]: - """ - Queries the subtensor. Only use this when making multiple queries, else use ``self.query`` - """ - # By allowing for specifying the block hash, users, if they have multiple query types they want - # to do, can simply query the block hash first, and then pass multiple query_subtensor calls - # into an asyncio.gather, with the specified block hash - block_hash = await self._get_current_block_hash(block_hash, reuse_block_hash) - if block_hash: - self.last_block_hash = block_hash - runtime = await self.init_runtime(block_hash=block_hash) - preprocessed: tuple[Preprocessed] = await asyncio.gather( - *[ - self._preprocess([x], block_hash, storage_function, module) - for x in params - ] - ) - all_info = [ - self.make_payload(item.queryable, item.method, item.params) - for item in preprocessed - ] - # These will always be the same throughout the preprocessed list, so we just grab the first one - value_scale_type = preprocessed[0].value_scale_type - storage_item = preprocessed[0].storage_item - - responses = await self._make_rpc_request( - all_info, value_scale_type, storage_item, runtime - ) - return { - param: responses[p.queryable][0] for (param, p) in zip(params, preprocessed) - } - - async def query_multi( - self, storage_keys: list[StorageKey], block_hash: Optional[str] = None - ) -> list: - """ - Query multiple storage keys in one request. - - Example: - - ``` - storage_keys = [ - substrate.create_storage_key( - "System", "Account", ["F4xQKRUagnSGjFqafyhajLs94e7Vvzvr8ebwYJceKpr8R7T"] - ), - substrate.create_storage_key( - "System", "Account", ["GSEX8kR4Kz5UZGhvRUCJG93D5hhTAoVZ5tAe6Zne7V42DSi"] - ) - ] - - result = substrate.query_multi(storage_keys) - ``` - - Parameters - ---------- - storage_keys: list of StorageKey objects - block_hash: Optional block_hash of state snapshot - - Returns - ------- - list of `(storage_key, scale_obj)` tuples - """ - - await self.init_runtime(block_hash=block_hash) - - # Retrieve corresponding value - response = await self.rpc_request( - "state_queryStorageAt", [[s.to_hex() for s in storage_keys], block_hash] - ) - - if "error" in response: - raise SubstrateRequestException(response["error"]["message"]) - - result = [] - - storage_key_map = {s.to_hex(): s for s in storage_keys} - - for result_group in response["result"]: - for change_storage_key, change_data in result_group["changes"]: - # Decode result for specified storage_key - storage_key = storage_key_map[change_storage_key] - if change_data is None: - change_data = b"\x00" - else: - change_data = bytes.fromhex(change_data[2:]) - result.append( - ( - storage_key, - await self.decode_scale( - storage_key.value_scale_type, change_data - ), - ) - ) - - return result - - async def create_scale_object( - self, - type_string: str, - data: Optional[ScaleBytes] = None, - block_hash: Optional[str] = None, - **kwargs, - ) -> "ScaleType": - """ - Convenience method to create a SCALE object of type `type_string`, this will initialize the runtime - automatically at moment of `block_hash`, or chain tip if omitted. - - :param type_string: str Name of SCALE type to create - :param data: ScaleBytes Optional ScaleBytes to decode - :param block_hash: Optional block hash for moment of decoding, when omitted the chain tip will be used - :param kwargs: keyword args for the Scale Type constructor - - :return: The created Scale Type object - """ - runtime = await self.init_runtime(block_hash=block_hash) - if "metadata" not in kwargs: - kwargs["metadata"] = runtime.metadata - - return runtime.runtime_config.create_scale_object( - type_string, data=data, **kwargs - ) - - async def generate_signature_payload( - self, - call: GenericCall, - era=None, - nonce: int = 0, - tip: int = 0, - tip_asset_id: Optional[int] = None, - include_call_length: bool = False, - ) -> ScaleBytes: - # Retrieve genesis hash - genesis_hash = await self.get_block_hash(0) - - if not era: - era = "00" - - if era == "00": - # Immortal extrinsic - block_hash = genesis_hash - else: - # Determine mortality of extrinsic - era_obj = self.runtime_config.create_scale_object("Era") - - if isinstance(era, dict) and "current" not in era and "phase" not in era: - raise ValueError( - 'The era dict must contain either "current" or "phase" element to encode a valid era' - ) - - era_obj.encode(era) - block_hash = await self.get_block_hash( - block_id=era_obj.birth(era.get("current")) - ) - - # Create signature payload - signature_payload = self.runtime_config.create_scale_object( - "ExtrinsicPayloadValue" - ) - - # Process signed extensions in metadata - if "signed_extensions" in self.metadata[1][1]["extrinsic"]: - # Base signature payload - signature_payload.type_mapping = [["call", "CallBytes"]] - - # Add signed extensions to payload - signed_extensions = self.metadata.get_signed_extensions() - - if "CheckMortality" in signed_extensions: - signature_payload.type_mapping.append( - ["era", signed_extensions["CheckMortality"]["extrinsic"]] - ) - - if "CheckEra" in signed_extensions: - signature_payload.type_mapping.append( - ["era", signed_extensions["CheckEra"]["extrinsic"]] - ) - - if "CheckNonce" in signed_extensions: - signature_payload.type_mapping.append( - ["nonce", signed_extensions["CheckNonce"]["extrinsic"]] - ) - - if "ChargeTransactionPayment" in signed_extensions: - signature_payload.type_mapping.append( - ["tip", signed_extensions["ChargeTransactionPayment"]["extrinsic"]] - ) - - if "ChargeAssetTxPayment" in signed_extensions: - signature_payload.type_mapping.append( - ["asset_id", signed_extensions["ChargeAssetTxPayment"]["extrinsic"]] - ) - - if "CheckMetadataHash" in signed_extensions: - signature_payload.type_mapping.append( - ["mode", signed_extensions["CheckMetadataHash"]["extrinsic"]] - ) - - if "CheckSpecVersion" in signed_extensions: - signature_payload.type_mapping.append( - [ - "spec_version", - signed_extensions["CheckSpecVersion"]["additional_signed"], - ] - ) - - if "CheckTxVersion" in signed_extensions: - signature_payload.type_mapping.append( - [ - "transaction_version", - signed_extensions["CheckTxVersion"]["additional_signed"], - ] - ) - - if "CheckGenesis" in signed_extensions: - signature_payload.type_mapping.append( - [ - "genesis_hash", - signed_extensions["CheckGenesis"]["additional_signed"], - ] - ) - - if "CheckMortality" in signed_extensions: - signature_payload.type_mapping.append( - [ - "block_hash", - signed_extensions["CheckMortality"]["additional_signed"], - ] - ) - - if "CheckEra" in signed_extensions: - signature_payload.type_mapping.append( - ["block_hash", signed_extensions["CheckEra"]["additional_signed"]] - ) - - if "CheckMetadataHash" in signed_extensions: - signature_payload.type_mapping.append( - [ - "metadata_hash", - signed_extensions["CheckMetadataHash"]["additional_signed"], - ] - ) - - if include_call_length: - length_obj = self.runtime_config.create_scale_object("Bytes") - call_data = str(length_obj.encode(str(call.data))) - - else: - call_data = str(call.data) - - payload_dict = { - "call": call_data, - "era": era, - "nonce": nonce, - "tip": tip, - "spec_version": self.runtime_version, - "genesis_hash": genesis_hash, - "block_hash": block_hash, - "transaction_version": self.transaction_version, - "asset_id": {"tip": tip, "asset_id": tip_asset_id}, - "metadata_hash": None, - "mode": "Disabled", - } - - signature_payload.encode(payload_dict) - - if signature_payload.data.length > 256: - return ScaleBytes( - data=blake2b(signature_payload.data.data, digest_size=32).digest() - ) - - return signature_payload.data - - async def create_signed_extrinsic( - self, - call: GenericCall, - keypair: Keypair, - era: Optional[dict] = None, - nonce: Optional[int] = None, - tip: int = 0, - tip_asset_id: Optional[int] = None, - signature: Optional[Union[bytes, str]] = None, - ) -> "GenericExtrinsic": - """ - Creates an extrinsic signed by given account details - - :param call: GenericCall to create extrinsic for - :param keypair: Keypair used to sign the extrinsic - :param era: Specify mortality in blocks in follow format: - {'period': [amount_blocks]} If omitted the extrinsic is immortal - :param nonce: nonce to include in extrinsics, if omitted the current nonce is retrieved on-chain - :param tip: The tip for the block author to gain priority during network congestion - :param tip_asset_id: Optional asset ID with which to pay the tip - :param signature: Optionally provide signature if externally signed - - :return: The signed Extrinsic - """ - if not self.metadata: - await self.init_runtime() - - # Check requirements - if not isinstance(call, GenericCall): - raise TypeError("'call' must be of type Call") - - # Check if extrinsic version is supported - if self.metadata[1][1]["extrinsic"]["version"] != 4: # type: ignore - raise NotImplementedError( - f"Extrinsic version {self.metadata[1][1]['extrinsic']['version']} not supported" # type: ignore - ) - - # Retrieve nonce - if nonce is None: - nonce = await self.get_account_nonce(keypair.ss58_address) or 0 - - # Process era - if era is None: - era = "00" - else: - if isinstance(era, dict) and "current" not in era and "phase" not in era: - # Retrieve current block id - era["current"] = await self.get_block_number( - await self.get_chain_finalised_head() - ) - - if signature is not None: - if isinstance(signature, str) and signature[0:2] == "0x": - signature = bytes.fromhex(signature[2:]) - - # Check if signature is a MultiSignature and contains signature version - if len(signature) == 65: - signature_version = signature[0] - signature = signature[1:] - else: - signature_version = keypair.crypto_type - - else: - # Create signature payload - signature_payload = await self.generate_signature_payload( - call=call, era=era, nonce=nonce, tip=tip, tip_asset_id=tip_asset_id - ) - - # Set Signature version to crypto type of keypair - signature_version = keypair.crypto_type - - # Sign payload - signature = keypair.sign(signature_payload) - - # Create extrinsic - extrinsic = self.runtime_config.create_scale_object( - type_string="Extrinsic", metadata=self.metadata - ) - value = { - "account_id": f"0x{keypair.public_key.hex()}", - "signature": f"0x{signature.hex()}", - "call_function": call.value["call_function"], - "call_module": call.value["call_module"], - "call_args": call.value["call_args"], - "nonce": nonce, - "era": era, - "tip": tip, - "asset_id": {"tip": tip, "asset_id": tip_asset_id}, - "mode": "Disabled", - } - - # Check if ExtrinsicSignature is MultiSignature, otherwise omit signature_version - signature_cls = self.runtime_config.get_decoder_class("ExtrinsicSignature") - if issubclass(signature_cls, self.runtime_config.get_decoder_class("Enum")): - value["signature_version"] = signature_version - extrinsic.encode(value) - return extrinsic - - async def get_chain_finalised_head(self): - """ - A pass-though to existing JSONRPC method `chain_getFinalizedHead` - - Returns - ------- - - """ - response = await self.rpc_request("chain_getFinalizedHead", []) - - if response is not None: - if "error" in response: - raise SubstrateRequestException(response["error"]["message"]) - - return response.get("result") - - async def runtime_call( - self, - api: str, - method: str, - params: Optional[Union[list, dict]] = None, - block_hash: Optional[str] = None, - ) -> ScaleType: - """ - Calls a runtime API method - - :param api: Name of the runtime API e.g. 'TransactionPaymentApi' - :param method: Name of the method e.g. 'query_fee_details' - :param params: List of parameters needed to call the runtime API - :param block_hash: Hash of the block at which to make the runtime API call - - :return: ScaleType from the runtime call - """ - await self.init_runtime() - - if params is None: - params = {} - - try: - runtime_call_def = self.runtime_config.type_registry["runtime_api"][api][ - "methods" - ][method] - runtime_api_types = self.runtime_config.type_registry["runtime_api"][ - api - ].get("types", {}) - except KeyError: - raise ValueError(f"Runtime API Call '{api}.{method}' not found in registry") - - if isinstance(params, list) and len(params) != len(runtime_call_def["params"]): - raise ValueError( - f"Number of parameter provided ({len(params)}) does not " - f"match definition {len(runtime_call_def['params'])}" - ) - - # Add runtime API types to registry - self.runtime_config.update_type_registry_types(runtime_api_types) - runtime = Runtime( - self.chain, - self.runtime_config, - self.metadata, - self.type_registry, - ) - - # Encode params - param_data = ScaleBytes(bytes()) - for idx, param in enumerate(runtime_call_def["params"]): - scale_obj = runtime.runtime_config.create_scale_object(param["type"]) - if isinstance(params, list): - param_data += scale_obj.encode(params[idx]) - else: - if param["name"] not in params: - raise ValueError(f"Runtime Call param '{param['name']}' is missing") - - param_data += scale_obj.encode(params[param["name"]]) - - # RPC request - result_data = await self.rpc_request( - "state_call", [f"{api}_{method}", str(param_data), block_hash] - ) - - # Decode result - # TODO update this to use bt-decode - result_obj = runtime.runtime_config.create_scale_object( - runtime_call_def["type"] - ) - result_obj.decode( - ScaleBytes(result_data["result"]), - check_remaining=self.config.get("strict_scale_decode"), - ) - - return result_obj - - async def get_account_nonce(self, account_address: str) -> int: - """ - Returns current nonce for given account address - - :param account_address: SS58 formatted address - - :return: Nonce for given account address - """ - nonce_obj = await self.runtime_call( - "AccountNonceApi", "account_nonce", [account_address] - ) - return nonce_obj.value - - async def get_metadata_constant(self, module_name, constant_name, block_hash=None): - """ - Retrieves the details of a constant for given module name, call function name and block_hash - (or chaintip if block_hash is omitted) - - Parameters - ---------- - module_name - constant_name - block_hash - - Returns - ------- - MetadataModuleConstants - """ - - await self.init_runtime(block_hash=block_hash) - - for module in self.metadata.pallets: - if module_name == module.name and module.constants: - for constant in module.constants: - if constant_name == constant.value["name"]: - return constant - - async def get_constant( - self, - module_name: str, - constant_name: str, - block_hash: Optional[str] = None, - reuse_block_hash: bool = False, - ) -> Optional["ScaleType"]: - """ - Returns the decoded `ScaleType` object of the constant for given module name, call function name and block_hash - (or chaintip if block_hash is omitted) - - Parameters - ---------- - :param module_name: Name of the module to query - :param constant_name: Name of the constant to query - :param block_hash: Hash of the block at which to make the runtime API call - :param reuse_block_hash: Reuse last-used block hash if set to true - - :return: ScaleType from the runtime call - """ - block_hash = await self._get_current_block_hash(block_hash, reuse_block_hash) - constant = await self.get_metadata_constant( - module_name, constant_name, block_hash=block_hash - ) - if constant: - # Decode to ScaleType - return await self.decode_scale( - constant.type, - bytes(constant.constant_value), - return_scale_obj=True, - ) - else: - return None - - async def get_payment_info( - self, call: GenericCall, keypair: Keypair - ) -> dict[str, Any]: - """ - Retrieves fee estimation via RPC for given extrinsic - - Parameters - ---------- - call: Call object to estimate fees for - keypair: Keypair of the sender, does not have to include private key because no valid signature is required - - Returns - ------- - Dict with payment info - - E.g. `{'class': 'normal', 'partialFee': 151000000, 'weight': {'ref_time': 143322000}}` - - """ - - # Check requirements - if not isinstance(call, GenericCall): - raise TypeError("'call' must be of type Call") - - if not isinstance(keypair, Keypair): - raise TypeError("'keypair' must be of type Keypair") - - # No valid signature is required for fee estimation - signature = "0x" + "00" * 64 - - # Create extrinsic - extrinsic = await self.create_signed_extrinsic( - call=call, keypair=keypair, signature=signature - ) - extrinsic_len = self.runtime_config.create_scale_object("u32") - extrinsic_len.encode(len(extrinsic.data)) - - result = await self.runtime_call( - "TransactionPaymentApi", "query_info", [extrinsic, extrinsic_len] - ) - - return result.value - - async def query( - self, - module: str, - storage_function: str, - params: Optional[list] = None, - block_hash: Optional[str] = None, - raw_storage_key: Optional[bytes] = None, - subscription_handler=None, - reuse_block_hash: bool = False, - ) -> "ScaleType": - """ - Queries subtensor. This should only be used when making a single request. For multiple requests, - you should use ``self.query_multiple`` - """ - block_hash = await self._get_current_block_hash(block_hash, reuse_block_hash) - if block_hash: - self.last_block_hash = block_hash - runtime = await self.init_runtime(block_hash=block_hash) - preprocessed: Preprocessed = await self._preprocess( - params, block_hash, storage_function, module - ) - payload = [ - self.make_payload( - preprocessed.queryable, preprocessed.method, preprocessed.params - ) - ] - value_scale_type = preprocessed.value_scale_type - storage_item = preprocessed.storage_item - - responses = await self._make_rpc_request( - payload, - value_scale_type, - storage_item, - runtime, - result_handler=subscription_handler, - ) - return responses[preprocessed.queryable][0] - - async def query_map( - self, - module: str, - storage_function: str, - params: Optional[list] = None, - block_hash: Optional[str] = None, - max_results: Optional[int] = None, - start_key: Optional[str] = None, - page_size: int = 100, - ignore_decoding_errors: bool = False, - reuse_block_hash: bool = False, - ) -> "QueryMapResult": - """ - Iterates over all key-pairs located at the given module and storage_function. The storage - item must be a map. - - Example: - - ``` - result = await substrate.query_map('System', 'Account', max_results=100) - - async for account, account_info in result: - print(f"Free balance of account '{account.value}': {account_info.value['data']['free']}") - ``` - - Note: it is important that you do not use `for x in result.records`, as this will sidestep possible - pagination. You must do `async for x in result`. - - :param module: The module name in the metadata, e.g. System or Balances. - :param storage_function: The storage function name, e.g. Account or Locks. - :param params: The input parameters in case of for example a `DoubleMap` storage function - :param block_hash: Optional block hash for result at given block, when left to None the chain tip will be used. - :param max_results: the maximum of results required, if set the query will stop fetching results when number is - reached - :param start_key: The storage key used as offset for the results, for pagination purposes - :param page_size: The results are fetched from the node RPC in chunks of this size - :param ignore_decoding_errors: When set this will catch all decoding errors, set the item to None and continue - decoding - :param reuse_block_hash: use True if you wish to make the query using the last-used block hash. Do not mark True - if supplying a block_hash - - :return: QueryMapResult object - """ - params = params or [] - block_hash = await self._get_current_block_hash(block_hash, reuse_block_hash) - if block_hash: - self.last_block_hash = block_hash - runtime = await self.init_runtime(block_hash=block_hash) - - metadata_pallet = runtime.metadata.get_metadata_pallet(module) - if not metadata_pallet: - raise ValueError(f'Pallet "{module}" not found') - storage_item = metadata_pallet.get_storage_function(storage_function) - - if not metadata_pallet or not storage_item: - raise ValueError( - f'Storage function "{module}.{storage_function}" not found' - ) - - value_type = storage_item.get_value_type_string() - param_types = storage_item.get_params_type_string() - key_hashers = storage_item.get_param_hashers() - - # Check MapType conditions - if len(param_types) == 0: - raise ValueError("Given storage function is not a map") - if len(params) > len(param_types) - 1: - raise ValueError( - f"Storage function map can accept max {len(param_types) - 1} parameters, {len(params)} given" - ) - - # Generate storage key prefix - storage_key = StorageKey.create_from_storage_function( - module, - storage_item.value["name"], - params, - runtime_config=runtime.runtime_config, - metadata=runtime.metadata, - ) - prefix = storage_key.to_hex() - - if not start_key: - start_key = prefix - - # Make sure if the max result is smaller than the page size, adjust the page size - if max_results is not None and max_results < page_size: - page_size = max_results - - # Retrieve storage keys - response = await self.rpc_request( - method="state_getKeysPaged", - params=[prefix, page_size, start_key, block_hash], - ) - - if "error" in response: - raise SubstrateRequestException(response["error"]["message"]) - - result_keys = response.get("result") - - result = [] - last_key = None - - def concat_hash_len(key_hasher: str) -> int: - """ - Helper function to avoid if statements - """ - if key_hasher == "Blake2_128Concat": - return 16 - elif key_hasher == "Twox64Concat": - return 8 - elif key_hasher == "Identity": - return 0 - else: - raise ValueError("Unsupported hash type") - - if len(result_keys) > 0: - last_key = result_keys[-1] - - # Retrieve corresponding value - response = await self.rpc_request( - method="state_queryStorageAt", params=[result_keys, block_hash] - ) - - if "error" in response: - raise SubstrateRequestException(response["error"]["message"]) - - for result_group in response["result"]: - for item in result_group["changes"]: - try: - # Determine type string - key_type_string = [] - for n in range(len(params), len(param_types)): - key_type_string.append( - f"[u8; {concat_hash_len(key_hashers[n])}]" - ) - key_type_string.append(param_types[n]) - - item_key_obj = await self.decode_scale( - type_string=f"({', '.join(key_type_string)})", - scale_bytes=bytes.fromhex(item[0][len(prefix) :]), - return_scale_obj=True, - ) - - # strip key_hashers to use as item key - if len(param_types) - len(params) == 1: - item_key = item_key_obj[1] - else: - item_key = tuple( - item_key_obj[key + 1] - for key in range(len(params), len(param_types) + 1, 2) - ) - - except Exception as _: - if not ignore_decoding_errors: - raise - item_key = None - - try: - item_bytes = hex_to_bytes(item[1]) - - item_value = await self.decode_scale( - type_string=value_type, - scale_bytes=item_bytes, - return_scale_obj=True, - ) - except Exception as _: - if not ignore_decoding_errors: - raise - item_value = None - - result.append([item_key, item_value]) - - return QueryMapResult( - records=result, - page_size=page_size, - module=module, - storage_function=storage_function, - params=params, - block_hash=block_hash, - substrate=self, - last_key=last_key, - max_results=max_results, - ignore_decoding_errors=ignore_decoding_errors, - ) - - async def submit_extrinsic( - self, - extrinsic: GenericExtrinsic, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = False, - ) -> "ExtrinsicReceipt": - """ - Submit an extrinsic to the connected node, with the possibility to wait until the extrinsic is included - in a block and/or the block is finalized. The receipt returned provided information about the block and - triggered events - - Parameters - ---------- - extrinsic: Extrinsic The extrinsic to be sent to the network - wait_for_inclusion: wait until extrinsic is included in a block (only works for websocket connections) - wait_for_finalization: wait until extrinsic is finalized (only works for websocket connections) - - Returns - ------- - ExtrinsicReceipt - - """ - - # Check requirements - if not isinstance(extrinsic, GenericExtrinsic): - raise TypeError("'extrinsic' must be of type Extrinsics") - - async def result_handler(message: dict, subscription_id) -> tuple[dict, bool]: - """ - Result handler function passed as an arg to _make_rpc_request as the result_handler - to handle the results of the extrinsic rpc call, which are multipart, and require - subscribing to the message - - :param message: message received from the rpc call - :param subscription_id: subscription id received from the initial rpc call for the subscription - - :returns: tuple containing the dict of the block info for the subscription, and bool for whether - the subscription is completed. - """ - # Check if extrinsic is included and finalized - if "params" in message and isinstance(message["params"]["result"], dict): - # Convert result enum to lower for backwards compatibility - message_result = { - k.lower(): v for k, v in message["params"]["result"].items() - } - - if "finalized" in message_result and wait_for_finalization: - # Created as a task because we don't actually care about the result - self._forgettable_task = asyncio.create_task( - self.rpc_request("author_unwatchExtrinsic", [subscription_id]) - ) - return { - "block_hash": message_result["finalized"], - "extrinsic_hash": "0x{}".format(extrinsic.extrinsic_hash.hex()), - "finalized": True, - }, True - elif ( - "inblock" in message_result - and wait_for_inclusion - and not wait_for_finalization - ): - # Created as a task because we don't actually care about the result - self._forgettable_task = asyncio.create_task( - self.rpc_request("author_unwatchExtrinsic", [subscription_id]) - ) - return { - "block_hash": message_result["inblock"], - "extrinsic_hash": "0x{}".format(extrinsic.extrinsic_hash.hex()), - "finalized": False, - }, True - return message, False - - if wait_for_inclusion or wait_for_finalization: - responses = ( - await self._make_rpc_request( - [ - self.make_payload( - "rpc_request", - "author_submitAndWatchExtrinsic", - [str(extrinsic.data)], - ) - ], - result_handler=result_handler, - ) - )["rpc_request"] - response = next( - (r for r in responses if "block_hash" in r and "extrinsic_hash" in r), - None, - ) - - if not response: - raise SubstrateRequestException(responses) - - # Also, this will be a multipart response, so maybe should change to everything after the first response? - # The following code implies this will be a single response after the initial subscription id. - result = ExtrinsicReceipt( - substrate=self, - extrinsic_hash=response["extrinsic_hash"], - block_hash=response["block_hash"], - finalized=response["finalized"], - ) - - else: - response = await self.rpc_request( - "author_submitExtrinsic", [str(extrinsic.data)] - ) - - if "result" not in response: - raise SubstrateRequestException(response.get("error")) - - result = ExtrinsicReceipt(substrate=self, extrinsic_hash=response["result"]) - - return result - - async def get_metadata_call_function( - self, - module_name: str, - call_function_name: str, - block_hash: Optional[str] = None, - ) -> Optional[list]: - """ - Retrieves a list of all call functions in metadata active for given block_hash (or chaintip if block_hash - is omitted) - - :param module_name: name of the module - :param call_function_name: name of the call function - :param block_hash: optional block hash - - :return: list of call functions - """ - runtime = await self.init_runtime(block_hash=block_hash) - - for pallet in runtime.metadata.pallets: - if pallet.name == module_name and pallet.calls: - for call in pallet.calls: - if call.name == call_function_name: - return call - return None - - async def get_block_number(self, block_hash: Optional[str]) -> int: - """Async version of `substrateinterface.base.get_block_number` method.""" - response = await self.rpc_request("chain_getHeader", [block_hash]) - - if "error" in response: - raise SubstrateRequestException(response["error"]["message"]) - - elif "result" in response: - if response["result"]: - return int(response["result"]["number"], 16) - - async def close(self): - """ - Closes the substrate connection, and the websocket connection. - """ - try: - await self.ws.shutdown() - except AttributeError: - pass diff --git a/bittensor_cli/src/bittensor/balances.py b/bittensor_cli/src/bittensor/balances.py index 1e678c9d..3a9c2fc1 100644 --- a/bittensor_cli/src/bittensor/balances.py +++ b/bittensor_cli/src/bittensor/balances.py @@ -18,6 +18,7 @@ # DEALINGS IN THE SOFTWARE. from typing import Union +from bittensor_cli.src import UNITS class Balance: @@ -72,7 +73,10 @@ def __str__(self): """ Returns the Balance object as a string in the format "symbolvalue", where the value is in tao. """ - return f"{self.unit}{float(self.tao):,.9f}" + if self.unit == UNITS[0]: + return f"{self.unit} {float(self.tao):,.4f}" + else: + return f"{float(self.tao):,.4f} {self.unit}\u200e" def __rich__(self): return "[green]{}[/green][green]{}[/green][green].[/green][dim green]{}[/dim green]".format( @@ -225,12 +229,6 @@ def __rfloordiv__(self, other: Union[int, float, "Balance"]): except (ValueError, TypeError): raise NotImplementedError("Unsupported type") - def __int__(self) -> int: - return self.rao - - def __float__(self) -> float: - return self.tao - def __nonzero__(self) -> bool: return bool(self.rao) @@ -278,4 +276,39 @@ def from_rao(amount: int): :return: A Balance object representing the given amount. """ - return Balance(amount) + return Balance(int(amount)) + + @staticmethod + def get_unit(netuid: int): + units = UNITS + base = len(units) + if netuid < base: + return units[netuid] + else: + result = "" + while netuid > 0: + result = units[netuid % base] + result + netuid //= base + return result + + def set_unit(self, netuid: int): + self.unit = Balance.get_unit(netuid) + self.rao_unit = Balance.get_unit(netuid) + return self + + +def fixed_to_float(fixed: dict) -> float: + # Currently this is stored as a U64F64 + # which is 64 bits of integer and 64 bits of fractional + # uint_bits = 64 + frac_bits = 64 + + data: int = fixed["bits"] + + # Shift bits to extract integer part (assuming 64 bits for integer part) + integer_part = data >> frac_bits + fractional_part = data & (2**frac_bits - 1) + + frac_float = fractional_part / (2**frac_bits) + + return integer_part + frac_float diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py index 73f41b1f..3b5a4c89 100644 --- a/bittensor_cli/src/bittensor/chain_data.py +++ b/bittensor_cli/src/bittensor/chain_data.py @@ -1,25 +1,59 @@ +from abc import abstractmethod from dataclasses import dataclass -from typing import Optional +from enum import Enum +from typing import Optional, Any, Union -import bt_decode import netaddr from scalecodec.utils.ss58 import ss58_encode from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.networking import int_to_ip -from bittensor_cli.src.bittensor.utils import SS58_FORMAT, u16_normalized_float - - -def decode_account_id(account_id_bytes: tuple): - # Convert the AccountId bytes to a Base64 string - return ss58_encode(bytes(account_id_bytes).hex(), SS58_FORMAT) - - -def process_stake_data(stake_data): +from bittensor_cli.src.bittensor.utils import ( + SS58_FORMAT, + u16_normalized_float, + decode_account_id, +) + + +class ChainDataType(Enum): + NeuronInfo = 1 + DelegateInfo = 2 + NeuronInfoLite = 3 + StakeInfo = 4 + SubnetHyperparameters = 5 + DelegateInfoLite = 6 + DynamicInfo = 7 + ScheduledColdkeySwapInfo = 8 + SubnetInfo = 9 + SubnetState = 10 + SubnetIdentity = 11 + + +def decode_hex_identity(info_dictionary): + decoded_info = {} + for k, v in info_dictionary.items(): + if isinstance(v, dict): + item = next(iter(v.values())) + else: + item = v + + if isinstance(item, tuple): + try: + decoded_info[k] = bytes(item).decode() + except UnicodeDecodeError: + print(f"Could not decode: {k}: {item}") + else: + decoded_info[k] = item + return decoded_info + + +def process_stake_data(stake_data, netuid): decoded_stake_data = {} for account_id_bytes, stake_ in stake_data: account_id = decode_account_id(account_id_bytes) - decoded_stake_data.update({account_id: Balance.from_rao(stake_)}) + decoded_stake_data.update( + {account_id: Balance.from_rao(stake_).set_unit(netuid)} + ) return decoded_stake_data @@ -62,14 +96,39 @@ def from_neuron_info(cls, neuron_info: dict) -> "AxonInfo": @dataclass -class SubnetHyperparameters: +class InfoBase: + """Base dataclass for info objects.""" + + @abstractmethod + def _fix_decoded(self, decoded: Any) -> "InfoBase": + raise NotImplementedError( + "This is an abstract method and must be implemented in a subclass." + ) + + @classmethod + def from_any(cls, data: Any) -> "InfoBase": + return cls._fix_decoded(data) + + @classmethod + def list_from_any(cls, data_list: list[Any]) -> list["InfoBase"]: + return [cls.from_any(data) for data in data_list] + + def __getitem__(self, item): + return getattr(self, item) + + def get(self, item, default=None): + return getattr(self, item, default) + + +@dataclass +class SubnetHyperparameters(InfoBase): """Dataclass for subnet hyperparameters.""" rho: int kappa: int immunity_period: int min_allowed_weights: int - max_weight_limit: float + max_weights_limit: float tempo: int min_difficulty: int max_difficulty: int @@ -87,90 +146,78 @@ class SubnetHyperparameters: max_validators: int adjustment_alpha: int difficulty: int - commit_reveal_weights_interval: int + commit_reveal_period: int commit_reveal_weights_enabled: bool alpha_high: int alpha_low: int liquid_alpha_enabled: bool @classmethod - def from_vec_u8(cls, vec_u8: bytes) -> Optional["SubnetHyperparameters"]: - decoded = bt_decode.SubnetHyperparameters.decode(vec_u8) + def _fix_decoded( + cls, decoded: Union[dict, "SubnetHyperparameters"] + ) -> "SubnetHyperparameters": return SubnetHyperparameters( - rho=decoded.rho, - kappa=decoded.kappa, - immunity_period=decoded.immunity_period, - min_allowed_weights=decoded.min_allowed_weights, - max_weight_limit=decoded.max_weights_limit, - tempo=decoded.tempo, - min_difficulty=decoded.min_difficulty, - max_difficulty=decoded.max_difficulty, - weights_version=decoded.weights_version, - weights_rate_limit=decoded.weights_rate_limit, - adjustment_interval=decoded.adjustment_interval, - activity_cutoff=decoded.activity_cutoff, - registration_allowed=decoded.registration_allowed, - target_regs_per_interval=decoded.target_regs_per_interval, - min_burn=decoded.min_burn, - max_burn=decoded.max_burn, - bonds_moving_avg=decoded.bonds_moving_avg, - max_regs_per_block=decoded.max_regs_per_block, - serving_rate_limit=decoded.serving_rate_limit, - max_validators=decoded.max_validators, - adjustment_alpha=decoded.adjustment_alpha, - difficulty=decoded.difficulty, - commit_reveal_weights_interval=decoded.commit_reveal_weights_interval, - commit_reveal_weights_enabled=decoded.commit_reveal_weights_enabled, - alpha_high=decoded.alpha_high, - alpha_low=decoded.alpha_low, - liquid_alpha_enabled=decoded.liquid_alpha_enabled, + rho=decoded.get("rho"), + kappa=decoded.get("kappa"), + immunity_period=decoded.get("immunity_period"), + min_allowed_weights=decoded.get("min_allowed_weights"), + max_weights_limit=decoded.get("max_weights_limit"), + tempo=decoded.get("tempo"), + min_difficulty=decoded.get("min_difficulty"), + max_difficulty=decoded.get("max_difficulty"), + weights_version=decoded.get("weights_version"), + weights_rate_limit=decoded.get("weights_rate_limit"), + adjustment_interval=decoded.get("adjustment_interval"), + activity_cutoff=decoded.get("activity_cutoff"), + registration_allowed=decoded.get("registration_allowed"), + target_regs_per_interval=decoded.get("target_regs_per_interval"), + min_burn=decoded.get("min_burn"), + max_burn=decoded.get("max_burn"), + bonds_moving_avg=decoded.get("bonds_moving_avg"), + max_regs_per_block=decoded.get("max_regs_per_block"), + serving_rate_limit=decoded.get("serving_rate_limit"), + max_validators=decoded.get("max_validators"), + adjustment_alpha=decoded.get("adjustment_alpha"), + difficulty=decoded.get("difficulty"), + commit_reveal_period=decoded.get("commit_reveal_period"), + commit_reveal_weights_enabled=decoded.get("commit_reveal_weights_enabled"), + alpha_high=decoded.get("alpha_high"), + alpha_low=decoded.get("alpha_low"), + liquid_alpha_enabled=decoded.get("liquid_alpha_enabled"), ) @dataclass -class StakeInfo: +class StakeInfo(InfoBase): """Dataclass for stake info.""" hotkey_ss58: str # Hotkey address coldkey_ss58: str # Coldkey address + netuid: int stake: Balance # Stake for the hotkey-coldkey pair + locked: Balance # Stake which is locked. + emission: Balance # Emission for the hotkey-coldkey pair + drain: int + is_registered: bool @classmethod - def list_from_vec_u8(cls, vec_u8: bytes) -> list["StakeInfo"]: - """ - Returns a list of StakeInfo objects from a `vec_u8`. - """ - decoded = bt_decode.StakeInfo.decode_vec(vec_u8) - results = [] - for d in decoded: - hotkey = decode_account_id(d.hotkey) - coldkey = decode_account_id(d.coldkey) - stake = Balance.from_rao(d.stake) - results.append(StakeInfo(hotkey, coldkey, stake)) - - return results - - -@dataclass -class PrometheusInfo: - """Dataclass for prometheus info.""" - - block: int - version: int - ip: str - port: int - ip_type: int - - @classmethod - def fix_decoded_values(cls, prometheus_info_decoded: dict) -> "PrometheusInfo": - """Returns a PrometheusInfo object from a prometheus_info_decoded dictionary.""" - prometheus_info_decoded["ip"] = int_to_ip(int(prometheus_info_decoded["ip"])) - - return cls(**prometheus_info_decoded) + def _fix_decoded(cls, decoded: Any) -> "StakeInfo": + hotkey = decode_account_id(decoded.get("hotkey")) + coldkey = decode_account_id(decoded.get("coldkey")) + netuid = int(decoded.get("netuid")) + stake = Balance.from_rao(decoded.get("stake")).set_unit(netuid) + locked = Balance.from_rao(decoded.get("locked")).set_unit(netuid) + emission = Balance.from_rao(decoded.get("emission")).set_unit(netuid) + drain = int(decoded.get("drain")) + is_registered = bool(decoded.get("is_registered")) + + return StakeInfo( + hotkey, coldkey, netuid, stake, locked, emission, drain, is_registered + ) @dataclass -class NeuronInfo: +class NeuronInfo(InfoBase): """Dataclass for neuron metadata.""" hotkey: str @@ -194,7 +241,6 @@ class NeuronInfo: weights: list[list[int]] bonds: list[list[int]] pruning_score: int - prometheus_info: Optional["PrometheusInfo"] = None axon_info: Optional[AxonInfo] = None is_null: bool = False @@ -231,7 +277,6 @@ def get_null_neuron() -> "NeuronInfo": validator_permit=False, weights=[], bonds=[], - prometheus_info=None, axon_info=None, is_null=True, coldkey="000000000000000000000000000000000000000000000000", @@ -241,49 +286,42 @@ def get_null_neuron() -> "NeuronInfo": return neuron @classmethod - def from_vec_u8(cls, vec_u8: bytes) -> "NeuronInfo": - n = bt_decode.NeuronInfo.decode(vec_u8) - stake_dict = process_stake_data(n.stake) + def _fix_decoded(cls, decoded: Any) -> "NeuronInfo": + netuid = decoded.get("netuid") + stake_dict = process_stake_data(decoded.get("stake"), netuid=netuid) total_stake = sum(stake_dict.values()) if stake_dict else Balance(0) - axon_info = n.axon_info - coldkey = decode_account_id(n.coldkey) - hotkey = decode_account_id(n.hotkey) + axon_info = decoded.get("axon_info", {}) + coldkey = decode_account_id(decoded.get("coldkey")) + hotkey = decode_account_id(decoded.get("hotkey")) return NeuronInfo( hotkey=hotkey, coldkey=coldkey, - uid=n.uid, - netuid=n.netuid, - active=n.active, + uid=decoded.get("uid"), + netuid=netuid, + active=decoded.get("active"), stake=total_stake, stake_dict=stake_dict, total_stake=total_stake, - rank=u16_normalized_float(n.rank), - emission=n.emission / 1e9, - incentive=u16_normalized_float(n.incentive), - consensus=u16_normalized_float(n.consensus), - trust=u16_normalized_float(n.trust), - validator_trust=u16_normalized_float(n.validator_trust), - dividends=u16_normalized_float(n.dividends), - last_update=n.last_update, - validator_permit=n.validator_permit, - weights=[[e[0], e[1]] for e in n.weights], - bonds=[[e[0], e[1]] for e in n.bonds], - pruning_score=n.pruning_score, - prometheus_info=PrometheusInfo( - block=n.prometheus_info.block, - version=n.prometheus_info.version, - ip=str(netaddr.IPAddress(n.prometheus_info.ip)), - port=n.prometheus_info.port, - ip_type=n.prometheus_info.ip_type, - ), + rank=u16_normalized_float(decoded.get("rank")), + emission=decoded.get("emission") / 1e9, + incentive=u16_normalized_float(decoded.get("incentive")), + consensus=u16_normalized_float(decoded.get("consensus")), + trust=u16_normalized_float(decoded.get("trust")), + validator_trust=u16_normalized_float(decoded.get("validator_trust")), + dividends=u16_normalized_float(decoded.get("dividends")), + last_update=decoded.get("last_update"), + validator_permit=decoded.get("validator_permit"), + weights=[[e[0], e[1]] for e in decoded.get("weights")], + bonds=[[e[0], e[1]] for e in decoded.get("bonds")], + pruning_score=decoded.get("pruning_score"), axon_info=AxonInfo( - version=axon_info.version, - ip=str(netaddr.IPAddress(axon_info.ip)), - port=axon_info.port, - ip_type=axon_info.ip_type, - placeholder1=axon_info.placeholder1, - placeholder2=axon_info.placeholder2, - protocol=axon_info.protocol, + version=axon_info.get("version"), + ip=str(netaddr.IPAddress(axon_info.get("ip"))), + port=axon_info.get("port"), + ip_type=axon_info.get("ip_type"), + placeholder1=axon_info.get("placeholder1"), + placeholder2=axon_info.get("placeholder2"), + protocol=axon_info.get("protocol"), hotkey=hotkey, coldkey=coldkey, ), @@ -292,7 +330,7 @@ def from_vec_u8(cls, vec_u8: bytes) -> "NeuronInfo": @dataclass -class NeuronInfoLite: +class NeuronInfoLite(InfoBase): """Dataclass for neuron metadata, but without the weights and bonds.""" hotkey: str @@ -313,7 +351,6 @@ class NeuronInfoLite: dividends: float last_update: int validator_permit: bool - prometheus_info: Optional["PrometheusInfo"] axon_info: AxonInfo pruning_score: int is_null: bool = False @@ -336,7 +373,6 @@ def get_null_neuron() -> "NeuronInfoLite": dividends=0, last_update=0, validator_permit=False, - prometheus_info=None, axon_info=None, is_null=True, coldkey="000000000000000000000000000000000000000000000000", @@ -346,74 +382,63 @@ def get_null_neuron() -> "NeuronInfoLite": return neuron @classmethod - def list_from_vec_u8(cls, vec_u8: bytes) -> list["NeuronInfoLite"]: - decoded = bt_decode.NeuronInfoLite.decode_vec(vec_u8) - results = [] - for item in decoded: - active = item.active - axon_info = item.axon_info - coldkey = decode_account_id(item.coldkey) - consensus = item.consensus - dividends = item.dividends - emission = item.emission - hotkey = decode_account_id(item.hotkey) - incentive = item.incentive - last_update = item.last_update - netuid = item.netuid - prometheus_info = item.prometheus_info - pruning_score = item.pruning_score - rank = item.rank - stake_dict = process_stake_data(item.stake) - stake = sum(stake_dict.values()) if stake_dict else Balance(0) - trust = item.trust - uid = item.uid - validator_permit = item.validator_permit - validator_trust = item.validator_trust - results.append( - NeuronInfoLite( - active=active, - axon_info=AxonInfo( - version=axon_info.version, - ip=str(netaddr.IPAddress(axon_info.ip)), - port=axon_info.port, - ip_type=axon_info.ip_type, - placeholder1=axon_info.placeholder1, - placeholder2=axon_info.placeholder2, - protocol=axon_info.protocol, - hotkey=hotkey, - coldkey=coldkey, - ), - coldkey=coldkey, - consensus=u16_normalized_float(consensus), - dividends=u16_normalized_float(dividends), - emission=emission / 1e9, - hotkey=hotkey, - incentive=u16_normalized_float(incentive), - last_update=last_update, - netuid=netuid, - prometheus_info=PrometheusInfo( - version=prometheus_info.version, - ip=str(netaddr.IPAddress(prometheus_info.ip)), - port=prometheus_info.port, - ip_type=prometheus_info.ip_type, - block=prometheus_info.block, - ), - pruning_score=pruning_score, - rank=u16_normalized_float(rank), - stake_dict=stake_dict, - stake=stake, - total_stake=stake, - trust=u16_normalized_float(trust), - uid=uid, - validator_permit=validator_permit, - validator_trust=u16_normalized_float(validator_trust), - ) - ) - return results + def _fix_decoded(cls, decoded: Union[dict, "NeuronInfoLite"]) -> "NeuronInfoLite": + active = decoded.get("active") + axon_info = decoded.get("axon_info", {}) + coldkey = decode_account_id(decoded.get("coldkey")) + consensus = decoded.get("consensus") + dividends = decoded.get("dividends") + emission = decoded.get("emission") + hotkey = decode_account_id(decoded.get("hotkey")) + incentive = decoded.get("incentive") + last_update = decoded.get("last_update") + netuid = decoded.get("netuid") + pruning_score = decoded.get("pruning_score") + rank = decoded.get("rank") + stake_dict = process_stake_data(decoded.get("stake"), netuid) + stake = sum(stake_dict.values()) if stake_dict else Balance(0) + trust = decoded.get("trust") + uid = decoded.get("uid") + validator_permit = decoded.get("validator_permit") + validator_trust = decoded.get("validator_trust") + + neuron = cls( + active=active, + axon_info=AxonInfo( + version=axon_info.get("version"), + ip=str(netaddr.IPAddress(axon_info.get("ip"))), + port=axon_info.get("port"), + ip_type=axon_info.get("ip_type"), + placeholder1=axon_info.get("placeholder1"), + placeholder2=axon_info.get("placeholder2"), + protocol=axon_info.get("protocol"), + hotkey=hotkey, + coldkey=coldkey, + ), + coldkey=coldkey, + consensus=u16_normalized_float(consensus), + dividends=u16_normalized_float(dividends), + emission=emission / 1e9, + hotkey=hotkey, + incentive=u16_normalized_float(incentive), + last_update=last_update, + netuid=netuid, + pruning_score=pruning_score, + rank=u16_normalized_float(rank), + stake_dict=stake_dict, + stake=stake, + total_stake=stake, + trust=u16_normalized_float(trust), + uid=uid, + validator_permit=validator_permit, + validator_trust=u16_normalized_float(validator_trust), + ) + + return neuron @dataclass -class DelegateInfo: +class DelegateInfo(InfoBase): """ Dataclass for delegate information. For a lighter version of this class, see :func:`DelegateInfoLite`. @@ -444,80 +469,69 @@ class DelegateInfo: total_daily_return: Balance # Total daily return of the delegate @classmethod - def from_vec_u8(cls, vec_u8: bytes) -> Optional["DelegateInfo"]: - decoded = bt_decode.DelegateInfo.decode(vec_u8) - hotkey = decode_account_id(decoded.delegate_ss58) - owner = decode_account_id(decoded.owner_ss58) + def _fix_decoded(cls, decoded: "DelegateInfo") -> "DelegateInfo": + hotkey = decode_account_id(decoded.get("hotkey_ss58")) + owner = decode_account_id(decoded.get("owner_ss58")) nominators = [ - (decode_account_id(x), Balance.from_rao(y)) for x, y in decoded.nominators + (decode_account_id(x), Balance.from_rao(y)) + for x, y in decoded.get("nominators") ] total_stake = sum((x[1] for x in nominators)) if nominators else Balance(0) - return DelegateInfo( + return cls( hotkey_ss58=hotkey, total_stake=total_stake, nominators=nominators, owner_ss58=owner, - take=u16_normalized_float(decoded.take), - validator_permits=decoded.validator_permits, - registrations=decoded.registrations, - return_per_1000=Balance.from_rao(decoded.return_per_1000), - total_daily_return=Balance.from_rao(decoded.total_daily_return), + take=u16_normalized_float(decoded.get("take")), + validator_permits=decoded.get("validator_permits"), + registrations=decoded.get("registrations"), + return_per_1000=Balance.from_rao(decoded.get("return_per_1000")), + total_daily_return=Balance.from_rao(decoded.get("total_daily_return")), ) - @classmethod - def list_from_vec_u8(cls, vec_u8: bytes) -> list["DelegateInfo"]: - decoded = bt_decode.DelegateInfo.decode_vec(vec_u8) - results = [] - for d in decoded: - hotkey = decode_account_id(d.delegate_ss58) - owner = decode_account_id(d.owner_ss58) - nominators = [ - (decode_account_id(x), Balance.from_rao(y)) for x, y in d.nominators - ] - total_stake = sum((x[1] for x in nominators)) if nominators else Balance(0) - results.append( - DelegateInfo( - hotkey_ss58=hotkey, - total_stake=total_stake, - nominators=nominators, - owner_ss58=owner, - take=u16_normalized_float(d.take), - validator_permits=d.validator_permits, - registrations=d.registrations, - return_per_1000=Balance.from_rao(d.return_per_1000), - total_daily_return=Balance.from_rao(d.total_daily_return), - ) - ) - return results + +@dataclass +class DelegateInfoLite(InfoBase): + """ + Dataclass for light delegate information. + + Args: + hotkey_ss58 (str): Hotkey of the delegate for which the information is being fetched. + owner_ss58 (str): Coldkey of the owner. + total_stake (int): Total stake of the delegate. + owner_stake (int): Own stake of the delegate. + take (float): Take of the delegate as a percentage. None if custom + """ + + hotkey_ss58: str # Hotkey of delegate + owner_ss58: str # Coldkey of owner + take: Optional[float] + total_stake: Balance # Total stake of the delegate + previous_total_stake: Optional[Balance] # Total stake of the delegate + owner_stake: Balance # Own stake of the delegate @classmethod - def delegated_list_from_vec_u8( - cls, vec_u8: bytes - ) -> list[tuple["DelegateInfo", Balance]]: - decoded = bt_decode.DelegateInfo.decode_delegated(vec_u8) - results = [] - for d, b in decoded: - nominators = [ - (decode_account_id(x), Balance.from_rao(y)) for x, y in d.nominators - ] - total_stake = sum((x[1] for x in nominators)) if nominators else Balance(0) - delegate = DelegateInfo( - hotkey_ss58=decode_account_id(d.delegate_ss58), - total_stake=total_stake, - nominators=nominators, - owner_ss58=decode_account_id(d.owner_ss58), - take=u16_normalized_float(d.take), - validator_permits=d.validator_permits, - registrations=d.registrations, - return_per_1000=Balance.from_rao(d.return_per_1000), - total_daily_return=Balance.from_rao(d.total_daily_return), - ) - results.append((delegate, Balance.from_rao(b))) - return results + def _fix_decoded(cls, decoded: Any) -> "DelegateInfoLite": + """Fixes the decoded values.""" + decoded_take = decoded.get("take") + + if decoded_take == 65535: + fixed_take = None + else: + fixed_take = u16_normalized_float(decoded_take) + + return cls( + hotkey_ss58=ss58_encode(decoded.get("delegate_ss58"), SS58_FORMAT), + owner_ss58=ss58_encode(decoded.get("owner_ss58"), SS58_FORMAT), + take=fixed_take, + total_stake=Balance.from_rao(decoded.get("total_stake")), + owner_stake=Balance.from_rao(decoded.get("owner_stake")), + previous_total_stake=None, + ) @dataclass -class SubnetInfo: +class SubnetInfo(InfoBase): """Dataclass for subnet info.""" netuid: int @@ -527,7 +541,7 @@ class SubnetInfo: immunity_period: int max_allowed_validators: int min_allowed_weights: int - max_weight_limit: float + max_weights_limit: float scaling_law_power: float subnetwork_n: int max_n: int @@ -540,193 +554,308 @@ class SubnetInfo: owner_ss58: str @classmethod - def list_from_vec_u8(cls, vec_u8: bytes) -> list["SubnetInfo"]: - decoded = bt_decode.SubnetInfo.decode_vec_option(vec_u8) - result = [] - for d in decoded: - result.append( - SubnetInfo( - netuid=d.netuid, - rho=d.rho, - kappa=d.kappa, - difficulty=d.difficulty, - immunity_period=d.immunity_period, - max_allowed_validators=d.max_allowed_validators, - min_allowed_weights=d.min_allowed_weights, - max_weight_limit=d.max_weights_limit, - scaling_law_power=d.scaling_law_power, - subnetwork_n=d.subnetwork_n, - max_n=d.max_allowed_uids, - blocks_since_epoch=d.blocks_since_last_step, - tempo=d.tempo, - modality=d.network_modality, - connection_requirements={ - str(int(netuid)): u16_normalized_float(int(req)) - for (netuid, req) in d.network_connect - }, - emission_value=d.emission_values, - burn=Balance.from_rao(d.burn), - owner_ss58=decode_account_id(d.owner), - ) - ) - return result - - -custom_rpc_type_registry = { - "types": { - "SubnetInfo": { - "type": "struct", - "type_mapping": [ - ["netuid", "Compact"], - ["rho", "Compact"], - ["kappa", "Compact"], - ["difficulty", "Compact"], - ["immunity_period", "Compact"], - ["max_allowed_validators", "Compact"], - ["min_allowed_weights", "Compact"], - ["max_weights_limit", "Compact"], - ["scaling_law_power", "Compact"], - ["subnetwork_n", "Compact"], - ["max_allowed_uids", "Compact"], - ["blocks_since_last_step", "Compact"], - ["tempo", "Compact"], - ["network_modality", "Compact"], - ["network_connect", "Vec<[u16; 2]>"], - ["emission_values", "Compact"], - ["burn", "Compact"], - ["owner", "AccountId"], - ], - }, - "DelegateInfo": { - "type": "struct", - "type_mapping": [ - ["delegate_ss58", "AccountId"], - ["take", "Compact"], - ["nominators", "Vec<(AccountId, Compact)>"], - ["owner_ss58", "AccountId"], - ["registrations", "Vec>"], - ["validator_permits", "Vec>"], - ["return_per_1000", "Compact"], - ["total_daily_return", "Compact"], - ], - }, - "NeuronInfo": { - "type": "struct", - "type_mapping": [ - ["hotkey", "AccountId"], - ["coldkey", "AccountId"], - ["uid", "Compact"], - ["netuid", "Compact"], - ["active", "bool"], - ["axon_info", "axon_info"], - ["prometheus_info", "PrometheusInfo"], - ["stake", "Vec<(AccountId, Compact)>"], - ["rank", "Compact"], - ["emission", "Compact"], - ["incentive", "Compact"], - ["consensus", "Compact"], - ["trust", "Compact"], - ["validator_trust", "Compact"], - ["dividends", "Compact"], - ["last_update", "Compact"], - ["validator_permit", "bool"], - ["weights", "Vec<(Compact, Compact)>"], - ["bonds", "Vec<(Compact, Compact)>"], - ["pruning_score", "Compact"], - ], - }, - "NeuronInfoLite": { - "type": "struct", - "type_mapping": [ - ["hotkey", "AccountId"], - ["coldkey", "AccountId"], - ["uid", "Compact"], - ["netuid", "Compact"], - ["active", "bool"], - ["axon_info", "axon_info"], - ["prometheus_info", "PrometheusInfo"], - ["stake", "Vec<(AccountId, Compact)>"], - ["rank", "Compact"], - ["emission", "Compact"], - ["incentive", "Compact"], - ["consensus", "Compact"], - ["trust", "Compact"], - ["validator_trust", "Compact"], - ["dividends", "Compact"], - ["last_update", "Compact"], - ["validator_permit", "bool"], - ["pruning_score", "Compact"], - ], - }, - "axon_info": { - "type": "struct", - "type_mapping": [ - ["block", "u64"], - ["version", "u32"], - ["ip", "u128"], - ["port", "u16"], - ["ip_type", "u8"], - ["protocol", "u8"], - ["placeholder1", "u8"], - ["placeholder2", "u8"], + def _fix_decoded(cls, decoded: "SubnetInfo") -> "SubnetInfo": + return SubnetInfo( + netuid=decoded.get("netuid"), + rho=decoded.get("rho"), + kappa=decoded.get("kappa"), + difficulty=decoded.get("difficulty"), + immunity_period=decoded.get("immunity_period"), + max_allowed_validators=decoded.get("max_allowed_validators"), + min_allowed_weights=decoded.get("min_allowed_weights"), + max_weights_limit=decoded.get("max_weights_limit"), + scaling_law_power=decoded.get("scaling_law_power"), + subnetwork_n=decoded.get("subnetwork_n"), + max_n=decoded.get("max_allowed_uids"), + blocks_since_epoch=decoded.get("blocks_since_last_step"), + tempo=decoded.get("tempo"), + modality=decoded.get("network_modality"), + connection_requirements={ + str(int(netuid)): u16_normalized_float(int(req)) + for (netuid, req) in decoded.get("network_connect") + }, + emission_value=decoded.get("emission_value"), + burn=Balance.from_rao(decoded.get("burn")), + owner_ss58=decode_account_id(decoded.get("owner")), + ) + + +@dataclass +class SubnetIdentity(InfoBase): + """Dataclass for subnet identity information.""" + + subnet_name: str + github_repo: str + subnet_contact: str + subnet_url: str + discord: str + description: str + additional: str + + @classmethod + def _fix_decoded(cls, decoded: dict) -> "SubnetIdentity": + return SubnetIdentity( + subnet_name=bytes(decoded["subnet_name"]).decode(), + github_repo=bytes(decoded["github_repo"]).decode(), + subnet_contact=bytes(decoded["subnet_contact"]).decode(), + subnet_url=bytes(decoded["subnet_url"]).decode(), + discord=bytes(decoded["discord"]).decode(), + description=bytes(decoded["description"]).decode(), + additional=bytes(decoded["additional"]).decode(), + ) + + +@dataclass +class DynamicInfo(InfoBase): + netuid: int + owner_hotkey: str + owner_coldkey: str + subnet_name: str + symbol: str + tempo: int + last_step: int + blocks_since_last_step: int + emission: Balance + alpha_in: Balance + alpha_out: Balance + tao_in: Balance + price: Balance + k: float + is_dynamic: bool + alpha_out_emission: Balance + alpha_in_emission: Balance + tao_in_emission: Balance + pending_alpha_emission: Balance + pending_root_emission: Balance + network_registered_at: int + subnet_identity: Optional[SubnetIdentity] + subnet_volume: Balance + + @classmethod + def _fix_decoded(cls, decoded: Any) -> "DynamicInfo": + """Returns a DynamicInfo object from a decoded DynamicInfo dictionary.""" + + netuid = int(decoded.get("netuid")) + symbol = bytes([int(b) for b in decoded.get("token_symbol")]).decode() + subnet_name = bytes([int(b) for b in decoded.get("subnet_name")]).decode() + is_dynamic = True if netuid > 0 else False # Patching for netuid 0 + + owner_hotkey = decode_account_id(decoded.get("owner_hotkey")) + owner_coldkey = decode_account_id(decoded.get("owner_coldkey")) + + emission = Balance.from_rao(decoded.get("emission")).set_unit(0) + alpha_in = Balance.from_rao(decoded.get("alpha_in")).set_unit(netuid) + alpha_out = Balance.from_rao(decoded.get("alpha_out")).set_unit(netuid) + tao_in = Balance.from_rao(decoded.get("tao_in")).set_unit(0) + alpha_out_emission = Balance.from_rao( + decoded.get("alpha_out_emission") + ).set_unit(netuid) + alpha_in_emission = Balance.from_rao(decoded.get("alpha_in_emission")).set_unit( + netuid + ) + subnet_volume = Balance.from_rao(decoded.get("subnet_volume")).set_unit(netuid) + tao_in_emission = Balance.from_rao(decoded.get("tao_in_emission")).set_unit(0) + pending_alpha_emission = Balance.from_rao( + decoded.get("pending_alpha_emission") + ).set_unit(netuid) + pending_root_emission = Balance.from_rao( + decoded.get("pending_root_emission") + ).set_unit(0) + price = ( + Balance.from_tao(1.0) + if netuid == 0 + else Balance.from_tao(tao_in.tao / alpha_in.tao) + if alpha_in.tao > 0 + else Balance.from_tao(1) + ) # TODO: Patching this temporarily for netuid 0 + + if decoded.get("subnet_identity"): + subnet_identity = SubnetIdentity.from_any(decoded.get("subnet_identity")) + else: + subnet_identity = None + + return cls( + netuid=netuid, + owner_hotkey=owner_hotkey, + owner_coldkey=owner_coldkey, + subnet_name=subnet_name, + symbol=symbol, + tempo=int(decoded.get("tempo")), + last_step=int(decoded.get("last_step")), + blocks_since_last_step=int(decoded.get("blocks_since_last_step")), + emission=emission, + alpha_in=alpha_in, + alpha_out=alpha_out, + tao_in=tao_in, + k=tao_in.rao * alpha_in.rao, + is_dynamic=is_dynamic, + price=price, + alpha_out_emission=alpha_out_emission, + alpha_in_emission=alpha_in_emission, + tao_in_emission=tao_in_emission, + pending_alpha_emission=pending_alpha_emission, + pending_root_emission=pending_root_emission, + network_registered_at=int(decoded.get("network_registered_at")), + subnet_identity=subnet_identity, + subnet_volume=subnet_volume, + ) + + def tao_to_alpha(self, tao: Balance) -> Balance: + if self.price.tao != 0: + return Balance.from_tao(tao.tao / self.price.tao).set_unit(self.netuid) + else: + return Balance.from_tao(0) + + def alpha_to_tao(self, alpha: Balance) -> Balance: + return Balance.from_tao(alpha.tao * self.price.tao) + + def tao_to_alpha_with_slippage(self, tao: Balance) -> tuple[Balance, Balance]: + """ + Returns an estimate of how much Alpha would a staker receive if they stake their tao using the current pool state. + Args: + tao: Amount of TAO to stake. + Returns: + Tuple of balances where the first part is the amount of Alpha received, and the + second part (slippage) is the difference between the estimated amount and ideal + amount as if there was no slippage + """ + if self.is_dynamic: + new_tao_in = self.tao_in + tao + if new_tao_in == 0: + return tao, Balance.from_rao(0) + new_alpha_in = self.k / new_tao_in + + # Amount of alpha given to the staker + alpha_returned = Balance.from_rao( + self.alpha_in.rao - new_alpha_in.rao + ).set_unit(self.netuid) + + # Ideal conversion as if there is no slippage, just price + alpha_ideal = self.tao_to_alpha(tao) + + if alpha_ideal.tao > alpha_returned.tao: + slippage = Balance.from_tao( + alpha_ideal.tao - alpha_returned.tao + ).set_unit(self.netuid) + else: + slippage = Balance.from_tao(0) + else: + alpha_returned = tao.set_unit(self.netuid) + slippage = Balance.from_tao(0) + + slippage_pct_float = ( + 100 * float(slippage) / float(slippage + alpha_returned) + if slippage + alpha_returned != 0 + else 0 + ) + return alpha_returned, slippage, slippage_pct_float + + def alpha_to_tao_with_slippage(self, alpha: Balance) -> tuple[Balance, Balance]: + """ + Returns an estimate of how much TAO would a staker receive if they unstake their alpha using the current pool state. + Args: + alpha: Amount of Alpha to stake. + Returns: + Tuple of balances where the first part is the amount of TAO received, and the + second part (slippage) is the difference between the estimated amount and ideal + amount as if there was no slippage + """ + if self.is_dynamic: + new_alpha_in = self.alpha_in + alpha + new_tao_reserve = self.k / new_alpha_in + # Amount of TAO given to the unstaker + tao_returned = Balance.from_rao(self.tao_in - new_tao_reserve) + + # Ideal conversion as if there is no slippage, just price + tao_ideal = self.alpha_to_tao(alpha) + + if tao_ideal > tao_returned: + slippage = Balance.from_tao(tao_ideal.tao - tao_returned.tao) + else: + slippage = Balance.from_tao(0) + else: + tao_returned = alpha.set_unit(0) + slippage = Balance.from_tao(0) + slippage_pct_float = ( + 100 * float(slippage) / float(slippage + tao_returned) + if slippage + tao_returned != 0 + else 0 + ) + return tao_returned, slippage, slippage_pct_float + + +@dataclass +class ScheduledColdkeySwapInfo(InfoBase): + """Dataclass for scheduled coldkey swap information.""" + + old_coldkey: str + new_coldkey: str + arbitration_block: int + + @classmethod + def _fix_decoded(cls, decoded: Any) -> "ScheduledColdkeySwapInfo": + """Fixes the decoded values.""" + return cls( + old_coldkey=decode_account_id(decoded.get("old_coldkey")), + new_coldkey=decode_account_id(decoded.get("new_coldkey")), + arbitration_block=decoded.get("arbitration_block"), + ) + + +@dataclass +class SubnetState(InfoBase): + netuid: int + hotkeys: list[str] + coldkeys: list[str] + active: list[bool] + validator_permit: list[bool] + pruning_score: list[float] + last_update: list[int] + emission: list[Balance] + dividends: list[float] + incentives: list[float] + consensus: list[float] + trust: list[float] + rank: list[float] + block_at_registration: list[int] + alpha_stake: list[Balance] + tao_stake: list[Balance] + total_stake: list[Balance] + emission_history: list[list[int]] + + @classmethod + def _fix_decoded(cls, decoded: Any) -> "SubnetState": + netuid = decoded.get("netuid") + return SubnetState( + netuid=netuid, + hotkeys=[decode_account_id(val) for val in decoded.get("hotkeys")], + coldkeys=[decode_account_id(val) for val in decoded.get("coldkeys")], + active=decoded.get("active"), + validator_permit=decoded.get("validator_permit"), + pruning_score=[ + u16_normalized_float(val) for val in decoded.get("pruning_score") ], - }, - "PrometheusInfo": { - "type": "struct", - "type_mapping": [ - ["block", "u64"], - ["version", "u32"], - ["ip", "u128"], - ["port", "u16"], - ["ip_type", "u8"], + last_update=decoded.get("last_update"), + emission=[ + Balance.from_rao(val).set_unit(netuid) + for val in decoded.get("emission") ], - }, - "IPInfo": { - "type": "struct", - "type_mapping": [ - ["ip", "Compact"], - ["ip_type_and_protocol", "Compact"], + dividends=[u16_normalized_float(val) for val in decoded.get("dividends")], + incentives=[u16_normalized_float(val) for val in decoded.get("incentives")], + consensus=[u16_normalized_float(val) for val in decoded.get("consensus")], + trust=[u16_normalized_float(val) for val in decoded.get("trust")], + rank=[u16_normalized_float(val) for val in decoded.get("rank")], + block_at_registration=decoded.get("block_at_registration"), + alpha_stake=[ + Balance.from_rao(val).set_unit(netuid) + for val in decoded.get("alpha_stake") ], - }, - "StakeInfo": { - "type": "struct", - "type_mapping": [ - ["hotkey", "AccountId"], - ["coldkey", "AccountId"], - ["stake", "Compact"], + tao_stake=[ + Balance.from_rao(val).set_unit(0) for val in decoded.get("tao_stake") ], - }, - "SubnetHyperparameters": { - "type": "struct", - "type_mapping": [ - ["rho", "Compact"], - ["kappa", "Compact"], - ["immunity_period", "Compact"], - ["min_allowed_weights", "Compact"], - ["max_weights_limit", "Compact"], - ["tempo", "Compact"], - ["min_difficulty", "Compact"], - ["max_difficulty", "Compact"], - ["weights_version", "Compact"], - ["weights_rate_limit", "Compact"], - ["adjustment_interval", "Compact"], - ["activity_cutoff", "Compact"], - ["registration_allowed", "bool"], - ["target_regs_per_interval", "Compact"], - ["min_burn", "Compact"], - ["max_burn", "Compact"], - ["bonds_moving_avg", "Compact"], - ["max_regs_per_block", "Compact"], - ["serving_rate_limit", "Compact"], - ["max_validators", "Compact"], - ["adjustment_alpha", "Compact"], - ["difficulty", "Compact"], - ["commit_reveal_weights_interval", "Compact"], - ["commit_reveal_weights_enabled", "bool"], - ["alpha_high", "Compact"], - ["alpha_low", "Compact"], - ["liquid_alpha_enabled", "bool"], + total_stake=[ + Balance.from_rao(val).set_unit(netuid) + for val in decoded.get("total_stake") ], - }, - } -} + emission_history=decoded.get("emission_history"), + ) diff --git a/bittensor_cli/src/bittensor/extrinsics/registration.py b/bittensor_cli/src/bittensor/extrinsics/registration.py index fcd52cb8..65d8f348 100644 --- a/bittensor_cli/src/bittensor/extrinsics/registration.py +++ b/bittensor_cli/src/bittensor/extrinsics/registration.py @@ -24,9 +24,11 @@ from rich.prompt import Confirm from rich.console import Console from rich.status import Status -from substrateinterface.exceptions import SubstrateRequestException +from async_substrate_interface.errors import SubstrateRequestException +from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.chain_data import NeuronInfo +from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.utils import ( console, err_console, @@ -436,7 +438,7 @@ async def is_hotkey_registered( subtensor: "SubtensorInterface", netuid: int, hotkey_ss58: str ) -> bool: """Checks to see if the hotkey is registered on a given netuid""" - _result = await subtensor.substrate.query( + _result = await subtensor.query( module="SubtensorModule", storage_function="Uids", params=[netuid, hotkey_ss58], @@ -487,22 +489,18 @@ async def register_extrinsic( """ async def get_neuron_for_pubkey_and_subnet(): - uid = await subtensor.substrate.query( + uid = await subtensor.query( "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] ) if uid is None: return NeuronInfo.get_null_neuron() - params = [netuid, uid] - json_body = await subtensor.substrate.rpc_request( - method="neuronInfo_getNeuron", - params=params, + result = await subtensor.neuron_for_uid( + uid=uid, + netuid=netuid, + block_hash=subtensor.substrate.last_block_hash, ) - - if not (result := json_body.get("result", None)): - return NeuronInfo.get_null_neuron() - - return NeuronInfo.from_vec_u8(bytes(result)) + return result print_verbose("Checking subnet status") if not await subtensor.subnet_exists(netuid): @@ -526,9 +524,9 @@ async def get_neuron_for_pubkey_and_subnet(): if prompt: if not Confirm.ask( f"Continue Registration?\n" - f" hotkey ({wallet.hotkey_str}):\t[bold white]{wallet.hotkey.ss58_address}[/bold white]\n" - f" coldkey ({wallet.name}):\t[bold white]{wallet.coldkeypub.ss58_address}[/bold white]\n" - f" network:\t\t[bold white]{subtensor.network}[/bold white]" + f" hotkey [{COLOR_PALETTE['GENERAL']['HOTKEY']}]({wallet.hotkey_str})[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]:\t[{COLOR_PALETTE['GENERAL']['HOTKEY']}]{wallet.hotkey.ss58_address}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]\n" + f" coldkey [{COLOR_PALETTE['GENERAL']['COLDKEY']}]({wallet.name})[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]:\t[{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n" + f" network:\t\t[{COLOR_PALETTE['GENERAL']['LINKS']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['LINKS']}]\n" ): return False @@ -581,7 +579,7 @@ async def get_neuron_for_pubkey_and_subnet(): ) if is_registered: err_console.print( - f":white_heavy_check_mark: [green]Already registered on netuid:{netuid}[/green]" + f":white_heavy_check_mark: [dark_sea_green3]Already registered on netuid:{netuid}[/dark_sea_green3]" ) return True @@ -625,8 +623,8 @@ async def get_neuron_for_pubkey_and_subnet(): if "HotKeyAlreadyRegisteredInSubNet" in err_msg: console.print( - f":white_heavy_check_mark: [green]Already Registered on " - f"[bold]subnet:{netuid}[/bold][/green]" + f":white_heavy_check_mark: [dark_sea_green3]Already Registered on " + f"[bold]subnet:{netuid}[/bold][/dark_sea_green3]" ) return True err_console.print( @@ -644,7 +642,7 @@ async def get_neuron_for_pubkey_and_subnet(): ) if is_registered: console.print( - ":white_heavy_check_mark: [green]Registered[/green]" + ":white_heavy_check_mark: [dark_sea_green3]Registered[/dark_sea_green3]" ) return True else: @@ -671,6 +669,114 @@ async def get_neuron_for_pubkey_and_subnet(): return False +async def burned_register_extrinsic( + subtensor: "SubtensorInterface", + wallet: Wallet, + netuid: int, + old_balance: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + prompt: bool = False, +) -> bool: + """Registers the wallet to chain by recycling TAO. + + :param subtensor: The SubtensorInterface object to use for the call, initialized + :param wallet: Bittensor wallet object. + :param netuid: The `netuid` of the subnet to register on. + :param old_balance: The wallet balance prior to the registration burn. + :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns + `False` if the extrinsic fails to enter the block within the timeout. + :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, + or returns `False` if the extrinsic fails to be finalized within the timeout. + :param prompt: If `True`, the call waits for confirmation from the user before proceeding. + + :return: Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for + finalization/inclusion, the response is `True`. + """ + + if not (unlock_status := unlock_key(wallet, print_out=False)).success: + return False, unlock_status.message + + with console.status( + f":satellite: Checking Account on [bold]subnet:{netuid}[/bold]...", + spinner="aesthetic", + ) as status: + my_uid = await subtensor.query( + "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] + ) + + print_verbose("Checking if already registered", status) + neuron = await subtensor.neuron_for_uid( + uid=my_uid, + netuid=netuid, + block_hash=subtensor.substrate.last_block_hash, + ) + + if not neuron.is_null: + console.print( + ":white_heavy_check_mark: [dark_sea_green3]Already Registered[/dark_sea_green3]:\n" + f"uid: [{COLOR_PALETTE['GENERAL']['NETUID_EXTRA']}]{neuron.uid}[/{COLOR_PALETTE['GENERAL']['NETUID_EXTRA']}]\n" + f"netuid: [{COLOR_PALETTE['GENERAL']['NETUID']}]{neuron.netuid}[/{COLOR_PALETTE['GENERAL']['NETUID']}]\n" + f"hotkey: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{neuron.hotkey}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]\n" + f"coldkey: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{neuron.coldkey}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]" + ) + return True + + with console.status( + ":satellite: Recycling TAO for Registration...", spinner="aesthetic" + ): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="burned_register", + call_params={ + "netuid": netuid, + "hotkey": wallet.hotkey.ss58_address, + }, + ) + success, err_msg = await subtensor.sign_and_send_extrinsic( + call, wallet, wait_for_inclusion, wait_for_finalization + ) + + if not success: + err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") + await asyncio.sleep(0.5) + return False + # Successful registration, final check for neuron and pubkey + else: + with console.status(":satellite: Checking Balance...", spinner="aesthetic"): + block_hash = await subtensor.substrate.get_chain_head() + new_balance, netuids_for_hotkey, my_uid = await asyncio.gather( + subtensor.get_balance( + wallet.coldkeypub.ss58_address, + block_hash=block_hash, + reuse_block=False, + ), + subtensor.get_netuids_for_hotkey( + wallet.hotkey.ss58_address, block_hash=block_hash + ), + subtensor.query( + "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] + ), + ) + + console.print( + "Balance:\n" + f" [blue]{old_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + ) + + if len(netuids_for_hotkey) > 0: + console.print( + f":white_heavy_check_mark: [green]Registered on netuid {netuid} with UID {my_uid}[/green]" + ) + return True + else: + # neuron not found, try again + err_console.print( + ":cross_mark: [red]Unknown error. Neuron not found.[/red]" + ) + return False + + async def run_faucet_extrinsic( subtensor: "SubtensorInterface", wallet: Wallet, @@ -806,8 +912,8 @@ async def run_faucet_extrinsic( wallet.coldkeypub.ss58_address ) console.print( - f"Balance: [blue]{old_balance[wallet.coldkeypub.ss58_address]}[/blue] :arrow_right:" - f" [green]{new_balance[wallet.coldkeypub.ss58_address]}[/green]" + f"Balance: [blue]{old_balance}[/blue] :arrow_right:" + f" [green]{new_balance}[/green]" ) old_balance = new_balance @@ -912,7 +1018,7 @@ async def _block_solver( limit = int(math.pow(2, 256)) - 1 # Establish communication queues - ## See the _Solver class for more information on the queues. + # See the _Solver class for more information on the queues. stop_event = Event() stop_event.clear() @@ -928,7 +1034,7 @@ async def _block_solver( ) if cuda: - ## Create a worker per CUDA device + # Create a worker per CUDA device solvers = [ _CUDASolver( i, diff --git a/bittensor_cli/src/bittensor/extrinsics/root.py b/bittensor_cli/src/bittensor/extrinsics/root.py index cdb28fd6..29b9e510 100644 --- a/bittensor_cli/src/bittensor/extrinsics/root.py +++ b/bittensor_cli/src/bittensor/extrinsics/root.py @@ -26,7 +26,7 @@ from rich.prompt import Confirm from rich.table import Table, Column from scalecodec import ScaleBytes, U16, Vec -from substrateinterface.exceptions import SubstrateRequestException +from async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.bittensor.extrinsics.registration import is_hotkey_registered @@ -339,7 +339,7 @@ async def root_register_extrinsic( # Successful registration, final check for neuron and pubkey else: - uid = await subtensor.substrate.query( + uid = await subtensor.query( module="SubtensorModule", storage_function="Uids", params=[0, wallet.hotkey.ss58_address], @@ -416,7 +416,7 @@ async def _do_set_weights(): else: return False, await response.error_message - my_uid = await subtensor.substrate.query( + my_uid = await subtensor.query( "SubtensorModule", "Uids", [0, wallet.hotkey.ss58_address] ) diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index 127fb488..5a167f0b 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -2,7 +2,7 @@ from bittensor_wallet import Wallet from rich.prompt import Confirm -from substrateinterface.exceptions import SubstrateRequestException +from async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src import NETWORK_EXPLORER_MAP from bittensor_cli.src.bittensor.balances import Balance @@ -64,14 +64,14 @@ async def get_transfer_fee() -> Balance: call=call, keypair=wallet.coldkeypub ) except SubstrateRequestException as e: - payment_info = {"partialFee": int(2e7)} # assume 0.02 Tao + payment_info = {"partial_fee": int(2e7)} # assume 0.02 Tao err_console.print( f":cross_mark: [red]Failed to get payment info[/red]:[bold white]\n" f" {format_error_message(e)}[/bold white]\n" f" Defaulting to default transfer fee: {payment_info['partialFee']}" ) - return Balance.from_rao(payment_info["partialFee"]) + return Balance.from_rao(payment_info["partial_fee"]) async def do_transfer() -> tuple[bool, str, str]: """ @@ -101,11 +101,7 @@ async def do_transfer() -> tuple[bool, str, str]: block_hash_ = response.block_hash return True, block_hash_, "" else: - return ( - False, - "", - format_error_message(await response.error_message), - ) + return False, "", format_error_message(await response.error_message) # Validate destination address. if not is_valid_bittensor_address_or_public_key(destination): @@ -126,13 +122,12 @@ async def do_transfer() -> tuple[bool, str, str]: # check existential deposit and fee print_verbose("Fetching existential and fee", status) block_hash = await subtensor.substrate.get_chain_head() - account_balance_, existential_deposit = await asyncio.gather( + account_balance, existential_deposit = await asyncio.gather( subtensor.get_balance( wallet.coldkeypub.ss58_address, block_hash=block_hash ), subtensor.get_existential_deposit(block_hash=block_hash), ) - account_balance = account_balance_[wallet.coldkeypub.ss58_address] fee = await get_transfer_fee() if not keep_alive: @@ -194,7 +189,7 @@ async def do_transfer() -> tuple[bool, str, str]: ) console.print( f"Balance:\n" - f" [blue]{account_balance}[/blue] :arrow_right: [green]{new_balance[wallet.coldkeypub.ss58_address]}[/green]" + f" [blue]{account_balance}[/blue] :arrow_right: [green]{new_balance}[/green]" ) return True diff --git a/bittensor_cli/src/bittensor/minigraph.py b/bittensor_cli/src/bittensor/minigraph.py index 3f2aac0f..9e149c1b 100644 --- a/bittensor_cli/src/bittensor/minigraph.py +++ b/bittensor_cli/src/bittensor/minigraph.py @@ -3,7 +3,7 @@ import numpy as np from numpy.typing import NDArray -from bittensor_cli.src.bittensor.chain_data import NeuronInfo +from bittensor_cli.src.bittensor.chain_data import NeuronInfo, SubnetState from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.bittensor.utils import ( convert_root_weight_uids_and_vals_to_tensor, @@ -18,6 +18,7 @@ def __init__( netuid: int, neurons: list[NeuronInfo], subtensor: "SubtensorInterface", + subnet_state: "SubnetState", block: int, ): self.neurons = neurons @@ -62,12 +63,14 @@ def __init__( self.validator_trust = self._create_tensor( [neuron.validator_trust for neuron in self.neurons], dtype=np.float32 ) - self.total_stake = self._create_tensor( - [neuron.total_stake.tao for neuron in self.neurons], dtype=np.float32 - ) - self.stake = self._create_tensor( - [neuron.stake for neuron in self.neurons], dtype=np.float32 + + # Fetch stakes from subnet_state until we get updated data in NeuronInfo + global_stake_list, local_stake_list, stake_weights_list = self._process_stakes( + neurons, subnet_state ) + self.global_stake = self._create_tensor(global_stake_list, dtype=np.float32) + self.local_stake = self._create_tensor(local_stake_list, dtype=np.float32) + self.stake_weights = self._create_tensor(stake_weights_list, dtype=np.float32) async def __aenter__(self): if not self.weights: @@ -120,6 +123,41 @@ async def _set_weights_and_bonds(self): [neuron.bonds for neuron in self.neurons], "bonds" ) + def _process_stakes( + self, + neurons: list[NeuronInfo], + subnet_state: SubnetState, + ) -> tuple[list[float], list[float], list[float]]: + """ + Processes the global_stake, local_stake, and stake_weights based on the neuron's hotkey. + + Args: + neurons (List[NeuronInfo]): List of neurons. + subnet_state (SubnetState): The subnet state containing stake information. + + Returns: + tuple[list[float], list[float], list[float]]: Lists of global_stake, local_stake, and stake_weights. + """ + global_stake_list = [] + local_stake_list = [] + stake_weights_list = [] + hotkey_to_index = { + hotkey: idx for idx, hotkey in enumerate(subnet_state.hotkeys) + } + + for neuron in neurons: + idx = hotkey_to_index.get(neuron.hotkey) + if idx is not None: + global_stake_list.append(subnet_state.global_stake[idx].tao) + local_stake_list.append(subnet_state.local_stake[idx].tao) + stake_weights_list.append(subnet_state.stake_weight[idx]) + else: + global_stake_list.append(0.0) + local_stake_list.append(0.0) + stake_weights_list.append(0.0) + + return global_stake_list, local_stake_list, stake_weights_list + def _process_weights_or_bonds(self, data, attribute: str) -> NDArray: """ Processes the raw weights or bonds data and converts it into a structured tensor format. This method handles @@ -177,7 +215,7 @@ async def _process_root_weights(self, data, attribute: str) -> NDArray: """ async def get_total_subnets(): - _result = await self.subtensor.substrate.query( + _result = await self.subtensor.query( module="SubtensorModule", storage_function="TotalNetworks", params=[], @@ -186,7 +224,7 @@ async def get_total_subnets(): return _result async def get_subnets(): - _result = await self.subtensor.substrate.query( + _result = await self.subtensor.query( module="SubtensorModule", storage_function="TotalNetworks", ) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 8f97e58d..cf5ac9e9 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1,41 +1,36 @@ import asyncio from typing import Optional, Any, Union, TypedDict, Iterable - import aiohttp from bittensor_wallet import Wallet from bittensor_wallet.utils import SS58_FORMAT -import scalecodec from scalecodec import GenericCall -from scalecodec.base import RuntimeConfiguration -from scalecodec.type_registry import load_type_registry_preset -from substrateinterface.exceptions import SubstrateRequestException +from async_substrate_interface.errors import SubstrateRequestException import typer -from bittensor_cli.src.bittensor.async_substrate_interface import ( - AsyncSubstrateInterface, - TimeoutException, -) + +from async_substrate_interface.async_substrate import AsyncSubstrateInterface from bittensor_cli.src.bittensor.chain_data import ( DelegateInfo, - custom_rpc_type_registry, StakeInfo, NeuronInfoLite, NeuronInfo, SubnetHyperparameters, decode_account_id, + decode_hex_identity, + DynamicInfo, + SubnetState, ) from bittensor_cli.src import DelegatesDetails -from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float from bittensor_cli.src import Constants, defaults, TYPE_REGISTRY from bittensor_cli.src.bittensor.utils import ( - ss58_to_vec_u8, format_error_message, console, err_console, decode_hex_identity_dict, validate_chain_endpoint, - hex_to_bytes, + u16_normalized_float, ) @@ -59,11 +54,11 @@ def __init__(self, proposal_dict: dict) -> None: self.end = proposal_dict["end"] @staticmethod - def decode_ss58_tuples(l: tuple): + def decode_ss58_tuples(data: tuple): """ Decodes a tuple of ss58 addresses formatted as bytes tuples """ - return [decode_account_id(l[x][0]) for x in range(len(l))] + return [decode_account_id(data[x][0]) for x in range(len(data))] class SubtensorInterface: @@ -96,13 +91,14 @@ def __init__(self, network): f"Network not specified or not valid. Using default chain endpoint: " f"{Constants.network_map[defaults.subtensor.network]}.\n" f"You can set this for commands with the `--network` flag, or by setting this" - f" in the config." + f" in the config. If you're sure you're using the correct URL, ensure it begins" + f" with 'ws://' or 'wss://'" ) self.chain_endpoint = Constants.network_map[defaults.subtensor.network] self.network = defaults.subtensor.network self.substrate = AsyncSubstrateInterface( - chain_endpoint=self.chain_endpoint, + url=self.chain_endpoint, ss58_format=SS58_FORMAT, type_registry=TYPE_REGISTRY, chain_name="Bittensor", @@ -112,41 +108,48 @@ def __str__(self): return f"Network: {self.network}, Chain: {self.chain_endpoint}" async def __aenter__(self): - try: - with console.status( - f"[yellow]Connecting to Substrate:[/yellow][bold white] {self}..." - ): + with console.status( + f"[yellow]Connecting to Substrate:[/yellow][bold white] {self}..." + ): + try: async with self.substrate: return self - except TimeoutException: - err_console.print( - "\n[red]Error[/red]: Timeout occurred connecting to substrate. " - f"Verify your chain and network settings: {self}" - ) - raise typer.Exit(code=1) + except TimeoutError: # TODO verify + err_console.print( + "\n[red]Error[/red]: Timeout occurred connecting to substrate. " + f"Verify your chain and network settings: {self}" + ) + raise typer.Exit(code=1) async def __aexit__(self, exc_type, exc_val, exc_tb): await self.substrate.close() - async def encode_params( + async def query( self, - call_definition: list["ParamWithTypes"], - params: Union[list[Any], dict[str, Any]], - ) -> str: - """Returns a hex encoded string of the params using their types.""" - param_data = scalecodec.ScaleBytes(b"") - - for i, param in enumerate(call_definition["params"]): # type: ignore - scale_obj = await self.substrate.create_scale_object(param["type"]) - if isinstance(params, list): - param_data += scale_obj.encode(params[i]) - else: - if param["name"] not in params: - raise ValueError(f"Missing param {param['name']} in params dict.") - - param_data += scale_obj.encode(params[param["name"]]) - - return param_data.to_hex() + module: str, + storage_function: str, + params: Optional[list] = None, + block_hash: Optional[str] = None, + raw_storage_key: Optional[bytes] = None, + subscription_handler=None, + reuse_block_hash: bool = False, + ) -> Any: + """ + Pass-through to substrate.query which automatically returns the .value if it's a ScaleObj + """ + result = await self.substrate.query( + module, + storage_function, + params, + block_hash, + raw_storage_key, + subscription_handler, + reuse_block_hash, + ) + if hasattr(result, "value"): + return result.value + else: + return result async def get_all_subnet_netuids( self, block_hash: Optional[str] = None @@ -155,6 +158,7 @@ async def get_all_subnet_netuids( Retrieves the list of all subnet unique identifiers (netuids) currently present in the Bittensor network. :param block_hash: The hash of the block to retrieve the subnet unique identifiers from. + :return: A list of subnet netuids. This function provides a comprehensive view of the subnets within the Bittensor network, @@ -166,59 +170,13 @@ async def get_all_subnet_netuids( block_hash=block_hash, reuse_block_hash=True, ) - return ( - [] - if result is None or not hasattr(result, "records") - else [netuid async for netuid, exists in result if exists] - ) - - async def is_hotkey_delegate( - self, - hotkey_ss58: str, - block_hash: Optional[str] = None, - reuse_block: Optional[bool] = False, - ) -> bool: - """ - Determines whether a given hotkey (public key) is a delegate on the Bittensor network. This function - checks if the neuron associated with the hotkey is part of the network's delegation system. - - :param hotkey_ss58: The SS58 address of the neuron's hotkey. - :param block_hash: The hash of the blockchain block number for the query. - :param reuse_block: Whether to reuse the last-used block hash. - - :return: `True` if the hotkey is a delegate, `False` otherwise. - - Being a delegate is a significant status within the Bittensor network, indicating a neuron's - involvement in consensus and governance processes. - """ - delegates = await self.get_delegates( - block_hash=block_hash, reuse_block=reuse_block - ) - return hotkey_ss58 in [info.hotkey_ss58 for info in delegates] - - async def get_delegates( - self, block_hash: Optional[str] = None, reuse_block: Optional[bool] = False - ) -> list[DelegateInfo]: - """ - Fetches all delegates on the chain - - :param block_hash: hash of the blockchain block number for the query. - :param reuse_block: whether to reuse the last-used block hash. - - :return: List of DelegateInfo objects, or an empty list if there are no delegates. - """ - hex_bytes_result = await self.query_runtime_api( - runtime_api="DelegateInfoRuntimeApi", - method="get_delegates", - params=[], - block_hash=block_hash, - ) - if hex_bytes_result is not None: - return DelegateInfo.list_from_vec_u8(hex_to_bytes(hex_bytes_result)) - else: - return [] + res = [] + async for netuid, exists in result: + if exists.value: + res.append(netuid) + return res - async def get_stake_info_for_coldkey( + async def get_stake_for_coldkey( self, coldkey_ss58: str, block_hash: Optional[str] = None, @@ -237,47 +195,80 @@ async def get_stake_info_for_coldkey( Stake information is vital for account holders to assess their investment and participation in the network's delegation and consensus processes. """ - encoded_coldkey = ss58_to_vec_u8(coldkey_ss58) - hex_bytes_result = await self.query_runtime_api( + result = await self.query_runtime_api( runtime_api="StakeInfoRuntimeApi", method="get_stake_info_for_coldkey", - params=[encoded_coldkey], + params=[coldkey_ss58], block_hash=block_hash, reuse_block=reuse_block, ) - if hex_bytes_result is None: + if result is None: return [] - - return StakeInfo.list_from_vec_u8(hex_to_bytes(hex_bytes_result)) + stakes = StakeInfo.list_from_any(result) + return [stake for stake in stakes if stake.stake > 0] async def get_stake_for_coldkey_and_hotkey( - self, hotkey_ss58: str, coldkey_ss58: str, block_hash: Optional[str] + self, + hotkey_ss58: str, + coldkey_ss58: str, + netuid: Optional[int] = None, + block_hash: Optional[str] = None, ) -> Balance: """ - Retrieves stake information associated with a specific coldkey and hotkey. - :param hotkey_ss58: the hotkey SS58 address to query - :param coldkey_ss58: the coldkey SS58 address to query - :param block_hash: the hash of the blockchain block number for the query. - :return: Stake Balance for the given coldkey and hotkey + Returns the stake under a coldkey - hotkey pairing. + + :param hotkey_ss58: The SS58 address of the hotkey. + :param coldkey_ss58: The SS58 address of the coldkey. + :param netuid: The subnet ID to filter by. If provided, only returns stake for this specific + subnet. + :param block_hash: The block hash at which to query the stake information. + + :return: Balance: The stake under the coldkey - hotkey pairing. """ - _result = await self.substrate.query( + alpha_shares = await self.query( module="SubtensorModule", - storage_function="Stake", - params=[hotkey_ss58, coldkey_ss58], + storage_function="Alpha", + params=[hotkey_ss58, coldkey_ss58, netuid], block_hash=block_hash, ) - return Balance.from_rao(_result or 0) + + hotkey_alpha = await self.query( + module="SubtensorModule", + storage_function="TotalHotkeyAlpha", + params=[hotkey_ss58, netuid], + block_hash=block_hash, + ) + + hotkey_shares = await self.query( + module="SubtensorModule", + storage_function="TotalHotkeyShares", + params=[hotkey_ss58, netuid], + block_hash=block_hash, + ) + + alpha_shares_as_float = fixed_to_float(alpha_shares or 0) + hotkey_shares_as_float = fixed_to_float(hotkey_shares or 0) + + if hotkey_shares_as_float == 0: + return Balance.from_rao(0).set_unit(netuid=netuid) + + stake = alpha_shares_as_float / hotkey_shares_as_float * (hotkey_alpha or 0) + + return Balance.from_rao(int(stake)).set_unit(netuid=netuid) + + # Alias + get_stake = get_stake_for_coldkey_and_hotkey async def query_runtime_api( self, runtime_api: str, method: str, - params: Optional[Union[list[list[int]], list[int], dict[str, int]]], + params: Optional[Union[list, dict]] = None, block_hash: Optional[str] = None, reuse_block: Optional[bool] = False, - ) -> Optional[str]: + ) -> Optional[Any]: """ Queries the runtime API of the Bittensor blockchain, providing a way to interact with the underlying runtime and retrieve data encoded in Scale Bytes format. This function is essential for advanced users @@ -289,45 +280,44 @@ async def query_runtime_api( :param block_hash: The hash of the blockchain block number at which to perform the query. :param reuse_block: Whether to reuse the last-used block hash. - :return: The Scale Bytes encoded result from the runtime API call, or ``None`` if the call fails. + :return: The decoded result from the runtime API call, or ``None`` if the call fails. This function enables access to the deeper layers of the Bittensor blockchain, allowing for detailed and specific interactions with the network's runtime environment. """ - call_definition = TYPE_REGISTRY["runtime_api"][runtime_api]["methods"][method] - - data = ( - "0x" - if params is None - else await self.encode_params( - call_definition=call_definition, params=params - ) - ) - api_method = f"{runtime_api}_{method}" - - json_result = await self.substrate.rpc_request( - method="state_call", - params=[api_method, data, block_hash] if block_hash else [api_method, data], - ) - - if json_result is None: - return None - - return_type = call_definition["type"] - - as_scale_bytes = scalecodec.ScaleBytes(json_result["result"]) # type: ignore + if reuse_block: + block_hash = self.substrate.last_block_hash + result = ( + await self.substrate.runtime_call(runtime_api, method, params, block_hash) + ).value - rpc_runtime_config = RuntimeConfiguration() - rpc_runtime_config.update_type_registry(load_type_registry_preset("legacy")) - rpc_runtime_config.update_type_registry(custom_rpc_type_registry) + return result - obj = rpc_runtime_config.create_scale_object(return_type, as_scale_bytes) - if obj.data.to_hex() == "0x0400": # RPC returned None result - return None + async def get_balance( + self, + address: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Balance: + """ + Retrieves the balance for a single coldkey address - return obj.decode() + :param address: coldkey address + :param block_hash: the block hash, optional + :param reuse_block: Whether to reuse the last-used block hash when retrieving info. + :return: Balance object representing the address's balance + """ + result = await self.query( + module="System", + storage_function="Account", + params=[address], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + value = result or {"data": {"free": 0}} + return Balance(value["data"]["free"]) - async def get_balance( + async def get_balances( self, *addresses: str, block_hash: Optional[str] = None, @@ -340,6 +330,8 @@ async def get_balance( :param reuse_block: Whether to reuse the last-used block hash when retrieving info. :return: dict of {address: Balance objects} """ + if reuse_block: + block_hash = self.substrate.last_block_hash calls = [ ( await self.substrate.create_storage_key( @@ -370,46 +362,132 @@ async def get_total_stake_for_coldkey( :return: {address: Balance objects} """ + sub_stakes = await self.get_stake_for_coldkeys( + list(ss58_addresses), block_hash=block_hash + ) + # Token pricing info + dynamic_info = await self.all_subnets() + + results = {} + for ss58, stake_info_list in sub_stakes.items(): + all_staked_tao = 0 + for sub_stake in stake_info_list: + if sub_stake.stake.rao == 0: + continue + netuid = sub_stake.netuid + pool = dynamic_info[netuid] + + alpha_value = Balance.from_rao(int(sub_stake.stake.rao)).set_unit( + netuid + ) + + tao_locked = pool.tao_in + + issuance = pool.alpha_out if pool.is_dynamic else tao_locked + tao_ownership = Balance(0) + + if alpha_value.tao > 0.00009 and issuance.tao != 0: + tao_ownership = Balance.from_tao( + (alpha_value.tao / issuance.tao) * tao_locked.tao + ) + + all_staked_tao += tao_ownership.rao + + results[ss58] = Balance.from_rao(all_staked_tao) + return results + + async def get_total_stake_for_hotkey( + self, + *ss58_addresses, + netuids: Optional[list[int]] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[str, dict[int, Balance]]: + """ + Returns the total stake held on a hotkey. + + :param ss58_addresses: The SS58 address(es) of the hotkey(s) + :param netuids: The netuids to retrieve the stake from. If not specified, will use all subnets. + :param block_hash: The hash of the block number to retrieve the stake from. + :param reuse_block: Whether to reuse the last-used block hash when retrieving info. + + :return: + { + hotkey_ss58_1: { + netuid_1: netuid1_stake, + netuid_2: netuid2_stake, + ... + }, + hotkey_ss58_2: { + netuid_1: netuid1_stake, + netuid_2: netuid2_stake, + ... + }, + ... + } + """ + if not block_hash: + if reuse_block: + block_hash = self.substrate.last_block_hash + else: + block_hash = await self.substrate.get_chain_head() + + netuids = netuids or await self.get_all_subnet_netuids(block_hash=block_hash) calls = [ ( await self.substrate.create_storage_key( "SubtensorModule", - "TotalColdkeyStake", - [address], + "TotalHotkeyAlpha", + params=[ss58, netuid], block_hash=block_hash, ) ) - for address in ss58_addresses + for ss58 in ss58_addresses + for netuid in netuids ] - batch_call = await self.substrate.query_multi(calls, block_hash=block_hash) - results = {} - for item in batch_call: - results.update({item[0].params[0]: Balance.from_rao(item[1] or 0)}) + query = await self.substrate.query_multi(calls, block_hash=block_hash) + results: dict[str, dict[int, "Balance"]] = { + hk_ss58: {} for hk_ss58 in ss58_addresses + } + for idx, (_, val) in enumerate(query): + hotkey_ss58 = ss58_addresses[idx // len(netuids)] + netuid = netuids[idx % len(netuids)] + value = (Balance.from_rao(val) if val is not None else Balance(0)).set_unit( + netuid + ) + results[hotkey_ss58][netuid] = value return results - async def get_total_stake_for_hotkey( + async def current_take( self, - *ss58_addresses, + hotkey_ss58: int, block_hash: Optional[str] = None, reuse_block: bool = False, - ) -> dict[str, Balance]: + ) -> Optional[float]: """ - Returns the total stake held on a hotkey. + Retrieves the delegate 'take' percentage for a neuron identified by its hotkey. The 'take' + represents the percentage of rewards that the delegate claims from its nominators' stakes. - :param ss58_addresses: The SS58 address(es) of the hotkey(s) + :param hotkey_ss58: The `SS58` address of the neuron's hotkey. :param block_hash: The hash of the block number to retrieve the stake from. :param reuse_block: Whether to reuse the last-used block hash when retrieving info. - :return: {address: Balance objects} + :return: The delegate take percentage, None if not available. + + The delegate take is a critical parameter in the network's incentive structure, influencing + the distribution of rewards among neurons and their nominators. """ - results = await self.substrate.query_multiple( - params=[s for s in ss58_addresses], + result = await self.query( module="SubtensorModule", - storage_function="TotalHotkeyStake", + storage_function="Delegates", + params=[hotkey_ss58], block_hash=block_hash, reuse_block_hash=reuse_block, ) - return {k: Balance.from_rao(r or 0) for (k, r) in results.items()} + if result is None: + return None + else: + return u16_normalized_float(result) async def get_netuids_for_hotkey( self, @@ -436,11 +514,11 @@ async def get_netuids_for_hotkey( block_hash=block_hash, reuse_block_hash=reuse_block, ) - return ( - [record[0] async for record in result if record[1]] - if result and hasattr(result, "records") - else [] - ) + res = [] + async for record in result: + if record[1].value: + res.append(record[0]) + return res async def subnet_exists( self, netuid: int, block_hash: Optional[str] = None, reuse_block: bool = False @@ -457,7 +535,7 @@ async def subnet_exists( This function is critical for verifying the presence of specific subnets in the network, enabling a deeper understanding of the network's structure and composition. """ - result = await self.substrate.query( + result = await self.query( module="SubtensorModule", storage_function="NetworksAdded", params=[netuid], @@ -466,6 +544,29 @@ async def subnet_exists( ) return result + async def get_subnet_state( + self, netuid: int, block_hash: Optional[str] = None + ) -> Optional["SubnetState"]: + """ + Retrieves the state of a specific subnet within the Bittensor network. + + :param netuid: The network UID of the subnet to query. + :param block_hash: The hash of the blockchain block number for the query. + + :return: SubnetState object containing the subnet's state information, or None if the subnet doesn't exist. + """ + result = await self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_subnet_state", + params=[netuid], + block_hash=block_hash, + ) + + if result is None: + return None + + return SubnetState.from_any(result) + async def get_hyperparameter( self, param_name: str, @@ -487,7 +588,7 @@ async def get_hyperparameter( print("subnet does not exist") return None - result = await self.substrate.query( + result = await self.query( module="SubtensorModule", storage_function=param_name, params=[netuid], @@ -569,11 +670,15 @@ async def get_existential_deposit( The existential deposit is a fundamental economic parameter in the Bittensor network, ensuring efficient use of storage and preventing the proliferation of dust accounts. """ - result = await self.substrate.get_constant( - module_name="Balances", - constant_name="ExistentialDeposit", - block_hash=block_hash, - reuse_block_hash=reuse_block, + result = getattr( + await self.substrate.get_constant( + module_name="Balances", + constant_name="ExistentialDeposit", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ), + "value", + None, ) if result is None: @@ -632,20 +737,18 @@ async def neurons_lite( This function offers a quick overview of the neuron population within a subnet, facilitating efficient analysis of the network's decentralized structure and neuron dynamics. """ - hex_bytes_result = await self.query_runtime_api( + result = await self.query_runtime_api( runtime_api="NeuronInfoRuntimeApi", method="get_neurons_lite", - params=[ - netuid - ], # TODO check to see if this can accept more than one at a time + params=[netuid], block_hash=block_hash, reuse_block=reuse_block, ) - if hex_bytes_result is None: + if result is None: return [] - return NeuronInfoLite.list_from_vec_u8(hex_to_bytes(hex_bytes_result)) + return NeuronInfoLite.list_from_any(result) async def neuron_for_uid( self, uid: Optional[int], netuid: int, block_hash: Optional[str] = None @@ -668,17 +771,20 @@ async def neuron_for_uid( if uid is None: return NeuronInfo.get_null_neuron() - params = [netuid, uid, block_hash] if block_hash else [netuid, uid] - json_body = await self.substrate.rpc_request( - method="neuronInfo_getNeuron", - params=params, # custom rpc method + result = await self.query_runtime_api( + runtime_api="NeuronInfoRuntimeApi", + method="get_neuron", + params=[ + netuid, + uid, + ], # TODO check to see if this can accept more than one at a time + block_hash=block_hash, ) - if not (result := json_body.get("result", None)): + if not result: return NeuronInfo.get_null_neuron() - bytes_result = bytes(result) - return NeuronInfo.from_vec_u8(bytes_result) + return NeuronInfo.from_any(result) async def get_delegated( self, @@ -705,16 +811,45 @@ async def get_delegated( if block_hash else (self.substrate.last_block_hash if reuse_block else None) ) - encoded_coldkey = ss58_to_vec_u8(coldkey_ss58) - json_body = await self.substrate.rpc_request( - method="delegateInfo_getDelegated", - params=([block_hash, encoded_coldkey] if block_hash else [encoded_coldkey]), + result = await self.query_runtime_api( + runtime_api="DelegateInfoRuntimeApi", + method="get_delegated", + params=[coldkey_ss58], + block_hash=block_hash, ) - if not (result := json_body.get("result")): + if not result: return [] - return DelegateInfo.delegated_list_from_vec_u8(bytes(result)) + return DelegateInfo.list_from_any(result) + + async def query_all_identities( + self, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[str, dict]: + """ + Queries all identities on the Bittensor blockchain. + + :param block_hash: The hash of the blockchain block number at which to perform the query. + :param reuse_block: Whether to reuse the last-used blockchain block hash. + + :return: A dictionary mapping addresses to their decoded identity data. + """ + + identities = await self.substrate.query_map( + module="SubtensorModule", + storage_function="IdentitiesV2", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + all_identities = {} + async for ss58_address, identity in identities: + all_identities[decode_account_id(ss58_address[0])] = decode_hex_identity( + identity.value + ) + + return all_identities async def query_identity( self, @@ -740,43 +875,61 @@ async def query_identity( The identity information can include various attributes such as the neuron's stake, rank, and other network-specific details, providing insights into the neuron's role and status within the Bittensor network. """ - - def decode_hex_identity_dict(info_dictionary): - for k, v in info_dictionary.items(): - if isinstance(v, dict): - item = next(iter(v.values())) - else: - item = v - if isinstance(item, tuple) and item: - if len(item) > 1: - try: - info_dictionary[k] = ( - bytes(item).hex(sep=" ", bytes_per_sep=2).upper() - ) - except UnicodeDecodeError: - print(f"Could not decode: {k}: {item}") - else: - try: - info_dictionary[k] = bytes(item[0]).decode("utf-8") - except UnicodeDecodeError: - print(f"Could not decode: {k}: {item}") - else: - info_dictionary[k] = item - - return info_dictionary - - identity_info = await self.substrate.query( - module="Registry", - storage_function="IdentityOf", + identity_info = await self.query( + module="SubtensorModule", + storage_function="IdentitiesV2", params=[key], block_hash=block_hash, reuse_block_hash=reuse_block, ) + if not identity_info: + return {} try: - return decode_hex_identity_dict(identity_info["info"]) + return decode_hex_identity(identity_info) except TypeError: return {} + async def fetch_coldkey_hotkey_identities( + self, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[str, dict]: + """ + Builds a dictionary containing coldkeys and hotkeys with their associated identities and relationships. + :param block_hash: The hash of the blockchain block number for the query. + :param reuse_block: Whether to reuse the last-used blockchain block hash. + :return: Dict with 'coldkeys' and 'hotkeys' as keys. + """ + + coldkey_identities = await self.query_all_identities() + identities = {"coldkeys": {}, "hotkeys": {}} + if not coldkey_identities: + return identities + query = await self.substrate.query_multiple( # TODO probably more efficient to do this with query_multi + params=list(coldkey_identities.keys()), + module="SubtensorModule", + storage_function="OwnedHotkeys", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + for coldkey_ss58, hotkeys in query.items(): + coldkey_identity = coldkey_identities.get(coldkey_ss58) + hotkeys = [decode_account_id(hotkey[0]) for hotkey in hotkeys or []] + + identities["coldkeys"][coldkey_ss58] = { + "identity": coldkey_identity, + "hotkeys": hotkeys, + } + + for hotkey_ss58 in hotkeys: + identities["hotkeys"][hotkey_ss58] = { + "coldkey": coldkey_ss58, + "identity": coldkey_identity, + } + + return identities + async def weights( self, netuid: int, block_hash: Optional[str] = None ) -> list[tuple[int, list[tuple[int, int]]]]: @@ -785,7 +938,6 @@ async def weights( This function maps each neuron's UID to the weights it assigns to other neurons, reflecting the network's trust and value assignment mechanisms. - Args: :param netuid: The network UID of the subnet to query. :param block_hash: The hash of the blockchain block for the query. @@ -794,14 +946,15 @@ async def weights( The weight distribution is a key factor in the network's consensus algorithm and the ranking of neurons, influencing their influence and reward allocation within the subnet. """ - # TODO look into seeing if we can speed this up with storage query w_map_encoded = await self.substrate.query_map( module="SubtensorModule", storage_function="Weights", params=[netuid], block_hash=block_hash, ) - w_map = [(uid, w or []) async for uid, w in w_map_encoded] + w_map = [] + async for uid, w in w_map_encoded: + w_map.append((uid, w.value)) return w_map @@ -829,7 +982,9 @@ async def bonds( params=[netuid], block_hash=block_hash, ) - b_map = [(uid, b) async for uid, b in b_map_encoded] + b_map = [] + async for uid, b in b_map_encoded: + b_map.append((uid, b)) return b_map @@ -848,31 +1003,27 @@ async def does_hotkey_exist( :return: `True` if the hotkey is known by the chain and there are accounts, `False` otherwise. """ - _result = await self.substrate.query( + result = await self.query( module="SubtensorModule", storage_function="Owner", params=[hotkey_ss58], block_hash=block_hash, reuse_block_hash=reuse_block, ) - result = decode_account_id(_result[0]) - return_val = ( - False - if result is None - else result != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" - ) + return_val = result != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" return return_val async def get_hotkey_owner( - self, hotkey_ss58: str, block_hash: str + self, + hotkey_ss58: str, + block_hash: Optional[str] = None, ) -> Optional[str]: - hk_owner_query = await self.substrate.query( + val = await self.query( module="SubtensorModule", storage_function="Owner", params=[hotkey_ss58], block_hash=block_hash, ) - val = decode_account_id(hk_owner_query[0]) if val: exists = await self.does_hotkey_exist(hotkey_ss58, block_hash=block_hash) else: @@ -929,7 +1080,7 @@ async def get_children(self, hotkey, netuid) -> tuple[bool, list, str]: message (if applicable) """ try: - children = await self.substrate.query( + children = await self.query( module="SubtensorModule", storage_function="ChildKeys", params=[hotkey, netuid], @@ -962,17 +1113,26 @@ async def get_subnet_hyperparameters( Understanding the hyperparameters is crucial for comprehending how subnets are configured and managed, and how they interact with the network's consensus and incentive mechanisms. """ - hex_bytes_result = await self.query_runtime_api( + result = await self.query_runtime_api( runtime_api="SubnetInfoRuntimeApi", method="get_subnet_hyperparams", params=[netuid], block_hash=block_hash, ) - if hex_bytes_result is None: + if not result: return [] - return SubnetHyperparameters.from_vec_u8(hex_to_bytes(hex_bytes_result)) + return SubnetHyperparameters.from_any(result) + + async def burn_cost(self, block_hash: Optional[str] = None) -> Optional[Balance]: + result = await self.query_runtime_api( + runtime_api="SubnetRegistrationRuntimeApi", + method="get_network_registration_cost", + params=[], + block_hash=block_hash, + ) + return Balance.from_rao(result) if result is not None else None async def get_vote_data( self, @@ -993,7 +1153,7 @@ async def get_vote_data( This function is important for tracking and understanding the decision-making processes within the Bittensor network, particularly how proposals are received and acted upon by the governing body. """ - vote_data = await self.substrate.query( + vote_data = await self.query( module="Triumvirate", storage_function="Voting", params=[proposal_hash], @@ -1013,10 +1173,9 @@ async def get_delegate_identities( is filled-in by the info from GitHub. At some point, we want to totally move away from fetching this info from GitHub, but chain data is still limited in that regard. - Args: - block_hash: the hash of the blockchain block for the query + :param block_hash: the hash of the blockchain block for the query - Returns: {ss58: DelegatesDetails, ...} + :return: {ss58: DelegatesDetails, ...} """ timeout = aiohttp.ClientTimeout(10.0) @@ -1030,12 +1189,17 @@ async def get_delegate_identities( session.get(Constants.delegates_detail_url), ) - all_delegates_details = { - decode_account_id(ss58_address[0]): DelegatesDetails.from_chain_data( - decode_hex_identity_dict(identity["info"]) + all_delegates_details = {} + async for ss58_address, identity in identities_info: + all_delegates_details.update( + { + decode_account_id( + ss58_address[0] + ): DelegatesDetails.from_chain_data( + decode_hex_identity_dict(identity.value["info"]) + ) + } ) - for ss58_address, identity in identities_info - } if response.ok: all_delegates: dict[str, Any] = await response.json(content_type=None) @@ -1066,3 +1230,156 @@ async def get_delegate_identities( ) return all_delegates_details + + async def get_stake_for_coldkey_and_hotkey_on_netuid( + self, + hotkey_ss58: str, + coldkey_ss58: str, + netuid: int, + block_hash: Optional[str] = None, + ) -> "Balance": + """Returns the stake under a coldkey - hotkey - netuid pairing""" + _result = await self.query( + "SubtensorModule", + "Alpha", + [hotkey_ss58, coldkey_ss58, netuid], + block_hash, + ) + if _result is None: + return Balance(0).set_unit(netuid) + else: + return Balance.from_rao(_result).set_unit(int(netuid)) + + async def multi_get_stake_for_coldkey_and_hotkey_on_netuid( + self, + hotkey_ss58s: list[str], + coldkey_ss58: str, + netuids: list[int], + block_hash: Optional[str] = None, + ) -> dict[str, dict[int, "Balance"]]: + """ + Queries the stake for multiple hotkey - coldkey - netuid pairings. + + :param hotkey_ss58s: list of hotkey ss58 addresses + :param coldkey_ss58: a single coldkey ss58 address + :param netuids: list of netuids + :param block_hash: hash of the blockchain block, if any + + :return: + { + hotkey_ss58_1: { + netuid_1: netuid1_stake, + netuid_2: netuid2_stake, + ... + }, + hotkey_ss58_2: { + netuid_1: netuid1_stake, + netuid_2: netuid2_stake, + ... + }, + ... + } + + """ + calls = [ + ( + await self.substrate.create_storage_key( + "SubtensorModule", + "Alpha", + [hk_ss58, coldkey_ss58, netuid], + block_hash=block_hash, + ) + ) + for hk_ss58 in hotkey_ss58s + for netuid in netuids + ] + batch_call = await self.substrate.query_multi(calls, block_hash=block_hash) + results: dict[str, dict[int, "Balance"]] = { + hk_ss58: {} for hk_ss58 in hotkey_ss58s + } + for idx, (_, val) in enumerate(batch_call): + hotkey_idx = idx // len(netuids) + netuid_idx = idx % len(netuids) + hotkey_ss58 = hotkey_ss58s[hotkey_idx] + netuid = netuids[netuid_idx] + value = ( + Balance.from_rao(val).set_unit(netuid) + if val is not None + else Balance(0).set_unit(netuid) + ) + results[hotkey_ss58][netuid] = value + return results + + async def get_stake_for_coldkeys( + self, coldkey_ss58_list: list[str], block_hash: Optional[str] = None + ) -> Optional[dict[str, list[StakeInfo]]]: + """ + Retrieves stake information for a list of coldkeys. This function aggregates stake data for multiple + accounts, providing a collective view of their stakes and delegations. + + :param coldkey_ss58_list: A list of SS58 addresses of the accounts' coldkeys. + :param block_hash: The blockchain block number for the query. + + :return: A dictionary mapping each coldkey to a list of its StakeInfo objects. + + This function is useful for analyzing the stake distribution and delegation patterns of multiple + accounts simultaneously, offering a broader perspective on network participation and investment strategies. + """ + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_info_for_coldkeys", + params=[coldkey_ss58_list], + block_hash=block_hash, + ) + if result is None: + return None + + stake_info_map = {} + for coldkey_bytes, stake_info_list in result: + coldkey_ss58 = decode_account_id(coldkey_bytes) + stake_info_map[coldkey_ss58] = StakeInfo.list_from_any(stake_info_list) + return stake_info_map + + async def all_subnets(self, block_hash: Optional[str] = None) -> list[DynamicInfo]: + result = await self.query_runtime_api( + "SubnetInfoRuntimeApi", + "get_all_dynamic_info", + block_hash=block_hash, + ) + return DynamicInfo.list_from_any(result) + + async def subnet( + self, netuid: int, block_hash: Optional[str] = None + ) -> "DynamicInfo": + result = await self.query_runtime_api( + "SubnetInfoRuntimeApi", + "get_dynamic_info", + params=[netuid], + block_hash=block_hash, + ) + return DynamicInfo.from_any(result) + + async def get_owned_hotkeys( + self, + coldkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list[str]: + """ + Retrieves all hotkeys owned by a specific coldkey address. + + :param coldkey_ss58: The SS58 address of the coldkey to query. + :param block_hash: The hash of the blockchain block number for the query. + :param reuse_block: Whether to reuse the last-used blockchain block hash. + + :return: A list of hotkey SS58 addresses owned by the coldkey. + """ + owned_hotkeys = await self.query( + module="SubtensorModule", + storage_function="OwnedHotkeys", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + return [decode_account_id(hotkey[0]) for hotkey in owned_hotkeys or []] diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 66c7a5c9..de113e1c 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -3,10 +3,14 @@ import math import os import sqlite3 +import platform import webbrowser +import sys from pathlib import Path from typing import TYPE_CHECKING, Any, Collection, Optional, Union, Callable from urllib.parse import urlparse +from functools import partial +import re from bittensor_wallet import Wallet, Keypair from bittensor_wallet.utils import SS58_FORMAT @@ -17,20 +21,17 @@ import numpy as np from numpy.typing import NDArray from rich.console import Console -import scalecodec -from scalecodec.base import RuntimeConfiguration -from scalecodec.type_registry import load_type_registry_preset +from rich.prompt import Prompt +from scalecodec.utils.ss58 import ss58_encode, ss58_decode import typer from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src import defaults, Constants if TYPE_CHECKING: from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters - from bittensor_cli.src.bittensor.async_substrate_interface import ( - AsyncSubstrateInterface, - ) console = Console() err_console = Console(stderr=True) @@ -39,6 +40,23 @@ UnlockStatus = namedtuple("UnlockStatus", ["success", "message"]) +class _Hotkey: + def __init__(self, hotkey_ss58=None): + self.ss58_address = hotkey_ss58 + + +class WalletLike: + def __init__(self, name=None, hotkey_ss58=None, hotkey_str=None): + self.name = name + self.hotkey_ss58 = hotkey_ss58 + self.hotkey_str = hotkey_str + self._hotkey = _Hotkey(hotkey_ss58) + + @property + def hotkey(self): + return self._hotkey + + def print_console(message: str, colour: str, title: str, console: Console): console.print( f"[bold {colour}][{title}]:[/bold {colour}] [{colour}]{message}[/{colour}]\n" @@ -197,13 +215,14 @@ def convert_root_weight_uids_and_vals_to_tensor( def get_hotkey_wallets_for_wallet( - wallet: Wallet, show_nulls: bool = False + wallet: Wallet, show_nulls: bool = False, show_encrypted: bool = False ) -> list[Optional[Wallet]]: """ Returns wallet objects with hotkeys for a single given wallet :param wallet: Wallet object to use for the path :param show_nulls: will add `None` into the output if a hotkey is encrypted or not on the device + :param show_encrypted: will add some basic info about the encrypted hotkey :return: a list of wallets (with Nones included for cases of a hotkey being encrypted or not on the device, if `show_nulls` is set to `True`) @@ -219,12 +238,18 @@ def get_hotkey_wallets_for_wallet( hotkey_for_name = Wallet(path=str(wallet_path), name=wallet.name, hotkey=h_name) try: if ( - hotkey_for_name.hotkey_file.exists_on_device() + (exists := hotkey_for_name.hotkey_file.exists_on_device()) and not hotkey_for_name.hotkey_file.is_encrypted() # and hotkey_for_name.coldkeypub.ss58_address and hotkey_for_name.hotkey.ss58_address ): hotkey_wallets.append(hotkey_for_name) + elif ( + show_encrypted and exists and hotkey_for_name.hotkey_file.is_encrypted() + ): + hotkey_wallets.append( + WalletLike(str(wallet_path), "", h_name) + ) elif show_nulls: hotkey_wallets.append(None) except ( @@ -376,21 +401,15 @@ def is_valid_bittensor_address_or_public_key(address: Union[str, bytes]) -> bool return False -def decode_scale_bytes(return_type, scale_bytes, custom_rpc_type_registry): - """Decodes a ScaleBytes object using our type registry and return type""" - rpc_runtime_config = RuntimeConfiguration() - rpc_runtime_config.update_type_registry(load_type_registry_preset("legacy")) - rpc_runtime_config.update_type_registry(custom_rpc_type_registry) - obj = rpc_runtime_config.create_scale_object(return_type, scale_bytes) - if obj.data.to_hex() == "0x0400": # RPC returned None result - return None - return obj.decode() +def decode_account_id(account_id_bytes: Union[tuple[int], tuple[tuple[int]]]): + if isinstance(account_id_bytes, tuple) and isinstance(account_id_bytes[0], tuple): + account_id_bytes = account_id_bytes[0] + # Convert the AccountId bytes to a Base64 string + return ss58_encode(bytes(account_id_bytes).hex(), SS58_FORMAT) -def ss58_address_to_bytes(ss58_address: str) -> bytes: - """Converts a ss58 address to a bytes object.""" - account_id_hex: str = scalecodec.ss58_decode(ss58_address, SS58_FORMAT) - return bytes.fromhex(account_id_hex) +def encode_account_id(ss58_address: str) -> bytes: + return bytes.fromhex(ss58_decode(ss58_address, SS58_FORMAT)) def ss58_to_vec_u8(ss58_address: str) -> list[int]: @@ -401,7 +420,7 @@ def ss58_to_vec_u8(ss58_address: str) -> list[int]: :return: A list of integers representing the byte values of the SS58 address. """ - ss58_bytes: bytes = ss58_address_to_bytes(ss58_address) + ss58_bytes: bytes = encode_account_id(ss58_address) encoded_address: list[int] = [int(byte) for byte in ss58_bytes] return encoded_address @@ -507,7 +526,10 @@ def format_error_message(error_message: Union[dict, Exception]) -> str: # subtensor custom error marker if err_data.startswith("Custom error:"): - err_description = f"{err_data} | Please consult https://docs.bittensor.com/subtensor-nodes/subtensor-error-messages" + err_description = ( + f"{err_data} | Please consult " + f"https://docs.bittensor.com/subtensor-nodes/subtensor-error-messages" + ) else: err_description = err_data @@ -542,7 +564,8 @@ def decode_hex_identity_dict(info_dictionary) -> dict[str, Any]: """ Decodes hex-encoded strings in a dictionary. - This function traverses the given dictionary, identifies hex-encoded strings, and decodes them into readable strings. It handles nested dictionaries and lists within the dictionary. + This function traverses the given dictionary, identifies hex-encoded strings, and decodes them into readable + strings. It handles nested dictionaries and lists within the dictionary. Args: info_dictionary (dict): The dictionary containing hex-encoded strings to decode. @@ -630,6 +653,32 @@ def millify(n: int): return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx]) +def millify_tao(n: float, start_at: str = "K") -> str: + """ + Dupe of millify, but for ease in converting tao values. + Allows thresholds to be specified for different suffixes. + """ + mill_names = ["", "k", "m", "b", "t"] + thresholds = {"K": 1, "M": 2, "B": 3, "T": 4} + + if start_at not in thresholds: + raise ValueError(f"start_at must be one of {list(thresholds.keys())}") + + n_ = float(n) + if n_ == 0: + return "0.00" + + mill_idx = int(math.floor(math.log10(abs(n_)) / 3)) + + # Number's index is below our threshold, return with commas + if mill_idx < thresholds[start_at]: + return f"{n_:,.2f}" + + mill_idx = max(thresholds[start_at], min(len(mill_names) - 1, mill_idx)) + + return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx]) + + def normalize_hyperparameters( subnet: "SubnetHyperparameters", ) -> list[tuple[str, str, str]]: @@ -942,7 +991,7 @@ def retry_prompt( rejection_text: str, default="", show_default=False, - prompt_type=typer.prompt, + prompt_type=Prompt.ask, ): """ Allows for asking prompts again if they do not meet a certain criteria (as defined in `rejection`) @@ -965,6 +1014,302 @@ def retry_prompt( err_console.print(rejection_text) +def validate_netuid(value: int) -> int: + if value is not None and value < 0: + raise typer.BadParameter("Negative netuid passed. Please use correct netuid.") + return value + + +def validate_uri(uri: str) -> str: + if not uri: + return None + clean_uri = uri.lstrip("/").lower() + if not clean_uri.isalnum(): + raise typer.BadParameter( + f"Invalid URI format: {uri}. URI must contain only alphanumeric characters (e.g. 'alice', 'bob')" + ) + return f"//{clean_uri.capitalize()}" + + +def get_effective_network(config, network: Optional[list[str]]) -> str: + """ + Determines the effective network to be used, considering the network parameter, + the configuration, and the default. + """ + if network: + for item in network: + if item.startswith("ws"): + network_ = item + break + else: + network_ = item + return network_ + elif config.get("network"): + return config["network"] + else: + return defaults.subtensor.network + + +def is_rao_network(network: str) -> bool: + """Check if the given network is 'rao'.""" + network = network.lower() + rao_identifiers = [ + "rao", + Constants.rao_entrypoint, + ] + return ( + network == "rao" + or network in rao_identifiers + or "rao.chain.opentensor.ai" in network + ) + + +def prompt_for_identity( + current_identity: dict, + name: Optional[str], + web_url: Optional[str], + image_url: Optional[str], + discord: Optional[str], + description: Optional[str], + additional: Optional[str], + github_repo: Optional[str], +): + """ + Prompts the user for identity fields with validation. + Returns a dictionary with the updated fields. + """ + identity_fields = {} + + fields = [ + ("name", "[blue]Display name[/blue]", name), + ("url", "[blue]Web URL[/blue]", web_url), + ("image", "[blue]Image URL[/blue]", image_url), + ("discord", "[blue]Discord handle[/blue]", discord), + ("description", "[blue]Description[/blue]", description), + ("additional", "[blue]Additional information[/blue]", additional), + ("github_repo", "[blue]GitHub repository URL[/blue]", github_repo), + ] + + text_rejection = partial( + retry_prompt, + rejection=lambda x: sys.getsizeof(x) > 113, + rejection_text="[red]Error:[/red] Identity field must be <= 64 raw bytes.", + ) + + if not any( + [ + name, + web_url, + image_url, + discord, + description, + additional, + github_repo, + ] + ): + console.print( + "\n[yellow]All fields are optional. Press Enter to skip and keep the default/existing value.[/yellow]\n" + "[dark_sea_green3]Tip: Entering a space and pressing Enter will clear existing default value.\n" + ) + + for key, prompt, value in fields: + if value: + identity_fields[key] = value + else: + identity_fields[key] = text_rejection( + prompt, + default=current_identity.get(key, ""), + show_default=True, + ) + + return identity_fields + + +def prompt_for_subnet_identity( + subnet_name: Optional[str], + github_repo: Optional[str], + subnet_contact: Optional[str], + subnet_url: Optional[str], + discord: Optional[str], + description: Optional[str], + additional: Optional[str], +): + """ + Prompts the user for required subnet identity fields with validation. + Returns a dictionary with the updated fields. + + Args: + subnet_name (Optional[str]): Name of the subnet + github_repo (Optional[str]): GitHub repository URL + subnet_contact (Optional[str]): Contact information for subnet (email) + + Returns: + dict: Dictionary containing the subnet identity fields + """ + identity_fields = {} + + fields = [ + ( + "subnet_name", + "[blue]Subnet name [dim](optional)[/blue]", + subnet_name, + lambda x: x and sys.getsizeof(x) > 113, + "[red]Error:[/red] Subnet name must be <= 64 raw bytes.", + ), + ( + "github_repo", + "[blue]GitHub repository URL [dim](optional)[/blue]", + github_repo, + lambda x: x and not is_valid_github_url(x), + "[red]Error:[/red] Please enter a valid GitHub repository URL (e.g., https://github.com/username/repo).", + ), + ( + "subnet_contact", + "[blue]Contact email [dim](optional)[/blue]", + subnet_contact, + lambda x: x and not is_valid_contact(x), + "[red]Error:[/red] Please enter a valid email address.", + ), + ( + "subnet_url", + "[blue]Subnet URL [dim](optional)[/blue]", + subnet_url, + lambda x: x and sys.getsizeof(x) > 113, + "[red]Error:[/red] Please enter a valid URL.", + ), + ( + "discord", + "[blue]Discord handle [dim](optional)[/blue]", + discord, + lambda x: x and sys.getsizeof(x) > 113, + "[red]Error:[/red] Please enter a valid Discord handle.", + ), + ( + "description", + "[blue]Description [dim](optional)[/blue]", + description, + lambda x: x and sys.getsizeof(x) > 113, + "[red]Error:[/red] Description must be <= 64 raw bytes.", + ), + ( + "additional", + "[blue]Additional information [dim](optional)[/blue]", + additional, + lambda x: x and sys.getsizeof(x) > 113, + "[red]Error:[/red] Additional information must be <= 64 raw bytes.", + ), + ] + + for key, prompt, value, rejection_func, rejection_msg in fields: + if value: + if rejection_func(value): + raise ValueError(rejection_msg) + identity_fields[key] = value + else: + identity_fields[key] = retry_prompt( + prompt, + rejection=rejection_func, + rejection_text=rejection_msg, + default=None, # Maybe we can add some defaults later + show_default=True, + ) + + return identity_fields + + +def is_valid_github_url(url: str) -> bool: + """ + Validates if the provided URL is a valid GitHub repository URL. + + Args: + url (str): URL to validate + + Returns: + bool: True if valid GitHub repo URL, False otherwise + """ + try: + parsed = urlparse(url) + if parsed.netloc != "github.com": + return False + + # Check path follows github.com/user/repo format + path_parts = [p for p in parsed.path.split("/") if p] + if len(path_parts) < 2: # Need at least username/repo + return False + + return True + except Exception: # TODO figure out the exceptions that can be raised in here + return False + + +def is_valid_contact(contact: str) -> bool: + """ + Validates if the provided contact is a valid email address. + + Args: + contact (str): Contact information to validate + + Returns: + bool: True if valid email, False otherwise + """ + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + return bool(re.match(email_pattern, contact)) + + +def get_subnet_name(subnet_info) -> str: + """Get the subnet name, prioritizing subnet_identity.subnet_name over subnet.subnet_name. + + Args: + subnet: The subnet dynamic info + + Returns: + str: The subnet name or empty string if no name is found + """ + return ( + subnet_info.subnet_identity.subnet_name + if hasattr(subnet_info, "subnet_identity") + and subnet_info.subnet_identity is not None + and subnet_info.subnet_identity.subnet_name is not None + else (subnet_info.subnet_name if subnet_info.subnet_name is not None else "") + ) + + +def print_linux_dependency_message(): + """Prints the WebKit dependency message for Linux systems.""" + console.print("[red]This command requires WebKit dependencies on Linux.[/red]") + console.print( + "\nPlease install the required packages using one of the following commands based on your distribution:" + ) + console.print("\nArch Linux / Manjaro:") + console.print("[green]sudo pacman -S webkit2gtk[/green]") + console.print("\nDebian / Ubuntu:") + console.print("[green]sudo apt install libwebkit2gtk-4.0-dev[/green]") + console.print("\nFedora / CentOS / AlmaLinux:") + console.print("[green]sudo dnf install gtk3-devel webkit2gtk3-devel[/green]") + + +def is_linux(): + """Returns True if the operating system is Linux.""" + return platform.system().lower() == "linux" + + +def validate_rate_tolerance(value: Optional[float]) -> Optional[float]: + """Validates rate tolerance input""" + if value is not None: + if value < 0: + raise typer.BadParameter( + "Rate tolerance cannot be negative (less than 0%)." + ) + if value > 1: + raise typer.BadParameter("Rate tolerance cannot be greater than 1 (100%).") + if value > 0.5: + console.print( + f"[yellow]Warning: High rate tolerance of {value*100}% specified. " + "This may result in unfavorable transaction execution.[/yellow]" + ) + return value + + def unlock_key( wallet: Wallet, unlock_type="cold", print_out: bool = True ) -> "UnlockStatus": diff --git a/bittensor_cli/src/commands/root.py b/bittensor_cli/src/commands/root.py deleted file mode 100644 index 668151db..00000000 --- a/bittensor_cli/src/commands/root.py +++ /dev/null @@ -1,1787 +0,0 @@ -import asyncio -import json -from typing import Optional, TYPE_CHECKING - -from bittensor_wallet import Wallet -import numpy as np -from numpy.typing import NDArray -from rich import box -from rich.prompt import Confirm -from rich.table import Column, Table -from rich.text import Text -from scalecodec import GenericCall, ScaleType -from substrateinterface.exceptions import SubstrateRequestException -import typer - -from bittensor_cli.src import DelegatesDetails -from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.bittensor.chain_data import ( - DelegateInfo, - NeuronInfoLite, - decode_account_id, -) -from bittensor_cli.src.bittensor.extrinsics.root import ( - root_register_extrinsic, - set_root_weights_extrinsic, -) -from bittensor_cli.src.commands.wallets import ( - get_coldkey_wallets_for_path, - set_id, - set_id_prompts, -) -from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -from bittensor_cli.src.bittensor.utils import ( - console, - convert_weight_uids_and_vals_to_tensor, - create_table, - err_console, - print_verbose, - get_metadata_table, - render_table, - ss58_to_vec_u8, - update_metadata_table, - group_subnets, - unlock_key, - blocks_to_duration, -) - -if TYPE_CHECKING: - from bittensor_cli.src.bittensor.subtensor_interface import ProposalVoteData - -# helpers - - -def display_votes( - vote_data: "ProposalVoteData", delegate_info: dict[str, DelegatesDetails] -) -> str: - vote_list = list() - - for address in vote_data.ayes: - vote_list.append( - "{}: {}".format( - delegate_info[address].display if address in delegate_info else address, - "[bold green]Aye[/bold green]", - ) - ) - - for address in vote_data.nays: - vote_list.append( - "{}: {}".format( - delegate_info[address].display if address in delegate_info else address, - "[bold red]Nay[/bold red]", - ) - ) - - return "\n".join(vote_list) - - -def format_call_data(call_data: dict) -> str: - # Extract the module and call details - module, call_details = next(iter(call_data.items())) - - # Extract the call function name and arguments - call_info = call_details[0] - call_function, call_args = next(iter(call_info.items())) - - # Format arguments, handle nested/large payloads - formatted_args = [] - for arg_name, arg_value in call_args.items(): - if isinstance(arg_value, (tuple, list, dict)): - # For large nested, show abbreviated version - content_str = str(arg_value) - if len(content_str) > 20: - formatted_args.append(f"{arg_name}: ... [{len(content_str)}] ...") - else: - formatted_args.append(f"{arg_name}: {arg_value}") - else: - formatted_args.append(f"{arg_name}: {arg_value}") - - # Format the final output string - args_str = ", ".join(formatted_args) - return f"{module}.{call_function}({args_str})" - - -async def _get_senate_members( - subtensor: SubtensorInterface, block_hash: Optional[str] = None -) -> list[str]: - """ - Gets all members of the senate on the given subtensor's network - - :param subtensor: SubtensorInterface object to use for the query - - :return: list of the senate members' ss58 addresses - """ - senate_members = await subtensor.substrate.query( - module="SenateMembers", - storage_function="Members", - params=None, - block_hash=block_hash, - ) - try: - return [ - decode_account_id(i[x][0]) for i in senate_members for x in range(len(i)) - ] - except (IndexError, TypeError): - err_console.print("Unable to retrieve senate members.") - return [] - - -async def _get_proposals( - subtensor: SubtensorInterface, block_hash: str -) -> dict[str, tuple[dict, "ProposalVoteData"]]: - async def get_proposal_call_data(p_hash: str) -> Optional[GenericCall]: - proposal_data = await subtensor.substrate.query( - module="Triumvirate", - storage_function="ProposalOf", - block_hash=block_hash, - params=[p_hash], - ) - return proposal_data - - ph = await subtensor.substrate.query( - module="Triumvirate", - storage_function="Proposals", - params=None, - block_hash=block_hash, - ) - - try: - proposal_hashes: list[str] = [ - f"0x{bytes(ph[0][x][0]).hex()}" for x in range(len(ph[0])) - ] - except (IndexError, TypeError): - err_console.print("Unable to retrieve proposal vote data") - return {} - - call_data_, vote_data_ = await asyncio.gather( - asyncio.gather(*[get_proposal_call_data(h) for h in proposal_hashes]), - asyncio.gather(*[subtensor.get_vote_data(h) for h in proposal_hashes]), - ) - return { - proposal_hash: (cd, vd) - for cd, vd, proposal_hash in zip(call_data_, vote_data_, proposal_hashes) - } - - -def _validate_proposal_hash(proposal_hash: str) -> bool: - if proposal_hash[0:2] != "0x" or len(proposal_hash) != 66: - return False - else: - return True - - -async def _is_senate_member(subtensor: SubtensorInterface, hotkey_ss58: str) -> bool: - """ - Checks if a given neuron (identified by its hotkey SS58 address) is a member of the Bittensor senate. - The senate is a key governance body within the Bittensor network, responsible for overseeing and - approving various network operations and proposals. - - :param subtensor: SubtensorInterface object to use for the query - :param hotkey_ss58: The `SS58` address of the neuron's hotkey. - - :return: `True` if the neuron is a senate member at the given block, `False` otherwise. - - This function is crucial for understanding the governance dynamics of the Bittensor network and for - identifying the neurons that hold decision-making power within the network. - """ - - senate_members = await _get_senate_members(subtensor) - - if not hasattr(senate_members, "count"): - return False - - return senate_members.count(hotkey_ss58) > 0 - - -async def vote_senate_extrinsic( - subtensor: SubtensorInterface, - wallet: Wallet, - proposal_hash: str, - proposal_idx: int, - vote: bool, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = True, - prompt: bool = False, -) -> bool: - """Votes ayes or nays on proposals. - - :param subtensor: The SubtensorInterface object to use for the query - :param wallet: Bittensor wallet object, with coldkey and hotkey unlocked. - :param proposal_hash: The hash of the proposal for which voting data is requested. - :param proposal_idx: The index of the proposal to vote. - :param vote: Whether to vote aye or nay. - :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns - `False` if the extrinsic fails to enter the block within the timeout. - :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, - or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. - - :return: Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for - finalization/inclusion, the response is `True`. - """ - - if prompt: - # Prompt user for confirmation. - if not Confirm.ask(f"Cast a vote of {vote}?"): - return False - - with console.status(":satellite: Casting vote..", spinner="aesthetic"): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="vote", - call_params={ - "hotkey": wallet.hotkey.ss58_address, - "proposal": proposal_hash, - "index": proposal_idx, - "approve": vote, - }, - ) - success, err_msg = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization - ) - if not success: - err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - await asyncio.sleep(0.5) - return False - # Successful vote, final check for data - else: - if vote_data := await subtensor.get_vote_data(proposal_hash): - if ( - vote_data.ayes.count(wallet.hotkey.ss58_address) > 0 - or vote_data.nays.count(wallet.hotkey.ss58_address) > 0 - ): - console.print(":white_heavy_check_mark: [green]Vote cast.[/green]") - return True - else: - # hotkey not found in ayes/nays - err_console.print( - ":cross_mark: [red]Unknown error. Couldn't find vote.[/red]" - ) - return False - else: - return False - - -async def burned_register_extrinsic( - subtensor: SubtensorInterface, - wallet: Wallet, - netuid: int, - recycle_amount: Balance, - old_balance: Balance, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = True, - prompt: bool = False, -) -> bool: - """Registers the wallet to chain by recycling TAO. - - :param subtensor: The SubtensorInterface object to use for the call, initialized - :param wallet: Bittensor wallet object. - :param netuid: The `netuid` of the subnet to register on. - :param recycle_amount: The amount of TAO required for this burn. - :param old_balance: The wallet balance prior to the registration burn. - :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns - `False` if the extrinsic fails to enter the block within the timeout. - :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, - or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. - - :return: Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for - finalization/inclusion, the response is `True`. - """ - - if not unlock_key(wallet).success: - return False - - with console.status( - f":satellite: Checking Account on [bold]subnet:{netuid}[/bold]...", - spinner="aesthetic", - ) as status: - my_uid = await subtensor.substrate.query( - "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] - ) - - print_verbose("Checking if already registered", status) - neuron = await subtensor.neuron_for_uid( - uid=my_uid, - netuid=netuid, - block_hash=subtensor.substrate.last_block_hash, - ) - - if not neuron.is_null: - console.print( - ":white_heavy_check_mark: [green]Already Registered[/green]:\n" - f"uid: [bold white]{neuron.uid}[/bold white]\n" - f"netuid: [bold white]{neuron.netuid}[/bold white]\n" - f"hotkey: [bold white]{neuron.hotkey}[/bold white]\n" - f"coldkey: [bold white]{neuron.coldkey}[/bold white]" - ) - return True - - with console.status( - ":satellite: Recycling TAO for Registration...", spinner="aesthetic" - ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="burned_register", - call_params={ - "netuid": netuid, - "hotkey": wallet.hotkey.ss58_address, - }, - ) - success, err_msg = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization - ) - - if not success: - err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - await asyncio.sleep(0.5) - return False - # Successful registration, final check for neuron and pubkey - else: - with console.status(":satellite: Checking Balance...", spinner="aesthetic"): - block_hash = await subtensor.substrate.get_chain_head() - new_balance, netuids_for_hotkey, my_uid = await asyncio.gather( - subtensor.get_balance( - wallet.coldkeypub.ss58_address, - block_hash=block_hash, - reuse_block=False, - ), - subtensor.get_netuids_for_hotkey( - wallet.hotkey.ss58_address, block_hash=block_hash - ), - subtensor.substrate.query( - "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] - ), - ) - - console.print( - "Balance:\n" - f" [blue]{old_balance}[/blue] :arrow_right: [green]{new_balance[wallet.coldkey.ss58_address]}[/green]" - ) - - if len(netuids_for_hotkey) > 0: - console.print( - f":white_heavy_check_mark: [green]Registered on netuid {netuid} with UID {my_uid}[/green]" - ) - return True - else: - # neuron not found, try again - err_console.print( - ":cross_mark: [red]Unknown error. Neuron not found.[/red]" - ) - return False - - -async def set_take_extrinsic( - subtensor: SubtensorInterface, - wallet: Wallet, - delegate_ss58: str, - take: float = 0.0, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> bool: - """ - Set delegate hotkey take - - :param subtensor: SubtensorInterface (initialized) - :param wallet: The wallet containing the hotkey to be nominated. - :param delegate_ss58: Hotkey - :param take: Delegate take on subnet ID - :param wait_for_finalization: If `True`, waits until the transaction is finalized on the - blockchain. - :param wait_for_inclusion: If `True`, waits until the transaction is included in a block. - - :return: `True` if the process is successful, `False` otherwise. - - This function is a key part of the decentralized governance mechanism of Bittensor, allowing for the - dynamic selection and participation of validators in the network's consensus process. - """ - - async def _get_delegate_by_hotkey(ss58: str) -> Optional[DelegateInfo]: - """Retrieves the delegate info for a given hotkey's ss58 address""" - encoded_hotkey = ss58_to_vec_u8(ss58) - json_body = await subtensor.substrate.rpc_request( - method="delegateInfo_getDelegate", # custom rpc method - params=([encoded_hotkey, subtensor.substrate.last_block_hash]), - ) - if not (result := json_body.get("result", None)): - return None - else: - return DelegateInfo.from_vec_u8(bytes(result)) - - # Calculate u16 representation of the take - take_u16 = int(take * 0xFFFF) - - print_verbose("Checking current take") - # Check if the new take is greater or lower than existing take or if existing is set - delegate = await _get_delegate_by_hotkey(delegate_ss58) - current_take = None - if delegate is not None: - current_take = int( - float(delegate.take) * 65535.0 - ) # TODO verify this, why not u16_float_to_int? - - if take_u16 == current_take: - console.print("Nothing to do, take hasn't changed") - return True - if current_take is None or current_take < take_u16: - console.print( - f"Current take is {float(delegate.take):.4f}. Increasing to {take:.4f}." - ) - with console.status( - f":satellite: Sending decrease_take_extrinsic call on [white]{subtensor}[/white] ..." - ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="increase_take", - call_params={ - "hotkey": delegate_ss58, - "take": take_u16, - }, - ) - success, err = await subtensor.sign_and_send_extrinsic(call, wallet) - - else: - console.print( - f"Current take is {float(delegate.take):.4f}. Decreasing to {take:.4f}." - ) - with console.status( - f":satellite: Sending increase_take_extrinsic call on [white]{subtensor}[/white] ..." - ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="decrease_take", - call_params={ - "hotkey": delegate_ss58, - "take": take_u16, - }, - ) - success, err = await subtensor.sign_and_send_extrinsic(call, wallet) - - if not success: - err_console.print(err) - else: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - return success - - -async def delegate_extrinsic( - subtensor: SubtensorInterface, - wallet: Wallet, - delegate_ss58: str, - amount: Optional[float], - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, - prompt: bool = False, - delegate: bool = True, -) -> bool: - """Delegates the specified amount of stake to the passed delegate. - - :param subtensor: The SubtensorInterface used to perform the delegation, initialized. - :param wallet: Bittensor wallet object. - :param delegate_ss58: The `ss58` address of the delegate. - :param amount: Amount to stake as bittensor balance, None to stake all available TAO. - :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns - `False` if the extrinsic fails to enter the block within the timeout. - :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, - or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. - :param delegate: whether to delegate (`True`) or undelegate (`False`) - - :return: `True` if extrinsic was finalized or included in the block. If we did not wait for finalization/inclusion, - the response is `True`. - """ - - async def _do_delegation(staking_balance_: Balance) -> tuple[bool, str]: - """Performs the delegation extrinsic call to the chain.""" - if delegate: - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="add_stake", - call_params={ - "hotkey": delegate_ss58, - "amount_staked": staking_balance_.rao, - }, - ) - else: - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="remove_stake", - call_params={ - "hotkey": delegate_ss58, - "amount_unstaked": staking_balance_.rao, - }, - ) - return await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization - ) - - async def get_hotkey_owner(ss58: str, block_hash_: str): - """Returns the coldkey owner of the passed hotkey.""" - if not await subtensor.does_hotkey_exist(ss58, block_hash=block_hash_): - return None - _result = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="Owner", - params=[ss58], - block_hash=block_hash_, - ) - return decode_account_id(_result[0]) - - async def get_stake_for_coldkey_and_hotkey( - hotkey_ss58: str, coldkey_ss58: str, block_hash_: str - ): - """Returns the stake under a coldkey - hotkey pairing.""" - _result = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="Stake", - params=[hotkey_ss58, coldkey_ss58], - block_hash=block_hash_, - ) - return Balance.from_rao(_result or 0) - - delegate_string = "delegate" if delegate else "undelegate" - - # Decrypt key - if not unlock_key(wallet).success: - return False - - print_verbose("Checking if hotkey is a delegate") - if not await subtensor.is_hotkey_delegate(delegate_ss58): - err_console.print(f"Hotkey: {delegate_ss58} is not a delegate.") - return False - - # Get state. - with console.status( - f":satellite: Syncing with [bold white]{subtensor}[/bold white] ...", - spinner="aesthetic", - ) as status: - print_verbose("Fetching balance, stake, and ownership", status) - initial_block_hash = await subtensor.substrate.get_chain_head() - ( - my_prev_coldkey_balance_, - delegate_owner, - my_prev_delegated_stake, - ) = await asyncio.gather( - subtensor.get_balance( - wallet.coldkey.ss58_address, block_hash=initial_block_hash - ), - get_hotkey_owner(delegate_ss58, block_hash_=initial_block_hash), - get_stake_for_coldkey_and_hotkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=delegate_ss58, - block_hash_=initial_block_hash, - ), - ) - - my_prev_coldkey_balance = my_prev_coldkey_balance_[wallet.coldkey.ss58_address] - - # Convert to bittensor.Balance - if amount is None: - # Stake it all. - if delegate_string == "delegate": - staking_balance = Balance.from_tao(my_prev_coldkey_balance.tao) - else: - # Unstake all - staking_balance = Balance.from_tao(my_prev_delegated_stake.tao) - else: - staking_balance = Balance.from_tao(amount) - - # Check enough balance to stake. - if delegate_string == "delegate" and staking_balance > my_prev_coldkey_balance: - err_console.print( - ":cross_mark: [red]Not enough balance to stake[/red]:\n" - f" [bold blue]current balance[/bold blue]:{my_prev_coldkey_balance}\n" - f" [bold red]amount staking[/bold red]: {staking_balance}\n" - f" [bold white]coldkey: {wallet.name}[/bold white]" - ) - return False - - if delegate_string == "undelegate" and ( - my_prev_delegated_stake is None or staking_balance > my_prev_delegated_stake - ): - err_console.print( - "\n:cross_mark: [red]Not enough balance to unstake[/red]:\n" - f" [bold blue]current stake[/bold blue]: {my_prev_delegated_stake}\n" - f" [bold red]amount unstaking[/bold red]: {staking_balance}\n" - f" [bold white]coldkey: {wallet.name}[bold white]\n\n" - ) - return False - - if delegate: - # Grab the existential deposit. - existential_deposit = await subtensor.get_existential_deposit() - - # Remove existential balance to keep key alive. - if staking_balance > my_prev_coldkey_balance - existential_deposit: - staking_balance = my_prev_coldkey_balance - existential_deposit - else: - staking_balance = staking_balance - - # Ask before moving on. - if prompt: - if not Confirm.ask( - f"\n[bold blue]Current stake[/bold blue]: [blue]{my_prev_delegated_stake}[/blue]\n" - f"[bold white]Do you want to {delegate_string}:[/bold white]\n" - f" [bold red]amount[/bold red]: [red]{staking_balance}\n[/red]" - f" [bold yellow]{'to' if delegate_string == 'delegate' else 'from'} hotkey[/bold yellow]: [yellow]{delegate_ss58}\n[/yellow]" - f" [bold green]hotkey owner[/bold green]: [green]{delegate_owner}[/green]" - ): - return False - - with console.status( - f":satellite: Staking to: [bold white]{subtensor}[/bold white] ...", - spinner="aesthetic", - ) as status: - print_verbose("Transmitting delegate operation call") - staking_response, err_msg = await _do_delegation(staking_balance) - - if staking_response is True: # If we successfully staked. - # We only wait here if we expect finalization. - if not wait_for_finalization and not wait_for_inclusion: - return True - - console.print(":white_heavy_check_mark: [green]Finalized[/green]\n") - with console.status( - f":satellite: Checking Balance on: [white]{subtensor}[/white] ...", - spinner="aesthetic", - ) as status: - print_verbose("Fetching balance and stakes", status) - block_hash = await subtensor.substrate.get_chain_head() - new_balance, new_delegate_stake = await asyncio.gather( - subtensor.get_balance( - wallet.coldkey.ss58_address, block_hash=block_hash - ), - get_stake_for_coldkey_and_hotkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=delegate_ss58, - block_hash_=block_hash, - ), - ) - - console.print( - "Balance:\n" - f" [blue]{my_prev_coldkey_balance}[/blue] :arrow_right: [green]{new_balance[wallet.coldkey.ss58_address]}[/green]\n" - "Stake:\n" - f" [blue]{my_prev_delegated_stake}[/blue] :arrow_right: [green]{new_delegate_stake}[/green]" - ) - return True - else: - err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - return False - - -async def nominate_extrinsic( - subtensor: SubtensorInterface, - wallet: Wallet, - wait_for_finalization: bool = False, - wait_for_inclusion: bool = True, -) -> bool: - """Becomes a delegate for the hotkey. - - :param wallet: The unlocked wallet to become a delegate for. - :param subtensor: The SubtensorInterface to use for the transaction - :param wait_for_finalization: Wait for finalization or not - :param wait_for_inclusion: Wait for inclusion or not - - :return: success - """ - with console.status( - ":satellite: Sending nominate call on [white]{}[/white] ...".format( - subtensor.network - ) - ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="become_delegate", - call_params={"hotkey": wallet.hotkey.ss58_address}, - ) - success, err_msg = await subtensor.sign_and_send_extrinsic( - call, - wallet, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) - - if success is True: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - - else: - err_console.print(f":cross_mark: [red]Failed[/red]: error:{err_msg}") - return success - - -# Commands - - -async def root_list(subtensor: SubtensorInterface): - """List the root network""" - - async def _get_list() -> tuple: - senate_query = await subtensor.substrate.query( - module="SenateMembers", - storage_function="Members", - params=None, - ) - sm = [decode_account_id(i[x][0]) for i in senate_query for x in range(len(i))] - - rn: list[NeuronInfoLite] = await subtensor.neurons_lite(netuid=0) - if not rn: - return [], [], {}, {} - - di: dict[str, DelegatesDetails] = await subtensor.get_delegate_identities() - ts: dict[str, ScaleType] = await subtensor.substrate.query_multiple( - [n.hotkey for n in rn], - module="SubtensorModule", - storage_function="TotalHotkeyStake", - reuse_block_hash=True, - ) - return sm, rn, di, ts - - with console.status( - f":satellite: Syncing with chain: [white]{subtensor}[/white] ...", - spinner="aesthetic", - ): - senate_members, root_neurons, delegate_info, total_stakes = await _get_list() - total_tao = sum( - float(Balance.from_rao(total_stakes[neuron.hotkey])) - for neuron in root_neurons - ) - - table = Table( - Column( - "[bold white]UID", - style="dark_orange", - no_wrap=True, - footer=f"[bold]{len(root_neurons)}[/bold]", - ), - Column( - "[bold white]NAME", - style="bright_cyan", - no_wrap=True, - ), - Column( - "[bold white]ADDRESS", - style="bright_magenta", - no_wrap=True, - ), - Column( - "[bold white]STAKE(\u03c4)", - justify="right", - style="light_goldenrod2", - no_wrap=True, - footer=f"{total_tao:.2f} (\u03c4) ", - ), - Column( - "[bold white]SENATOR", - style="dark_sea_green", - no_wrap=True, - ), - title=f"[underline dark_orange]Root Network[/underline dark_orange]\n[dark_orange]Network {subtensor.network}", - show_footer=True, - show_edge=False, - expand=False, - border_style="bright_black", - leading=True, - ) - - if not root_neurons: - err_console.print( - f"[red]Error: No neurons detected on the network:[/red] [white]{subtensor}" - ) - raise typer.Exit() - - sorted_root_neurons = sorted( - root_neurons, - key=lambda neuron: float(Balance.from_rao(total_stakes[neuron.hotkey])), - reverse=True, - ) - - for neuron_data in sorted_root_neurons: - table.add_row( - str(neuron_data.uid), - ( - delegate_info[neuron_data.hotkey].display - if neuron_data.hotkey in delegate_info - else "~" - ), - neuron_data.hotkey, - "{:.5f}".format(float(Balance.from_rao(total_stakes[neuron_data.hotkey]))), - "Yes" if neuron_data.hotkey in senate_members else "No", - ) - - return console.print(table) - - -async def set_weights( - wallet: Wallet, - subtensor: SubtensorInterface, - netuids: list[int], - weights: list[float], - prompt: bool, -): - """Set weights for root network.""" - netuids_ = np.array(netuids, dtype=np.int64) - weights_ = np.array(weights, dtype=np.float32) - console.print(f"Setting weights in [dark_orange]network: {subtensor.network}") - - # Run the set weights operation. - - await set_root_weights_extrinsic( - subtensor=subtensor, - wallet=wallet, - netuids=netuids_, - weights=weights_, - version_key=0, - prompt=prompt, - wait_for_finalization=True, - wait_for_inclusion=True, - ) - - -async def get_weights( - subtensor: SubtensorInterface, - limit_min_col: Optional[int], - limit_max_col: Optional[int], - reuse_last: bool, - html_output: bool, - no_cache: bool, -): - """Get weights for root network.""" - if not reuse_last: - with console.status( - ":satellite: Fetching weights from chain...", spinner="aesthetic" - ): - weights = await subtensor.weights(0) - - uid_to_weights: dict[int, dict] = {} - netuids = set() - for matrix in weights: - [uid, weights_data] = matrix - - if not len(weights_data): - uid_to_weights[uid] = {} - normalized_weights = [] - else: - normalized_weights = np.array(weights_data)[:, 1] / max( - np.sum(weights_data, axis=0)[1], 1 - ) - - for weight_data, normalized_weight in zip(weights_data, normalized_weights): - [netuid, _] = weight_data - netuids.add(netuid) - if uid not in uid_to_weights: - uid_to_weights[uid] = {} - - uid_to_weights[uid][netuid] = normalized_weight - rows: list[list[str]] = [] - sorted_netuids: list = list(netuids) - sorted_netuids.sort() - for uid in uid_to_weights: - row = [str(uid)] - - uid_weights = uid_to_weights[uid] - for netuid in sorted_netuids: - if netuid in uid_weights: - row.append("{:0.2f}%".format(uid_weights[netuid] * 100)) - else: - row.append("~") - rows.append(row) - - if not no_cache: - db_cols = [("UID", "INTEGER")] - for netuid in sorted_netuids: - db_cols.append((f"_{netuid}", "TEXT")) - create_table("rootgetweights", db_cols, rows) - update_metadata_table( - "rootgetweights", - {"rows": json.dumps(rows), "netuids": json.dumps(sorted_netuids)}, - ) - else: - metadata = get_metadata_table("rootgetweights") - rows = json.loads(metadata["rows"]) - sorted_netuids = json.loads(metadata["netuids"]) - - _min_lim = limit_min_col if limit_min_col is not None else 0 - _max_lim = limit_max_col + 1 if limit_max_col is not None else len(sorted_netuids) - _max_lim = min(_max_lim, len(sorted_netuids)) - - if _min_lim is not None and _min_lim > len(sorted_netuids): - err_console.print("Minimum limit greater than number of netuids") - return - - if not html_output: - table = Table( - show_footer=True, - box=None, - pad_edge=False, - width=None, - title="[white]Root Network Weights", - ) - table.add_column( - "[white]UID", - header_style="overline white", - footer_style="overline white", - style="rgb(50,163,219)", - no_wrap=True, - ) - for netuid in sorted_netuids[_min_lim:_max_lim]: - table.add_column( - f"[white]{netuid}", - header_style="overline white", - footer_style="overline white", - justify="right", - style="green", - no_wrap=True, - ) - - if not rows: - err_console.print("No weights exist on the root network.") - return - - # Adding rows - for row in rows: - new_row = [row[0]] + row[_min_lim + 1 : _max_lim + 1] - table.add_row(*new_row) - - return console.print(table) - - else: - html_cols = [{"title": "UID", "field": "UID"}] - for netuid in sorted_netuids[_min_lim:_max_lim]: - html_cols.append({"title": str(netuid), "field": f"_{netuid}"}) - render_table( - "rootgetweights", - "Root Network Weights", - html_cols, - ) - - -async def _get_my_weights( - subtensor: SubtensorInterface, ss58_address: str, my_uid: str -) -> NDArray[np.float32]: - """Retrieves the weight array for a given hotkey SS58 address.""" - - my_weights_, total_subnets_ = await asyncio.gather( - subtensor.substrate.query( - "SubtensorModule", "Weights", [0, my_uid], reuse_block_hash=True - ), - subtensor.substrate.query( - "SubtensorModule", "TotalNetworks", reuse_block_hash=True - ), - ) - # If setting weights for the first time, pass 0 root weights - my_weights: list[tuple[int, int]] = ( - my_weights_ if my_weights_ is not None else [(0, 0)] - ) - total_subnets: int = total_subnets_ - - print_verbose("Fetching current weights") - for _, w in enumerate(my_weights): - if w: - print_verbose(f"{w}") - - uids, values = zip(*my_weights) - weight_array = convert_weight_uids_and_vals_to_tensor(total_subnets, uids, values) - return weight_array - - -async def set_boost( - wallet: Wallet, - subtensor: SubtensorInterface, - netuid: int, - amount: float, - prompt: bool, -): - """Boosts weight of a given netuid for root network.""" - console.print(f"Boosting weights in [dark_orange]network: {subtensor.network}") - print_verbose(f"Fetching uid of hotkey on root: {wallet.hotkey_str}") - my_uid = await subtensor.substrate.query( - "SubtensorModule", "Uids", [0, wallet.hotkey.ss58_address] - ) - - if my_uid is None: - err_console.print("Your hotkey is not registered to the root network") - return False - - print_verbose("Fetching current weights") - my_weights = await _get_my_weights(subtensor, wallet.hotkey.ss58_address, my_uid) - prev_weights = my_weights.copy() - my_weights[netuid] += amount - all_netuids = np.arange(len(my_weights)) - - console.print( - f"Boosting weight for netuid {netuid}\n\tfrom {prev_weights[netuid]} to {my_weights[netuid]}\n" - ) - console.print( - f"Previous weights -> Raw weights: \n\t{prev_weights} -> \n\t{my_weights}" - ) - - print_verbose(f"All netuids: {all_netuids}") - await set_root_weights_extrinsic( - subtensor=subtensor, - wallet=wallet, - netuids=all_netuids, - weights=my_weights, - version_key=0, - wait_for_inclusion=True, - wait_for_finalization=True, - prompt=prompt, - ) - - -async def set_slash( - wallet: Wallet, - subtensor: SubtensorInterface, - netuid: int, - amount: float, - prompt: bool, -): - """Slashes weight""" - console.print(f"Slashing weights in [dark_orange]network: {subtensor.network}") - print_verbose(f"Fetching uid of hotkey on root: {wallet.hotkey_str}") - my_uid = await subtensor.substrate.query( - "SubtensorModule", "Uids", [0, wallet.hotkey.ss58_address] - ) - if my_uid is None: - err_console.print("Your hotkey is not registered to the root network") - return False - - print_verbose("Fetching current weights") - my_weights = await _get_my_weights(subtensor, wallet.hotkey.ss58_address, my_uid) - prev_weights = my_weights.copy() - my_weights[netuid] -= amount - my_weights[my_weights < 0] = 0 # Ensure weights don't go negative - all_netuids = np.arange(len(my_weights)) - - console.print( - f"Slashing weight for netuid {netuid}\n\tfrom {prev_weights[netuid]} to {my_weights[netuid]}\n" - ) - console.print( - f"Previous weights -> Raw weights: \n\t{prev_weights} -> \n\t{my_weights}" - ) - - await set_root_weights_extrinsic( - subtensor=subtensor, - wallet=wallet, - netuids=all_netuids, - weights=my_weights, - version_key=0, - wait_for_inclusion=True, - wait_for_finalization=True, - prompt=prompt, - ) - - -async def senate_vote( - wallet: Wallet, - subtensor: SubtensorInterface, - proposal_hash: str, - vote: bool, - prompt: bool, -) -> bool: - """Vote in Bittensor's governance protocol proposals""" - - if not proposal_hash: - err_console.print( - "Aborting: Proposal hash not specified. View all proposals with the `proposals` command." - ) - return False - elif not _validate_proposal_hash(proposal_hash): - err_console.print( - "Aborting. Proposal hash is invalid. Proposal hashes should start with '0x' and be 32 bytes long" - ) - return False - - print_verbose(f"Fetching senate status of {wallet.hotkey_str}") - if not await _is_senate_member(subtensor, hotkey_ss58=wallet.hotkey.ss58_address): - err_console.print( - f"Aborting: Hotkey {wallet.hotkey.ss58_address} isn't a senate member." - ) - return False - - # Unlock the wallet. - if not unlock_key(wallet).success and unlock_key(wallet, "hot").success: - return False - - console.print(f"Fetching proposals in [dark_orange]network: {subtensor.network}") - vote_data = await subtensor.get_vote_data(proposal_hash, reuse_block=True) - if not vote_data: - err_console.print(":cross_mark: [red]Failed[/red]: Proposal not found.") - return False - - success = await vote_senate_extrinsic( - subtensor=subtensor, - wallet=wallet, - proposal_hash=proposal_hash, - proposal_idx=vote_data.index, - vote=vote, - wait_for_inclusion=True, - wait_for_finalization=False, - prompt=prompt, - ) - - return success - - -async def get_senate(subtensor: SubtensorInterface): - """View Bittensor's governance protocol proposals""" - with console.status( - f":satellite: Syncing with chain: [white]{subtensor}[/white] ...", - spinner="aesthetic", - ) as status: - print_verbose("Fetching senate members", status) - senate_members = await _get_senate_members(subtensor) - - print_verbose("Fetching member details from Github") - delegate_info: dict[ - str, DelegatesDetails - ] = await subtensor.get_delegate_identities() - - table = Table( - Column( - "[bold white]NAME", - style="bright_cyan", - no_wrap=True, - ), - Column( - "[bold white]ADDRESS", - style="bright_magenta", - no_wrap=True, - ), - title=f"[underline dark_orange]Senate[/underline dark_orange]\n[dark_orange]Network: {subtensor.network}\n", - show_footer=True, - show_edge=False, - expand=False, - border_style="bright_black", - leading=True, - ) - - for ss58_address in senate_members: - table.add_row( - ( - delegate_info[ss58_address].display - if ss58_address in delegate_info - else "~" - ), - ss58_address, - ) - - return console.print(table) - - -async def register(wallet: Wallet, subtensor: SubtensorInterface, prompt: bool): - """Register neuron by recycling some TAO.""" - - console.print( - f"Registering on [dark_orange]netuid 0[/dark_orange] on network: [dark_orange]{subtensor.network}" - ) - - # Check current recycle amount - print_verbose("Fetching recycle amount & balance") - recycle_call, balance_ = await asyncio.gather( - subtensor.get_hyperparameter(param_name="Burn", netuid=0, reuse_block=True), - subtensor.get_balance(wallet.coldkeypub.ss58_address, reuse_block=True), - ) - current_recycle = Balance.from_rao(int(recycle_call)) - try: - balance: Balance = balance_[wallet.coldkeypub.ss58_address] - except TypeError as e: - err_console.print(f"Unable to retrieve current recycle. {e}") - return False - except KeyError: - err_console.print("Unable to retrieve current balance.") - return False - - # Check balance is sufficient - if balance < current_recycle: - err_console.print( - f"[red]Insufficient balance {balance} to register neuron. " - f"Current recycle is {current_recycle} TAO[/red]" - ) - return False - - if prompt: - if not Confirm.ask( - f"Your balance is: [bold green]{balance}[/bold green]\n" - f"The cost to register by recycle is [bold red]{current_recycle}[/bold red]\n" - f"Do you want to continue?", - default=False, - ): - return False - - await root_register_extrinsic( - subtensor, - wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - prompt=prompt, - ) - - -async def proposals(subtensor: SubtensorInterface, verbose: bool): - console.print( - ":satellite: Syncing with chain: [white]{}[/white] ...".format( - subtensor.network - ) - ) - block_hash = await subtensor.substrate.get_chain_head() - senate_members, all_proposals, current_block = await asyncio.gather( - _get_senate_members(subtensor, block_hash), - _get_proposals(subtensor, block_hash), - subtensor.substrate.get_block_number(block_hash), - ) - - registered_delegate_info: dict[ - str, DelegatesDetails - ] = await subtensor.get_delegate_identities() - - title = ( - f"[bold #4196D6]Bittensor Governance Proposals[/bold #4196D6]\n" - f"[steel_blue3]Current Block:[/steel_blue3] {current_block}\t" - f"[steel_blue3]Network:[/steel_blue3] {subtensor.network}\n\n" - f"[steel_blue3]Active Proposals:[/steel_blue3] {len(all_proposals)}\t" - f"[steel_blue3]Senate Size:[/steel_blue3] {len(senate_members)}\n" - ) - table = Table( - Column( - "[white]HASH", - style="light_goldenrod2", - no_wrap=True, - ), - Column("[white]THRESHOLD", style="rgb(42,161,152)"), - Column("[white]AYES", style="green"), - Column("[white]NAYS", style="red"), - Column( - "[white]VOTES", - style="rgb(50,163,219)", - ), - Column("[white]END", style="bright_cyan"), - Column("[white]CALLDATA", style="dark_sea_green", width=30), - title=title, - show_footer=True, - box=box.SIMPLE_HEAVY, - pad_edge=False, - width=None, - border_style="bright_black", - ) - for hash_, (call_data, vote_data) in all_proposals.items(): - blocks_remaining = vote_data.end - current_block - if blocks_remaining > 0: - duration_str = blocks_to_duration(blocks_remaining) - vote_end_cell = f"{vote_data.end} [dim](in {duration_str})[/dim]" - else: - vote_end_cell = f"{vote_data.end} [red](expired)[/red]" - - ayes_threshold = ( - (len(vote_data.ayes) / vote_data.threshold * 100) - if vote_data.threshold > 0 - else 0 - ) - nays_threshold = ( - (len(vote_data.nays) / vote_data.threshold * 100) - if vote_data.threshold > 0 - else 0 - ) - table.add_row( - hash_ if verbose else f"{hash_[:4]}...{hash_[-4:]}", - str(vote_data.threshold), - f"{len(vote_data.ayes)} ({ayes_threshold:.2f}%)", - f"{len(vote_data.nays)} ({nays_threshold:.2f}%)", - display_votes(vote_data, registered_delegate_info), - vote_end_cell, - format_call_data(call_data), - ) - console.print(table) - console.print( - "\n[dim]* Both Ayes and Nays percentages are calculated relative to the proposal's threshold.[/dim]" - ) - - -async def set_take(wallet: Wallet, subtensor: SubtensorInterface, take: float) -> bool: - """Set delegate take.""" - - async def _do_set_take() -> bool: - """ - Just more easily allows an early return and to close the substrate interface after the logic - """ - print_verbose("Checking if hotkey is a delegate") - # Check if the hotkey is not a delegate. - if not await subtensor.is_hotkey_delegate(wallet.hotkey.ss58_address): - err_console.print( - f"Aborting: Hotkey {wallet.hotkey.ss58_address} is NOT a delegate." - ) - return False - - if take > 0.18 or take < 0: - err_console.print("ERROR: Take value should not exceed 18% or be below 0%") - return False - - result: bool = await set_take_extrinsic( - subtensor=subtensor, - wallet=wallet, - delegate_ss58=wallet.hotkey.ss58_address, - take=take, - ) - - if not result: - err_console.print("Could not set the take") - return False - else: - # Check if we are a delegate. - is_delegate: bool = await subtensor.is_hotkey_delegate( - wallet.hotkey.ss58_address - ) - if not is_delegate: - err_console.print( - "Could not set the take [white]{}[/white]".format(subtensor.network) - ) - return False - else: - console.print( - "Successfully set the take on [white]{}[/white]".format( - subtensor.network - ) - ) - return True - - console.print(f"Setting take on [dark_orange]network: {subtensor.network}") - # Unlock the wallet. - if not unlock_key(wallet).success and unlock_key(wallet, "hot").success: - return False - - result_ = await _do_set_take() - - return result_ - - -async def delegate_stake( - wallet: Wallet, - subtensor: SubtensorInterface, - amount: Optional[float], - delegate_ss58key: str, - prompt: bool, -): - """Delegates stake to a chain delegate.""" - console.print(f"Delegating stake on [dark_orange]network: {subtensor.network}") - await delegate_extrinsic( - subtensor, - wallet, - delegate_ss58key, - amount, - wait_for_inclusion=True, - prompt=prompt, - delegate=True, - ) - - -async def delegate_unstake( - wallet: Wallet, - subtensor: SubtensorInterface, - amount: Optional[float], - delegate_ss58key: str, - prompt: bool, -): - """Undelegates stake from a chain delegate.""" - console.print(f"Undelegating stake on [dark_orange]network: {subtensor.network}") - await delegate_extrinsic( - subtensor, - wallet, - delegate_ss58key, - amount, - wait_for_inclusion=True, - prompt=prompt, - delegate=False, - ) - - -async def my_delegates( - wallet: Wallet, subtensor: SubtensorInterface, all_wallets: bool -): - """Delegates stake to a chain delegate.""" - - async def wallet_to_delegates( - w: Wallet, bh: str - ) -> tuple[Optional[Wallet], Optional[list[tuple[DelegateInfo, Balance]]]]: - """Helper function to retrieve the validity of the wallet (if it has a coldkeypub on the device) - and its delegate info.""" - if not w.coldkeypub_file.exists_on_device(): - return None, None - else: - delegates_ = await subtensor.get_delegated( - w.coldkeypub.ss58_address, block_hash=bh - ) - return w, delegates_ - - wallets = get_coldkey_wallets_for_path(wallet.path) if all_wallets else [wallet] - - table = Table( - Column("[white]Wallet", style="bright_cyan"), - Column( - "[white]OWNER", - style="bold bright_cyan", - overflow="fold", - justify="left", - ratio=1, - ), - Column( - "[white]SS58", - style="bright_magenta", - justify="left", - overflow="fold", - ratio=3, - ), - Column("[white]Delegation", style="dark_orange", no_wrap=True, ratio=1), - Column("[white]\u03c4/24h", style="bold green", ratio=1), - Column( - "[white]NOMS", - justify="center", - style="rgb(42,161,152)", - no_wrap=True, - ratio=1, - ), - Column( - "[white]OWNER STAKE(\u03c4)", - justify="right", - style="light_goldenrod2", - no_wrap=True, - ratio=1, - ), - Column( - "[white]TOTAL STAKE(\u03c4)", - justify="right", - style="light_goldenrod2", - no_wrap=True, - ratio=1, - ), - Column("[white]SUBNETS", justify="right", style="white", ratio=1), - Column("[white]VPERMIT", justify="right"), - Column( - "[white]24h/k\u03c4", style="rgb(42,161,152)", justify="center", ratio=1 - ), - Column("[white]Desc", style="rgb(50,163,219)", ratio=3), - title=f"[underline dark_orange]My Delegates[/underline dark_orange]\n[dark_orange]Network: {subtensor.network}\n", - show_footer=True, - show_edge=False, - expand=False, - box=box.SIMPLE_HEAVY, - border_style="bright_black", - leading=True, - ) - - total_delegated = 0 - - # TODO: this doesnt work when passed to wallets_with_delegates - # block_hash = await subtensor.substrate.get_chain_head() - - registered_delegate_info: dict[str, DelegatesDetails] - wallets_with_delegates: tuple[ - tuple[Optional[Wallet], Optional[list[tuple[DelegateInfo, Balance]]]] - ] - - print_verbose("Fetching delegate information") - wallets_with_delegates, registered_delegate_info = await asyncio.gather( - asyncio.gather(*[wallet_to_delegates(wallet_, None) for wallet_ in wallets]), - subtensor.get_delegate_identities(), - ) - if not registered_delegate_info: - console.print( - ":warning:[yellow]Could not get delegate info from chain.[/yellow]" - ) - - print_verbose("Processing delegate information") - for wall, delegates in wallets_with_delegates: - if not wall or not delegates: - continue - - my_delegates_ = {} # hotkey, amount - for delegate in delegates: - for coldkey_addr, staked in delegate[0].nominators: - if coldkey_addr == wall.coldkeypub.ss58_address and staked.tao > 0: - my_delegates_[delegate[0].hotkey_ss58] = staked - - delegates.sort(key=lambda d: d[0].total_stake, reverse=True) - total_delegated += sum(my_delegates_.values()) - - for i, delegate in enumerate(delegates): - owner_stake = next( - ( - stake - for owner, stake in delegate[0].nominators - if owner == delegate[0].owner_ss58 - ), - Balance.from_rao(0), # default to 0 if no owner stake. - ) - if delegate[0].hotkey_ss58 in registered_delegate_info: - delegate_name = registered_delegate_info[ - delegate[0].hotkey_ss58 - ].display - delegate_url = registered_delegate_info[delegate[0].hotkey_ss58].web - delegate_description = registered_delegate_info[ - delegate[0].hotkey_ss58 - ].additional - else: - delegate_name = "~" - delegate_url = "" - delegate_description = "" - - if delegate[0].hotkey_ss58 in my_delegates_: - twenty_four_hour = delegate[0].total_daily_return.tao * ( - my_delegates_[delegate[0].hotkey_ss58] / delegate[0].total_stake.tao - ) - table.add_row( - wall.name, - Text(delegate_name, style=f"link {delegate_url}"), - f"{delegate[0].hotkey_ss58}", - f"{my_delegates_[delegate[0].hotkey_ss58]!s:13.13}", - f"{twenty_four_hour!s:6.6}", - str(len(delegate[0].nominators)), - f"{owner_stake!s:13.13}", - f"{delegate[0].total_stake!s:13.13}", - group_subnets(delegate[0].registrations), - group_subnets(delegate[0].validator_permits), - f"{delegate[0].total_daily_return.tao * (1000 / (0.001 + delegate[0].total_stake.tao))!s:6.6}", - str(delegate_description), - ) - if console.width < 150: - console.print( - "[yellow]Warning: Your terminal width might be too small to view all the information clearly" - ) - console.print(table) - console.print(f"Total delegated TAO: {total_delegated}") - - -async def list_delegates(subtensor: SubtensorInterface): - """List all delegates on the network.""" - - with console.status( - ":satellite: Loading delegates...", spinner="aesthetic" - ) as status: - print_verbose("Fetching delegate details from chain", status) - block_hash = await subtensor.substrate.get_chain_head() - registered_delegate_info, block_number, delegates = await asyncio.gather( - subtensor.get_delegate_identities(block_hash=block_hash), - subtensor.substrate.get_block_number(block_hash), - subtensor.get_delegates(block_hash=block_hash), - ) - - print_verbose("Fetching previous delegates info from chain", status) - - async def get_prev_delegates(fallback_offsets=(1200, 200)): - for offset in fallback_offsets: - try: - prev_block_hash = await subtensor.substrate.get_block_hash( - max(0, block_number - offset) - ) - return await subtensor.get_delegates(block_hash=prev_block_hash) - except SubstrateRequestException: - continue - return None - - prev_delegates = await get_prev_delegates() - - if prev_delegates is None: - err_console.print( - ":warning: [yellow]Could not fetch delegates history. [/yellow]" - ) - - delegates.sort(key=lambda d: d.total_stake, reverse=True) - prev_delegates_dict = {} - if prev_delegates is not None: - for prev_delegate in prev_delegates: - prev_delegates_dict[prev_delegate.hotkey_ss58] = prev_delegate - - if not registered_delegate_info: - console.print( - ":warning:[yellow]Could not get delegate info from chain.[/yellow]" - ) - table = Table( - Column( - "[white]INDEX\n\n", - str(len(delegates)), - style="bold white", - ), - Column( - "[white]DELEGATE\n\n", - style="bold bright_cyan", - justify="left", - overflow="fold", - ratio=1, - ), - Column( - "[white]SS58\n\n", - style="bright_magenta", - no_wrap=False, - overflow="fold", - ratio=2, - ), - Column( - "[white]NOMINATORS\n\n", - justify="center", - style="gold1", - no_wrap=True, - ratio=1, - ), - Column( - "[white]OWN STAKE\n(\u03c4)\n", - justify="right", - style="orange1", - no_wrap=True, - ratio=1, - ), - Column( - "[white]TOTAL STAKE\n(\u03c4)\n", - justify="right", - style="light_goldenrod2", - no_wrap=True, - ratio=1, - ), - Column("[white]CHANGE\n/(4h)\n", style="grey0", justify="center", ratio=1), - Column("[white]TAKE\n\n", style="white", no_wrap=True, ratio=1), - Column( - "[white]NOMINATOR\n/(24h)/k\u03c4\n", - style="dark_olive_green3", - justify="center", - ratio=1, - ), - Column( - "[white]DELEGATE\n/(24h)\n", - style="dark_olive_green3", - justify="center", - ratio=1, - ), - Column( - "[white]VPERMIT\n\n", - justify="center", - no_wrap=False, - max_width=20, - style="dark_sea_green", - ratio=2, - ), - Column("[white]Desc\n\n", style="rgb(50,163,219)", max_width=30, ratio=2), - title=f"[underline dark_orange]Root Delegates[/underline dark_orange]\n[dark_orange]Network: {subtensor.network}\n", - show_footer=True, - pad_edge=False, - box=None, - ) - - for i, delegate in enumerate(delegates): - owner_stake = next( - ( - stake - for owner, stake in delegate.nominators - if owner == delegate.owner_ss58 - ), - Balance.from_rao(0), # default to 0 if no owner stake. - ) - if delegate.hotkey_ss58 in registered_delegate_info: - delegate_name = registered_delegate_info[delegate.hotkey_ss58].display - delegate_url = registered_delegate_info[delegate.hotkey_ss58].web - delegate_description = registered_delegate_info[ - delegate.hotkey_ss58 - ].additional - else: - delegate_name = "~" - delegate_url = "" - delegate_description = "" - - if delegate.hotkey_ss58 in prev_delegates_dict: - prev_stake = prev_delegates_dict[delegate.hotkey_ss58].total_stake - if prev_stake == 0: - if delegate.total_stake > 0: - rate_change_in_stake_str = "[green]100%[/green]" - else: - rate_change_in_stake_str = "[grey0]0%[/grey0]" - else: - rate_change_in_stake = ( - 100 - * (float(delegate.total_stake) - float(prev_stake)) - / float(prev_stake) - ) - if rate_change_in_stake > 0: - rate_change_in_stake_str = "[green]{:.2f}%[/green]".format( - rate_change_in_stake - ) - elif rate_change_in_stake < 0: - rate_change_in_stake_str = "[red]{:.2f}%[/red]".format( - rate_change_in_stake - ) - else: - rate_change_in_stake_str = "[grey0]0%[/grey0]" - else: - rate_change_in_stake_str = "[grey0]NA[/grey0]" - table.add_row( - # INDEX - str(i), - # DELEGATE - Text(delegate_name, style=f"link {delegate_url}"), - # SS58 - f"{delegate.hotkey_ss58}", - # NOMINATORS - str(len([nom for nom in delegate.nominators if nom[1].rao > 0])), - # DELEGATE STAKE - f"{owner_stake!s:13.13}", - # TOTAL STAKE - f"{delegate.total_stake!s:13.13}", - # CHANGE/(4h) - rate_change_in_stake_str, - # TAKE - f"{delegate.take * 100:.1f}%", - # NOMINATOR/(24h)/k - f"{Balance.from_tao(delegate.total_daily_return.tao * (1000 / (0.001 + delegate.total_stake.tao)))!s:6.6}", - # DELEGATE/(24h) - f"{Balance.from_tao(delegate.total_daily_return.tao * 0.18) !s:6.6}", - # VPERMIT - str(group_subnets(delegate.registrations)), - # Desc - str(delegate_description), - end_section=True, - ) - console.print(table) - - -async def nominate(wallet: Wallet, subtensor: SubtensorInterface, prompt: bool): - """Nominate wallet.""" - - console.print(f"Nominating on [dark_orange]network: {subtensor.network}") - # Unlock the wallet. - if not unlock_key(wallet).success and unlock_key(wallet, "hot").success: - return False - - print_verbose(f"Checking hotkey ({wallet.hotkey_str}) is a delegate") - # Check if the hotkey is already a delegate. - if await subtensor.is_hotkey_delegate(wallet.hotkey.ss58_address): - err_console.print( - f"Aborting: Hotkey {wallet.hotkey.ss58_address} is already a delegate." - ) - return - - print_verbose("Nominating hotkey as a delegate") - result: bool = await nominate_extrinsic(subtensor, wallet) - if not result: - err_console.print( - f"Could not became a delegate on [white]{subtensor.network}[/white]" - ) - return - else: - # Check if we are a delegate. - print_verbose("Confirming delegate status") - is_delegate: bool = await subtensor.is_hotkey_delegate( - wallet.hotkey.ss58_address - ) - if not is_delegate: - err_console.print( - f"Could not became a delegate on [white]{subtensor.network}[/white]" - ) - return - console.print( - f"Successfully became a delegate on [white]{subtensor.network}[/white]" - ) - - # Prompt use to set identity on chain. - if prompt: - do_set_identity = Confirm.ask("Would you like to set your identity? [y/n]") - - if do_set_identity: - id_prompts = set_id_prompts(validator=True) - await set_id(wallet, subtensor, *id_prompts, prompt=prompt) diff --git a/bittensor_cli/src/commands/stake/__init__.py b/bittensor_cli/src/commands/stake/__init__.py index e69de29b..d1dceeab 100644 --- a/bittensor_cli/src/commands/stake/__init__.py +++ b/bittensor_cli/src/commands/stake/__init__.py @@ -0,0 +1,154 @@ +from typing import Optional, TYPE_CHECKING + +import rich.prompt +from rich.table import Table + +from bittensor_cli.src.bittensor.chain_data import DelegateInfoLite +from bittensor_cli.src.bittensor.utils import console + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def select_delegate(subtensor: "SubtensorInterface", netuid: int): + # Get a list of delegates and sort them by total stake in descending order + delegates: list[DelegateInfoLite] = ( + await subtensor.get_delegates_by_netuid_light(netuid) + ).sort(key=lambda x: x.total_stake, reverse=True) + + # Get registered delegates details. + registered_delegate_info = await subtensor.get_delegate_identities() + + # Create a table to display delegate information + table = Table( + show_header=True, + header_style="bold", + border_style="rgb(7,54,66)", + style="rgb(0,43,54)", + ) + + # Add columns to the table with specific styles + table.add_column("Index", style="rgb(253,246,227)", no_wrap=True) + table.add_column("Delegate Name", no_wrap=True) + table.add_column("Hotkey SS58", style="rgb(211,54,130)", no_wrap=True) + table.add_column("Owner SS58", style="rgb(133,153,0)", no_wrap=True) + table.add_column("Take", style="rgb(181,137,0)", no_wrap=True) + table.add_column( + "Total Stake", style="rgb(38,139,210)", no_wrap=True, justify="right" + ) + table.add_column( + "Owner Stake", style="rgb(220,50,47)", no_wrap=True, justify="right" + ) + # table.add_column("Return per 1000", style="rgb(108,113,196)", no_wrap=True, justify="right") + # table.add_column("Total Daily Return", style="rgb(42,161,152)", no_wrap=True, justify="right") + + # List to store visible delegates + visible_delegates = [] + + def get_user_input() -> str: + return rich.prompt.Prompt.ask( + 'Press Enter to scroll, enter a number (1-N) to select, or type "h" for help: ', + choices=["", "h"] + [str(x) for x in range(1, len(delegates) - 1)], + show_choices=True, + ) + + # TODO: Add pagination to handle large number of delegates more efficiently + # Iterate through delegates and display their information + + def loop_selections() -> Optional[int]: + idx = 0 + selected_idx = None + while idx < len(delegates): + if idx < len(delegates): + delegate = delegates[idx] + + # Add delegate to visible list + visible_delegates.append(delegate) + + # Add a row to the table with delegate information + table.add_row( + str(idx), + registered_delegate_info[delegate.hotkey_ss58].name + if delegate.hotkey_ss58 in registered_delegate_info + else "", + delegate.hotkey_ss58[:5] + + "..." + + delegate.hotkey_ss58[-5:], # Show truncated hotkey + delegate.owner_ss58[:5] + + "..." + + delegate.owner_ss58[-5:], # Show truncated owner address + f"{delegate.take:.6f}", + f"τ{delegate.total_stake.tao:,.4f}", + f"τ{delegate.owner_stake.tao:,.4f}", + # f"τ{delegate.return_per_1000.tao:,.4f}", + # f"τ{delegate.total_daily_return.tao:,.4f}", + ) + + # Clear console and print updated table + console.clear() + console.print(table) + + # Prompt user for input + user_input: str = get_user_input() + + # Add a help option to display information about each column + if user_input == "h": + console.print("\nColumn Information:") + console.print( + "[rgb(253,246,227)]Index:[/rgb(253,246,227)] Position in the list of delegates" + ) + console.print( + "[rgb(211,54,130)]Hotkey SS58:[/rgb(211,54,130)] Truncated public key of the delegate's hotkey" + ) + console.print( + "[rgb(133,153,0)]Owner SS58:[/rgb(133,153,0)] Truncated public key of the delegate's owner" + ) + console.print( + "[rgb(181,137,0)]Take:[/rgb(181,137,0)] Percentage of rewards the delegate takes" + ) + console.print( + "[rgb(38,139,210)]Total Stake:[/rgb(38,139,210)] Total amount staked to this delegate" + ) + console.print( + "[rgb(220,50,47)]Owner Stake:[/rgb(220,50,47)] Amount staked by the delegate owner" + ) + console.print( + "[rgb(108,113,196)]Return per 1000:[/rgb(108,113,196)] Estimated return for 1000 Tao staked" + ) + console.print( + "[rgb(42,161,152)]Total Daily Return:[/rgb(42,161,152)] Estimated total daily return for all stake" + ) + user_input = get_user_input() + + # If user presses Enter, continue to next delegate + if user_input and user_input != "h": + selected_idx = int(user_input) + break + + if idx < len(delegates): + idx += 1 + + return selected_idx + + # TODO( const ): uncomment for check + # Add a confirmation step before returning the selected delegate + # console.print(f"\nSelected delegate: [rgb(211,54,130)]{visible_delegates[selected_idx].hotkey_ss58}[/rgb(211,54,130)]") + # console.print(f"Take: [rgb(181,137,0)]{visible_delegates[selected_idx].take:.6f}[/rgb(181,137,0)]") + # console.print(f"Total Stake: [rgb(38,139,210)]{visible_delegates[selected_idx].total_stake}[/rgb(38,139,210)]") + + # confirmation = Prompt.ask("Do you want to proceed with this delegate? (y/n)") + # if confirmation.lower() != 'yes' and confirmation.lower() != 'y': + # return select_delegate( subtensor, netuid ) + + # Return the selected delegate + while True: + selected_idx_ = loop_selections() + if selected_idx_ is None: + if not rich.prompt.Confirm.ask( + "You've reached the end of the list. You must make a selection. Loop through again?" + ): + raise IndexError + else: + continue + else: + return delegates[selected_idx_] diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py new file mode 100644 index 00000000..d400ce96 --- /dev/null +++ b/bittensor_cli/src/commands/stake/add.py @@ -0,0 +1,625 @@ +import asyncio +from functools import partial + +import typer +from typing import TYPE_CHECKING, Optional +from rich.table import Table +from rich.prompt import Confirm, Prompt + +from async_substrate_interface.errors import SubstrateRequestException +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, + format_error_message, + get_hotkey_wallets_for_wallet, + is_valid_ss58_address, + print_error, + print_verbose, +) +from bittensor_wallet import Wallet +from bittensor_wallet.errors import KeyFileError + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +# Command +async def stake_add( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: Optional[int], + stake_all: bool, + amount: float, + prompt: bool, + all_hotkeys: bool, + include_hotkeys: list[str], + exclude_hotkeys: list[str], + safe_staking: bool, + rate_tolerance: float, + allow_partial_stake: bool, +): + """ + Args: + wallet: wallet object + subtensor: SubtensorInterface object + netuid: the netuid to stake to (None indicates all subnets) + stake_all: whether to stake all available balance + amount: specified amount of balance to stake + delegate: whether to delegate stake, currently unused + prompt: whether to prompt the user + max_stake: maximum amount to stake (used in combination with stake_all), currently unused + all_hotkeys: whether to stake all hotkeys + include_hotkeys: list of hotkeys to include in staking process (if not specifying `--all`) + exclude_hotkeys: list of hotkeys to exclude in staking (if specifying `--all`) + safe_staking: whether to use safe staking + rate_tolerance: rate tolerance percentage for stake operations + allow_partial_stake: whether to allow partial stake + + Returns: + bool: True if stake operation is successful, False otherwise + """ + + async def safe_stake_extrinsic( + netuid: int, + amount: Balance, + current_stake: Balance, + hotkey_ss58: str, + price_limit: Balance, + wallet: Wallet, + subtensor: "SubtensorInterface", + status=None, + ) -> None: + err_out = partial(print_error, status=status) + failure_prelude = ( + f":cross_mark: [red]Failed[/red] to stake {amount} on Netuid {netuid}" + ) + current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + next_nonce = await subtensor.substrate.get_account_next_index( + wallet.coldkeypub.ss58_address + ) + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake_limit", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount_staked": amount.rao, + "limit_price": price_limit, + "allow_partial": allow_partial_stake, + }, + ) + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey, nonce=next_nonce + ) + try: + response = await subtensor.substrate.submit_extrinsic( + extrinsic, wait_for_inclusion=True, wait_for_finalization=False + ) + except SubstrateRequestException as e: + if "Custom error: 8" in str(e): + print_error( + f"\n{failure_prelude}: Price exceeded tolerance limit. " + f"Transaction rejected because partial staking is disabled. " + f"Either increase price tolerance or enable partial staking.", + status=status, + ) + return + else: + err_out( + f"\n{failure_prelude} with error: {format_error_message(e)}" + ) + return + else: + await response.process_events() + if not await response.is_success: + err_out( + f"\n{failure_prelude} with error: {format_error_message(await response.error_message, subtensor.substrate)}" + ) + else: + block_hash = await subtensor.substrate.get_chain_head() + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), + subtensor.get_stake( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + block_hash=block_hash, + ), + ) + console.print( + f":white_heavy_check_mark: [dark_sea_green3]Finalized. Stake added to netuid: {netuid}[/dark_sea_green3]" + ) + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + + amount_staked = current_balance - new_balance + if allow_partial_stake and (amount_staked != amount): + console.print( + "Partial stake transaction. Staked:\n" + f" [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_staked}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"instead of " + f"[blue]{amount}[/blue]" + ) + + console.print( + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"Stake:\n" + f" [blue]{current_stake}[/blue] " + f":arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}\n" + ) + + async def stake_extrinsic( + netuid_i, amount_, current, staking_address_ss58, status=None + ): + err_out = partial(print_error, status=status) + current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + failure_prelude = ( + f":cross_mark: [red]Failed[/red] to stake {amount} on Netuid {netuid_i}" + ) + next_nonce = await subtensor.substrate.get_account_next_index( + wallet.coldkeypub.ss58_address + ) + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake", + call_params={ + "hotkey": staking_address_ss58, + "netuid": netuid_i, + "amount_staked": amount_.rao, + }, + ) + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey, nonce=next_nonce + ) + try: + response = await subtensor.substrate.submit_extrinsic( + extrinsic, wait_for_inclusion=True, wait_for_finalization=False + ) + except SubstrateRequestException as e: + err_out( + f"\n{failure_prelude} with error: {format_error_message(e)}" + ) + return + else: + await response.process_events() + if not await response.is_success: + err_out( + f"\n{failure_prelude} with error: {format_error_message(await response.error_message, subtensor.substrate)}" + ) + else: + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_stake( + hotkey_ss58=staking_address_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid_i, + ), + ) + console.print( + f":white_heavy_check_mark: [dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" + ) + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + console.print( + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid_i}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"Stake:\n" + f" [blue]{current}[/blue] " + f":arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}\n" + ) + + netuids = ( + [int(netuid)] + if netuid is not None + else await subtensor.get_all_subnet_netuids() + ) + + hotkeys_to_stake_to = _get_hotkeys_to_stake_to( + wallet=wallet, + all_hotkeys=all_hotkeys, + include_hotkeys=include_hotkeys, + exclude_hotkeys=exclude_hotkeys, + ) + + # Get subnet data and stake information for coldkey + chain_head = await subtensor.substrate.get_chain_head() + _all_subnets, _stake_info, current_wallet_balance = await asyncio.gather( + subtensor.all_subnets(), + subtensor.get_stake_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address, + block_hash=chain_head, + ), + subtensor.get_balance(wallet.coldkeypub.ss58_address), + ) + all_subnets = {di.netuid: di for di in _all_subnets} + + # Map current stake balances for hotkeys + hotkey_stake_map = {} + for _, hotkey_ss58 in hotkeys_to_stake_to: + hotkey_stake_map[hotkey_ss58] = {} + for netuid in netuids: + hotkey_stake_map[hotkey_ss58][netuid] = Balance.from_rao(0) + + for stake_info in _stake_info: + if stake_info.hotkey_ss58 in hotkey_stake_map: + hotkey_stake_map[stake_info.hotkey_ss58][stake_info.netuid] = ( + stake_info.stake + ) + + # Determine the amount we are staking. + rows = [] + amounts_to_stake = [] + current_stake_balances = [] + prices_with_tolerance = [] + remaining_wallet_balance = current_wallet_balance + max_slippage = 0.0 + + for hotkey in hotkeys_to_stake_to: + for netuid in netuids: + # Check that the subnet exists. + subnet_info = all_subnets.get(netuid) + if not subnet_info: + err_console.print(f"Subnet with netuid: {netuid} does not exist.") + continue + current_stake_balances.append(hotkey_stake_map[hotkey[1]][netuid]) + + # Get the amount. + amount_to_stake = Balance(0) + if amount: + amount_to_stake = Balance.from_tao(amount) + elif stake_all: + amount_to_stake = current_wallet_balance / len(netuids) + elif not amount: + amount_to_stake, _ = _prompt_stake_amount( + current_balance=remaining_wallet_balance, + netuid=netuid, + action_name="stake", + ) + amounts_to_stake.append(amount_to_stake) + + # Check enough to stake. + if amount_to_stake > remaining_wallet_balance: + err_console.print( + f"[red]Not enough stake[/red]:[bold white]\n wallet balance:{remaining_wallet_balance} < " + f"staking amount: {amount_to_stake}[/bold white]" + ) + return False + remaining_wallet_balance -= amount_to_stake + + # Calculate slippage + received_amount, slippage_pct, slippage_pct_float, rate = ( + _calculate_slippage(subnet_info, amount_to_stake) + ) + max_slippage = max(slippage_pct_float, max_slippage) + + # Add rows for the table + base_row = [ + str(netuid), # netuid + f"{hotkey[1]}", # hotkey + str(amount_to_stake), # amount + str(rate) + + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate + str(received_amount.set_unit(netuid)), # received + str(slippage_pct), # slippage + ] + + # If we are staking safe, add price tolerance + if safe_staking: + if subnet_info.is_dynamic: + rate = 1 / subnet_info.price.tao or 1 + _rate_with_tolerance = rate * ( + 1 + rate_tolerance + ) # Rate only for display + rate_with_tolerance = f"{_rate_with_tolerance:.4f}" + price_with_tolerance = subnet_info.price.rao * ( + 1 + rate_tolerance + ) # Actual price to pass to extrinsic + else: + rate_with_tolerance = "1" + price_with_tolerance = Balance.from_rao(1) + prices_with_tolerance.append(price_with_tolerance) + + base_row.extend( + [ + f"{rate_with_tolerance} {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", + f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", # safe staking + ] + ) + + rows.append(tuple(base_row)) + + # Define and print stake table + slippage warning + table = _define_stake_table(wallet, subtensor, safe_staking, rate_tolerance) + for row in rows: + table.add_row(*row) + _print_table_and_slippage(table, max_slippage, safe_staking) + + if prompt: + if not Confirm.ask("Would you like to continue?"): + raise typer.Exit() + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + + if safe_staking: + stake_coroutines = [] + for i, (ni, am, curr, price_with_tolerance) in enumerate( + zip( + netuids, amounts_to_stake, current_stake_balances, prices_with_tolerance + ) + ): + for _, staking_address in hotkeys_to_stake_to: + # Regular extrinsic for root subnet + if ni == 0: + stake_coroutines.append( + stake_extrinsic( + netuid_i=ni, + amount_=am, + current=curr, + staking_address_ss58=staking_address, + ) + ) + else: + stake_coroutines.append( + safe_stake_extrinsic( + netuid=ni, + amount=am, + current_stake=curr, + hotkey_ss58=staking_address, + price_limit=price_with_tolerance, + wallet=wallet, + subtensor=subtensor, + ) + ) + else: + stake_coroutines = [ + stake_extrinsic( + netuid_i=ni, + amount_=am, + current=curr, + staking_address_ss58=staking_address, + ) + for i, (ni, am, curr) in enumerate( + zip(netuids, amounts_to_stake, current_stake_balances) + ) + for _, staking_address in hotkeys_to_stake_to + ] + + with console.status(f"\n:satellite: Staking on netuid(s): {netuids} ..."): + # We can gather them all at once but balance reporting will be in race-condition. + for coroutine in stake_coroutines: + await coroutine + + +# Helper functions +def _prompt_stake_amount( + current_balance: Balance, netuid: int, action_name: str +) -> tuple[Balance, bool]: + """Prompts user to input a stake amount with validation. + + Args: + current_balance (Balance): The maximum available balance + netuid (int): The subnet id to get the correct unit + action_name (str): The name of the action (e.g. "transfer", "move", "unstake") + + Returns: + tuple[Balance, bool]: (The amount to use as Balance object, whether all balance was selected) + """ + while True: + amount_input = Prompt.ask( + f"\nEnter the amount to {action_name}" + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{Balance.get_unit(netuid)}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}](max: {current_balance})[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"or " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]'all'[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"for entire balance" + ) + + if amount_input.lower() == "all": + return current_balance, True + + try: + amount = float(amount_input) + if amount <= 0: + console.print("[red]Amount must be greater than 0[/red]") + continue + if amount > current_balance.tao: + console.print( + f"[red]Amount exceeds available balance of " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f"[/red]" + ) + continue + return Balance.from_tao(amount), False + except ValueError: + console.print("[red]Please enter a valid number or 'all'[/red]") + + +def _get_hotkeys_to_stake_to( + wallet: Wallet, + all_hotkeys: bool = False, + include_hotkeys: list[str] = None, + exclude_hotkeys: list[str] = None, +) -> list[tuple[Optional[str], str]]: + """Get list of hotkeys to stake to based on input parameters. + + Args: + wallet: The wallet containing hotkeys + all_hotkeys: If True, get all hotkeys from wallet except excluded ones + include_hotkeys: List of specific hotkeys to include (by name or ss58 address) + exclude_hotkeys: List of hotkeys to exclude when all_hotkeys is True + + Returns: + List of tuples containing (hotkey_name, hotkey_ss58_address) + hotkey_name may be None if ss58 address was provided directly + """ + if all_hotkeys: + # Stake to all hotkeys except excluded ones + all_hotkeys_: list[Wallet] = get_hotkey_wallets_for_wallet(wallet=wallet) + return [ + (wallet.hotkey_str, wallet.hotkey.ss58_address) + for wallet in all_hotkeys_ + if wallet.hotkey_str not in (exclude_hotkeys or []) + ] + + if include_hotkeys: + print_verbose("Staking to only included hotkeys") + # Stake to specific hotkeys + hotkeys = [] + for hotkey_ss58_or_hotkey_name in include_hotkeys: + if is_valid_ss58_address(hotkey_ss58_or_hotkey_name): + # If valid ss58 address, add directly + hotkeys.append((None, hotkey_ss58_or_hotkey_name)) + else: + # If hotkey name, get ss58 from wallet + wallet_ = Wallet( + path=wallet.path, + name=wallet.name, + hotkey=hotkey_ss58_or_hotkey_name, + ) + hotkeys.append((wallet_.hotkey_str, wallet_.hotkey.ss58_address)) + + return hotkeys + + # Default: stake to single hotkey from wallet + print_verbose( + f"Staking to hotkey: ({wallet.hotkey_str}) in wallet: ({wallet.name})" + ) + assert wallet.hotkey is not None + return [(None, wallet.hotkey.ss58_address)] + + +def _define_stake_table( + wallet: Wallet, + subtensor: "SubtensorInterface", + safe_staking: bool, + rate_tolerance: float, +) -> Table: + """Creates and initializes a table for displaying stake information. + + Args: + wallet: The wallet being used for staking + subtensor: The subtensor interface + + Returns: + Table: An initialized rich Table object with appropriate columns + """ + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Staking to:\n" + f"Wallet: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.name}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}], " + f"Coldkey ss58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n" + f"Network: {subtensor.network}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column("Netuid", justify="center", style="grey89") + table.add_column( + "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + ) + table.add_column( + f"Amount ({Balance.get_unit(0)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO"], + ) + table.add_column( + f"Rate (per {Balance.get_unit(0)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + "Received", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], + ) + table.add_column( + "Slippage", justify="center", style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"] + ) + + if safe_staking: + table.add_column( + f"Rate with tolerance: [blue]({rate_tolerance*100}%)[/blue]", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + "Partial stake enabled", + justify="center", + style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"], + ) + return table + + +def _print_table_and_slippage(table: Table, max_slippage: float, safe_staking: bool): + """Prints the stake table, slippage warning, and table description. + + Args: + table: The rich Table object to print + max_slippage: The maximum slippage percentage across all operations + """ + console.print(table) + + # Greater than 5% + if max_slippage > 5: + message = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]-------------------------------------------------------------------------------------------------------------------\n" + message += f"[bold]WARNING:[/bold] The slippage on one of your operations is high: [{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{max_slippage} %[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}], this may result in a loss of funds.\n" + message += "-------------------------------------------------------------------------------------------------------------------\n" + console.print(message) + + # Table description + base_description = """ +[bold white]Description[/bold white]: +The table displays information about the stake operation you are about to perform. +The columns are as follows: + - [bold white]Netuid[/bold white]: The netuid of the subnet you are staking to. + - [bold white]Hotkey[/bold white]: The ss58 address of the hotkey you are staking to. + - [bold white]Amount[/bold white]: The TAO you are staking into this subnet onto this hotkey. + - [bold white]Rate[/bold white]: The rate of exchange between your TAO and the subnet's stake. + - [bold white]Received[/bold white]: The amount of stake you will receive on this subnet after slippage. + - [bold white]Slippage[/bold white]: The slippage percentage of the stake operation. (0% if the subnet is not dynamic i.e. root).""" + + safe_staking_description = """ + - [bold white]Rate Tolerance[/bold white]: Maximum acceptable alpha rate. If the rate exceeds this tolerance, the transaction will be limited or rejected. + - [bold white]Partial staking[/bold white]: If True, allows staking up to the rate tolerance limit. If False, the entire transaction will fail if rate tolerance is exceeded.\n""" + + console.print(base_description + (safe_staking_description if safe_staking else "")) + + +def _calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, float]: + """Calculate slippage when adding stake. + + Args: + subnet_info: Subnet dynamic info + amount: Amount being staked + + Returns: + tuple containing: + - received_amount: Amount received after slippage + - slippage_str: Formatted slippage percentage string + - slippage_float: Raw slippage percentage value + """ + received_amount, _, slippage_pct_float = subnet_info.tao_to_alpha_with_slippage( + amount + ) + if subnet_info.is_dynamic: + slippage_str = f"{slippage_pct_float:.4f} %" + rate = f"{(1 / subnet_info.price.tao or 1):.4f}" + else: + slippage_pct_float = 0 + slippage_str = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]N/A[/{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]" + rate = "1" + + return received_amount, slippage_str, slippage_pct_float, rate diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index 66f25d3e..c0a4914e 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -5,7 +5,7 @@ from rich.prompt import Confirm, Prompt, IntPrompt from rich.table import Table from rich.text import Text -from substrateinterface.exceptions import SubstrateRequestException +from async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface @@ -22,6 +22,33 @@ ) +async def get_childkey_completion_block( + subtensor: SubtensorInterface, netuid: int +) -> tuple[int, int]: + """ + Calculates the block at which the childkey set request will complete + """ + blocks_since_last_step_query = subtensor.query( + "SubtensorModule", + "BlocksSinceLastStep", + params=[netuid], + ) + tempo_query = subtensor.get_hyperparameter( + param_name="Tempo", + netuid=netuid, + ) + block_number, blocks_since_last_step, tempo = await asyncio.gather( + subtensor.substrate.get_block_number(), + blocks_since_last_step_query, + tempo_query, + ) + cooldown = block_number + 1 + blocks_left_in_tempo = tempo - blocks_since_last_step + next_tempo = block_number + blocks_left_in_tempo + next_epoch_after_cooldown = (cooldown - next_tempo) % tempo + cooldown + return block_number, next_epoch_after_cooldown + + async def set_children_extrinsic( subtensor: "SubtensorInterface", wallet: Wallet, @@ -223,13 +250,13 @@ async def get_childkey_take(subtensor, hotkey: str, netuid: int) -> Optional[int - Optional[float]: The value of the "ChildkeyTake" if found, or None if any error occurs. """ try: - childkey_take_ = await subtensor.substrate.query( + childkey_take_ = await subtensor.query( module="SubtensorModule", storage_function="ChildkeyTake", params=[hotkey, netuid], ) if childkey_take_: - return int(childkey_take_) + return int(childkey_take_.value) except SubstrateRequestException as e: err_console.print(f"Error querying ChildKeys: {format_error_message(e)}") @@ -264,6 +291,7 @@ def prepare_child_proportions(children_with_proportions): async def get_children( wallet: Wallet, subtensor: "SubtensorInterface", netuid: Optional[int] = None ): + # TODO rao asks separately for the hotkey from the user, should we do this, or the way we do it now? """ Retrieves the child hotkeys for the specified wallet. @@ -281,35 +309,7 @@ async def get_children( - If netuid is not specified, generates and prints a summary table of all child hotkeys across all subnets. """ - async def get_total_stake_for_hk(hotkey: str, parent: bool = False): - """ - Fetches and displays the total stake for a specified hotkey from the Subtensor blockchain network. - If `parent` is True, it prints the hotkey and its corresponding stake. - - Parameters: - - hotkey (str): The hotkey for which the stake needs to be fetched. - - parent (bool, optional): A flag to indicate whether the hotkey is the parent key. Defaults to False. - - Returns: - - Balance: The total stake associated with the specified hotkey. - """ - _result = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="TotalHotkeyStake", - params=[hotkey], - reuse_block_hash=True, - ) - stake = Balance.from_rao(_result) if _result is not None else Balance(0) - if parent: - console.print( - f"\nYour Hotkey: [bright_magenta]{hotkey}[/bright_magenta] | Total Stake: [dark_orange]{stake}t[/dark_orange]\n", - end="", - no_wrap=True, - ) - - return stake - - async def get_take(child: tuple) -> float: + async def get_take(child: tuple, netuid__: int) -> float: """ Get the take value for a given subtensor, hotkey, and netuid. @@ -320,7 +320,7 @@ async def get_take(child: tuple) -> float: """ child_hotkey = child[1] take_u16 = await get_childkey_take( - subtensor=subtensor, hotkey=child_hotkey, netuid=netuid + subtensor=subtensor, hotkey=child_hotkey, netuid=netuid__ ) if take_u16: return u16_to_float(take_u16) @@ -329,7 +329,7 @@ async def get_take(child: tuple) -> float: async def _render_table( parent_hotkey: str, - netuid_children_tuples: list[tuple[int, list[tuple[int, str]]]], + netuid_children_: list[tuple[int, list[tuple[int, str]]]], ): """ Retrieves and renders children hotkeys and their details for a given parent hotkey. @@ -352,10 +352,11 @@ async def _render_table( "Current Stake Weight", style="bold red", no_wrap=True, justify="right" ) - if not netuid_children_tuples: + if not netuid_children_: console.print(table) console.print( - f"[bold red]There are currently no child hotkeys with parent hotkey: {wallet.name} ({parent_hotkey}).[/bold red]" + f"[bold red]There are currently no child hotkeys with parent hotkey: " + f"{wallet.name} | {wallet.hotkey_str} ({parent_hotkey}).[/bold red]" ) return @@ -363,48 +364,64 @@ async def _render_table( total_proportion = 0 total_stake_weight = 0 - netuid_children_tuples.sort( - key=lambda x: x[0] - ) # Sort by netuid in ascending order + netuid_children_.sort(key=lambda x: x[0]) # Sort by netuid in ascending order + unique_keys = set( + [parent_hotkey] + + [s for _, child_list in netuid_children_ for _, s in child_list] + ) + hotkey_stake_dict = await subtensor.get_total_stake_for_hotkey( + *unique_keys, + netuids=[n[0] for n in netuid_children_], + ) + parent_total = sum(hotkey_stake_dict[parent_hotkey].values()) + insert_text = ( + " " + if netuid is None + else f" on netuids: {', '.join(str(n[0]) for n in netuid_children_)} " + ) + console.print( + f"The total stake of parent hotkey '{parent_hotkey}'{insert_text}is {parent_total}." + ) - for index, (netuid, children_) in enumerate(netuid_children_tuples): + for index, (netuid_, children_) in enumerate(netuid_children_): # calculate totals total_proportion_per_netuid = 0 total_stake_weight_per_netuid = 0 - avg_take_per_netuid = 0 + avg_take_per_netuid = 0.0 - hotkey_stake_dict = await subtensor.get_total_stake_for_hotkey( - parent_hotkey - ) - hotkey_stake = hotkey_stake_dict.get(parent_hotkey, Balance(0)) + hotkey_stake: dict[int, Balance] = hotkey_stake_dict[parent_hotkey] children_info = [] - child_stakes = await asyncio.gather( - *[get_total_stake_for_hk(c[1]) for c in children_] + child_takes = await asyncio.gather( + *[get_take(c, netuid_) for c in children_] ) - child_takes = await asyncio.gather(*[get_take(c) for c in children_]) - for child, child_stake, child_take in zip( - children_, child_stakes, child_takes - ): + for child, child_take in zip(children_, child_takes): proportion = child[0] child_hotkey = child[1] # add to totals avg_take_per_netuid += child_take - proportion = u64_to_float(proportion) + converted_proportion = u64_to_float(proportion) children_info.append( - (proportion, child_hotkey, child_stake, child_take) + ( + converted_proportion, + child_hotkey, + hotkey_stake_dict[child_hotkey][netuid_], + child_take, + ) ) children_info.sort( key=lambda x: x[0], reverse=True ) # sorting by proportion (highest first) - for proportion, hotkey, stake, child_take in children_info: - proportion_percent = proportion * 100 # Proportion in percent - proportion_tao = hotkey_stake.tao * proportion # Proportion in TAO + for proportion_, hotkey, stake, child_take in children_info: + proportion_percent = proportion_ * 100 # Proportion in percent + proportion_tao = ( + hotkey_stake[netuid_].tao * proportion_ + ) # Proportion in TAO total_proportion_per_netuid += proportion_percent @@ -414,9 +431,9 @@ async def _render_table( total_stake_weight_per_netuid += stake_weight take_str = f"{child_take * 100:.3f}%" - hotkey = Text(hotkey, style="italic red" if proportion == 0 else "") + hotkey = Text(hotkey, style="italic red" if proportion_ == 0 else "") table.add_row( - str(netuid), + str(netuid_), hotkey, proportion_str, take_str, @@ -440,7 +457,7 @@ async def _render_table( total_stake_weight += total_stake_weight_per_netuid # Add a dividing line if there are more than one netuid - if len(netuid_children_tuples) > 1: + if len(netuid_children_) > 1: table.add_section() console.print(table) @@ -449,17 +466,16 @@ async def _render_table( if netuid is None: # get all netuids netuids = await subtensor.get_all_subnet_netuids() - await get_total_stake_for_hk(wallet.hotkey.ss58_address, True) netuid_children_tuples = [] - for netuid in netuids: + for netuid_ in netuids: success, children, err_mg = await subtensor.get_children( - wallet.hotkey.ss58_address, netuid + wallet.hotkey.ss58_address, netuid_ ) if children: - netuid_children_tuples.append((netuid, children)) + netuid_children_tuples.append((netuid_, children)) if not success: err_console.print( - f"Failed to get children from subtensor {netuid}: {err_mg}" + f"Failed to get children from subtensor {netuid_}: {err_mg}" ) await _render_table(wallet.hotkey.ss58_address, netuid_children_tuples) else: @@ -468,7 +484,6 @@ async def _render_table( ) if not success: err_console.print(f"Failed to get children from subtensor: {err_mg}") - await get_total_stake_for_hk(wallet.hotkey.ss58_address, True) if children: netuid_children_tuples = [(netuid, children)] await _render_table(wallet.hotkey.ss58_address, netuid_children_tuples) @@ -488,6 +503,8 @@ async def set_children( ): """Set children hotkeys.""" # Validate children SS58 addresses + # TODO check to see if this should be allowed to be specified by user instead of pulling from wallet + hotkey = wallet.hotkey.ss58_address for child in children: if not is_valid_ss58_address(child): err_console.print(f":cross_mark:[red] Invalid SS58 address: {child}[/red]") @@ -502,14 +519,13 @@ async def set_children( f"Invalid proportion: The sum of all proportions cannot be greater than 1. " f"Proposed sum of proportions is {total_proposed}." ) - children_with_proportions = list(zip(proportions, children)) - if netuid: + if netuid is not None: success, message = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, netuid=netuid, - hotkey=wallet.hotkey.ss58_address, + hotkey=hotkey, children_with_proportions=children_with_proportions, prompt=prompt, wait_for_inclusion=wait_for_inclusion, @@ -518,8 +534,13 @@ async def set_children( # Result if success: if wait_for_inclusion and wait_for_finalization: - console.print("New Status:") - await get_children(wallet, subtensor, netuid) + current_block, completion_block = await get_childkey_completion_block( + subtensor, netuid + ) + console.print( + f"Your childkey request has been submitted. It will be completed around block {completion_block}. " + f"The current block is {current_block}" + ) console.print( ":white_heavy_check_mark: [green]Set children hotkeys.[/green]" ) @@ -530,20 +551,27 @@ async def set_children( else: # set children on all subnets that parent is registered on netuids = await subtensor.get_all_subnet_netuids() - for netuid in netuids: - if netuid == 0: # dont include root network + for netuid_ in netuids: + if netuid_ == 0: # dont include root network continue - console.print(f"Setting children on netuid {netuid}.") + console.print(f"Setting children on netuid {netuid_}.") await set_children_extrinsic( subtensor=subtensor, wallet=wallet, - netuid=netuid, - hotkey=wallet.hotkey.ss58_address, + netuid=netuid_, + hotkey=hotkey, children_with_proportions=children_with_proportions, prompt=prompt, wait_for_inclusion=True, wait_for_finalization=False, ) + current_block, completion_block = await get_childkey_completion_block( + subtensor, netuid_ + ) + console.print( + f"Your childkey request for netuid {netuid_} has been submitted. It will be completed around " + f"block {completion_block}. The current block is {current_block}." + ) console.print( ":white_heavy_check_mark: [green]Sent set children request for all subnets.[/green]" ) diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py new file mode 100644 index 00000000..9f2ba776 --- /dev/null +++ b/bittensor_cli/src/commands/stake/list.py @@ -0,0 +1,687 @@ +import asyncio + +from typing import TYPE_CHECKING, Optional +import typer + +from bittensor_wallet import Wallet +from rich.prompt import Prompt +from rich.table import Table +from rich import box +from rich.progress import Progress, BarColumn, TextColumn +from rich.console import Group +from rich.live import Live + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.chain_data import StakeInfo +from bittensor_cli.src.bittensor.utils import ( + console, + print_error, + millify_tao, + get_subnet_name, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def stake_list( + wallet: Wallet, + coldkey_ss58: str, + subtensor: "SubtensorInterface", + live: bool = False, + verbose: bool = False, + prompt: bool = False, +): + coldkey_address = coldkey_ss58 if coldkey_ss58 else wallet.coldkeypub.ss58_address + + async def get_stake_data(block_hash: str = None): + ( + sub_stakes, + registered_delegate_info, + _dynamic_info, + ) = await asyncio.gather( + subtensor.get_stake_for_coldkey( + coldkey_ss58=coldkey_address, block_hash=block_hash + ), + subtensor.get_delegate_identities(block_hash=block_hash), + subtensor.all_subnets(), + ) + # sub_stakes = substakes[coldkey_address] + dynamic_info = {info.netuid: info for info in _dynamic_info} + return ( + sub_stakes, + registered_delegate_info, + dynamic_info, + ) + + def define_table( + hotkey_name: str, + rows: list[list[str]], + total_tao_ownership: Balance, + total_tao_value: Balance, + total_swapped_tao_value: Balance, + live: bool = False, + ): + title = f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Hotkey: {hotkey_name}\nNetwork: {subtensor.network}\n\n" + # TODO: Add hint back in after adding columns descriptions + # if not live: + # title += f"[{COLOR_PALETTE['GENERAL']['HINT']}]See below for an explanation of the columns\n" + table = Table( + title=title, + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + table.add_column( + "[white]Netuid", + footer=f"{len(rows)}", + footer_style="overline white", + style="grey89", + ) + table.add_column( + "[white]Name", + style="cyan", + justify="left", + no_wrap=True, + ) + table.add_column( + f"[white]Value \n({Balance.get_unit(1)} x {Balance.unit}/{Balance.get_unit(1)})", + footer_style="overline white", + style=COLOR_PALETTE["STAKE"]["TAO"], + justify="right", + footer=f"τ {millify_tao(total_tao_value.tao)}" + if not verbose + else f"{total_tao_value}", + ) + table.add_column( + f"[white]Stake ({Balance.get_unit(1)})", + footer_style="overline white", + style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + justify="center", + ) + table.add_column( + f"[white]Price \n({Balance.unit}_in/{Balance.get_unit(1)}_in)", + footer_style="white", + style=COLOR_PALETTE["POOLS"]["RATE"], + justify="center", + ) + table.add_column( + f"[white]Swap ({Balance.get_unit(1)} -> {Balance.unit})", + footer_style="overline white", + style=COLOR_PALETTE["STAKE"]["STAKE_SWAP"], + justify="right", + footer=f"τ {millify_tao(total_swapped_tao_value.tao)}" + if not verbose + else f"{total_swapped_tao_value}", + ) + table.add_column( + "[white]Registered", + style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + justify="right", + ) + table.add_column( + f"[white]Emission \n({Balance.get_unit(1)}/block)", + style=COLOR_PALETTE["POOLS"]["EMISSION"], + justify="right", + ) + return table + + def create_table(hotkey_: str, substakes: list[StakeInfo]): + name = ( + f"{registered_delegate_info[hotkey_].display} ({hotkey_})" + if hotkey_ in registered_delegate_info + else hotkey_ + ) + rows = [] + total_tao_ownership = Balance(0) + total_tao_value = Balance(0) + total_swapped_tao_value = Balance(0) + root_stakes = [s for s in substakes if s.netuid == 0] + other_stakes = sorted( + [s for s in substakes if s.netuid != 0], + key=lambda x: dynamic_info[x.netuid] + .alpha_to_tao(Balance.from_rao(int(x.stake.rao)).set_unit(x.netuid)) + .tao, + reverse=True, + ) + sorted_substakes = root_stakes + other_stakes + for substake_ in sorted_substakes: + netuid = substake_.netuid + pool = dynamic_info[netuid] + symbol = f"{Balance.get_unit(netuid)}\u200e" + # TODO: what is this price var for? + price = ( + "{:.4f}{}".format( + pool.price.__float__(), f" τ/{Balance.get_unit(netuid)}\u200e" + ) + if pool.is_dynamic + else (f" 1.0000 τ/{symbol} ") + ) + + # Alpha value cell + alpha_value = Balance.from_rao(int(substake_.stake.rao)).set_unit(netuid) + + # TAO value cell + tao_value = pool.alpha_to_tao(alpha_value) + total_tao_value += tao_value + + # Swapped TAO value and slippage cell + swapped_tao_value, _, slippage_percentage_ = ( + pool.alpha_to_tao_with_slippage(substake_.stake) + ) + total_swapped_tao_value += swapped_tao_value + + # Slippage percentage cell + if pool.is_dynamic: + slippage_percentage = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{slippage_percentage_:.3f}%[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]" + else: + slippage_percentage = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]0.000%[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]" + + if netuid == 0: + swap_value = f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]N/A[/{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}] ({slippage_percentage})" + else: + swap_value = ( + f"τ {millify_tao(swapped_tao_value.tao)} ({slippage_percentage})" + if not verbose + else f"{swapped_tao_value} ({slippage_percentage})" + ) + + # TAO locked cell + tao_locked = pool.tao_in + + # Issuance cell + issuance = pool.alpha_out if pool.is_dynamic else tao_locked + + # Per block emission cell + per_block_emission = substake_.emission.tao / pool.tempo + # Alpha ownership and TAO ownership cells + if alpha_value.tao > 0.00009: + if issuance.tao != 0: + # TODO figure out why this alpha_ownership does nothing + alpha_ownership = "{:.4f}".format( + (alpha_value.tao / issuance.tao) * 100 + ) + tao_ownership = Balance.from_tao( + (alpha_value.tao / issuance.tao) * tao_locked.tao + ) + total_tao_ownership += tao_ownership + else: + # TODO what's this var for? + alpha_ownership = "0.0000" + tao_ownership = Balance.from_tao(0) + + stake_value = ( + millify_tao(substake_.stake.tao) + if not verbose + else f"{substake_.stake.tao:,.4f}" + ) + subnet_name_cell = f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}] {get_subnet_name(dynamic_info[netuid])}" + + rows.append( + [ + str(netuid), # Number + subnet_name_cell, # Symbol + name + f"τ {millify_tao(tao_value.tao)}" + if not verbose + else f"{tao_value}", # Value (α x τ/α) + f"{stake_value} {symbol}" + if netuid != 0 + else f"{symbol} {stake_value}", # Stake (a) + f"{pool.price.tao:.4f} τ/{symbol}", # Rate (t/a) + # f"τ {millify_tao(tao_ownership.tao)}" if not verbose else f"{tao_ownership}", # TAO equiv + swap_value, # Swap(α) -> τ + "YES" + if substake_.is_registered + else f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]NO", # Registered + str(Balance.from_tao(per_block_emission).set_unit(netuid)), + # Removing this flag for now, TODO: Confirm correct values are here w.r.t CHKs + # if substake_.is_registered + # else f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]N/A", # Emission(α/block) + ] + ) + table = define_table( + name, rows, total_tao_ownership, total_tao_value, total_swapped_tao_value + ) + for row in rows: + table.add_row(*row) + console.print(table) + return total_tao_ownership, total_tao_value + + def create_live_table( + substakes: list, + registered_delegate_info: dict, + dynamic_info: dict, + hotkey_name: str, + previous_data: Optional[dict] = None, + ) -> tuple[Table, dict, Balance, Balance, Balance]: + rows = [] + current_data = {} + + total_tao_ownership = Balance(0) + total_tao_value = Balance(0) + total_swapped_tao_value = Balance(0) + + def format_cell( + value, previous_value, unit="", unit_first=False, precision=4, millify=False + ): + if previous_value is not None: + change = value - previous_value + if abs(change) > 10 ** (-precision): + formatted_change = ( + f"{change:.{precision}f}" + if not millify + else f"{millify_tao(change)}" + ) + change_text = ( + f" [pale_green3](+{formatted_change})[/pale_green3]" + if change > 0 + else f" [hot_pink3]({formatted_change})[/hot_pink3]" + ) + else: + change_text = "" + else: + change_text = "" + formatted_value = ( + f"{value:,.{precision}f}" if not millify else f"{millify_tao(value)}" + ) + return ( + f"{formatted_value} {unit}{change_text}" + if not unit_first + else f"{unit} {formatted_value}{change_text}" + ) + + # Sort subnets by value + root_stakes = [s for s in substakes if s.netuid == 0] + other_stakes = sorted( + [s for s in substakes if s.netuid != 0], + key=lambda x: dynamic_info[x.netuid] + .alpha_to_tao(Balance.from_rao(int(x.stake.rao)).set_unit(x.netuid)) + .tao, + reverse=True, + ) + sorted_substakes = root_stakes + other_stakes + + # Process each stake + for substake in sorted_substakes: + netuid = substake.netuid + pool = dynamic_info.get(netuid) + if substake.stake.rao == 0 or not pool: + continue + + # Calculate base values + symbol = f"{Balance.get_unit(netuid)}\u200e" + alpha_value = Balance.from_rao(int(substake.stake.rao)).set_unit(netuid) + tao_value = pool.alpha_to_tao(alpha_value) + total_tao_value += tao_value + swapped_tao_value, slippage = pool.alpha_to_tao_with_slippage( + substake.stake + ) + total_swapped_tao_value += swapped_tao_value + + # Calculate TAO ownership + tao_locked = pool.tao_in + issuance = pool.alpha_out if pool.is_dynamic else tao_locked + if alpha_value.tao > 0.00009 and issuance.tao != 0: + tao_ownership = Balance.from_tao( + (alpha_value.tao / issuance.tao) * tao_locked.tao + ) + total_tao_ownership += tao_ownership + else: + tao_ownership = Balance.from_tao(0) + + # Store current values for future delta tracking + current_data[netuid] = { + "stake": alpha_value.tao, + "price": pool.price.tao, + "tao_value": tao_value.tao, + "swapped_value": swapped_tao_value.tao, + "emission": substake.emission.tao / pool.tempo, + "tao_ownership": tao_ownership.tao, + } + + # Get previous values for delta tracking + prev = previous_data.get(netuid, {}) if previous_data else {} + unit_first = True if netuid == 0 else False + + stake_cell = format_cell( + alpha_value.tao, + prev.get("stake"), + unit=symbol, + unit_first=unit_first, + precision=4, + millify=True if not verbose else False, + ) + + rate_cell = format_cell( + pool.price.tao, + prev.get("price"), + unit=f"τ/{symbol}", + unit_first=False, + precision=5, + millify=True if not verbose else False, + ) + + exchange_cell = format_cell( + tao_value.tao, + prev.get("tao_value"), + unit="τ", + unit_first=True, + precision=4, + millify=True if not verbose else False, + ) + + if pool.is_dynamic: + slippage_pct = ( + 100 * float(slippage) / float(slippage + swapped_tao_value) + if slippage + swapped_tao_value != 0 + else 0 + ) + else: + slippage_pct = 0 + + if netuid != 0: + swap_cell = ( + format_cell( + swapped_tao_value.tao, + prev.get("swapped_value"), + unit="τ", + unit_first=True, + precision=4, + millify=True if not verbose else False, + ) + + f" ({slippage_pct:.2f}%)" + ) + else: + swap_cell = f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]N/A[/{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}] ({slippage_pct}%)" + + emission_value = substake.emission.tao / pool.tempo + emission_cell = format_cell( + emission_value, + prev.get("emission"), + unit=symbol, + unit_first=unit_first, + precision=4, + ) + subnet_name_cell = ( + f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f" {get_subnet_name(dynamic_info[netuid])}" + ) + + rows.append( + [ + str(netuid), # Netuid + subnet_name_cell, + exchange_cell, # Exchange value + stake_cell, # Stake amount + rate_cell, # Rate + swap_cell, # Swap value with slippage + "YES" + if substake.is_registered + else f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]NO", # Registration status + emission_cell, # Emission rate + ] + ) + + table = define_table( + hotkey_name, + rows, + total_tao_ownership, + total_tao_value, + total_swapped_tao_value, + live=True, + ) + + for row in rows: + table.add_row(*row) + + return table, current_data + + # Main execution + ( + sub_stakes, + registered_delegate_info, + dynamic_info, + ) = await get_stake_data() + balance = await subtensor.get_balance(coldkey_address) + + # Iterate over substakes and aggregate them by hotkey. + hotkeys_to_substakes: dict[str, list[StakeInfo]] = {} + + for substake in sub_stakes: + hotkey = substake.hotkey_ss58 + if substake.stake.rao == 0: + continue + if hotkey not in hotkeys_to_substakes: + hotkeys_to_substakes[hotkey] = [] + hotkeys_to_substakes[hotkey].append(substake) + + if not hotkeys_to_substakes: + print_error(f"No stakes found for coldkey ss58: ({coldkey_address})") + raise typer.Exit() + + if live: + # Select one hokkey for live monitoring + if len(hotkeys_to_substakes) > 1: + console.print( + "\n[bold]Multiple hotkeys found. Please select one for live monitoring:[/bold]" + ) + for idx, hotkey in enumerate(hotkeys_to_substakes.keys()): + name = ( + f"{registered_delegate_info[hotkey].display} ({hotkey})" + if hotkey in registered_delegate_info + else hotkey + ) + console.print(f"[{idx}] [{COLOR_PALETTE['GENERAL']['HEADER']}]{name}") + + selected_idx = Prompt.ask( + "Enter hotkey index", + choices=[str(i) for i in range(len(hotkeys_to_substakes))], + ) + selected_hotkey = list(hotkeys_to_substakes.keys())[int(selected_idx)] + selected_stakes = hotkeys_to_substakes[selected_hotkey] + else: + selected_hotkey = list(hotkeys_to_substakes.keys())[0] + selected_stakes = hotkeys_to_substakes[selected_hotkey] + + hotkey_name = ( + f"{registered_delegate_info[selected_hotkey].display} ({selected_hotkey})" + if selected_hotkey in registered_delegate_info + else selected_hotkey + ) + + refresh_interval = 10 # seconds + progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(bar_width=20), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + console=console, + ) + progress_task = progress.add_task("Updating: ", total=refresh_interval) + + previous_block = None + current_block = None + previous_data = None + + with Live(console=console, screen=True, auto_refresh=True) as live: + try: + while True: + block_hash = await subtensor.substrate.get_chain_head() + ( + sub_stakes, + registered_delegate_info, + dynamic_info_, + ) = await get_stake_data(block_hash) + selected_stakes = [ + stake + for stake in sub_stakes + if stake.hotkey_ss58 == selected_hotkey + ] + + block_number = await subtensor.substrate.get_block_number(None) + + previous_block = current_block + current_block = block_number + new_blocks = ( + "N/A" + if previous_block is None + else str(current_block - previous_block) + ) + + table, current_data = create_live_table( + selected_stakes, + registered_delegate_info, + dynamic_info, + hotkey_name, + previous_data, + ) + + previous_data = current_data + progress.reset(progress_task) + start_time = asyncio.get_event_loop().time() + + block_info = ( + f"Previous: [dark_sea_green]{previous_block}[/dark_sea_green] " + f"Current: [dark_sea_green]{current_block}[/dark_sea_green] " + f"Diff: [dark_sea_green]{new_blocks}[/dark_sea_green]" + ) + + message = f"\nLive stake view - Press [bold red]Ctrl+C[/bold red] to exit\n{block_info}" + live_render = Group(message, progress, table) + live.update(live_render) + + while not progress.finished: + await asyncio.sleep(0.1) + elapsed = asyncio.get_event_loop().time() - start_time + progress.update( + progress_task, completed=min(elapsed, refresh_interval) + ) + + except KeyboardInterrupt: + console.print("\n[bold]Stopped live updates[/bold]") + return + + else: + # Iterate over each hotkey and make a table + counter = 0 + num_hotkeys = len(hotkeys_to_substakes) + all_hotkeys_total_global_tao = Balance(0) + all_hotkeys_total_tao_value = Balance(0) + for hotkey in hotkeys_to_substakes.keys(): + counter += 1 + stake, value = create_table(hotkey, hotkeys_to_substakes[hotkey]) + all_hotkeys_total_global_tao += stake + all_hotkeys_total_tao_value += value + + if num_hotkeys > 1 and counter < num_hotkeys and prompt: + console.print("\nPress Enter to continue to the next hotkey...") + input() + + total_tao_value = ( + f"τ {millify_tao(all_hotkeys_total_tao_value.tao)}" + if not verbose + else all_hotkeys_total_tao_value + ) + total_tao_ownership = ( + f"τ {millify_tao(all_hotkeys_total_global_tao.tao)}" + if not verbose + else all_hotkeys_total_global_tao + ) + + console.print("\n\n") + console.print( + f"Wallet:\n" + f" Coldkey SS58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{coldkey_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n" + f" Free Balance: [{COLOR_PALETTE['GENERAL']['BALANCE']}]{balance}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]\n" + f" Total TAO ({Balance.unit}): [{COLOR_PALETTE['GENERAL']['BALANCE']}]{total_tao_ownership}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]\n" + f" Total Value ({Balance.unit}): [{COLOR_PALETTE['GENERAL']['BALANCE']}]{total_tao_value}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]" + ) + if not sub_stakes: + console.print( + f"\n[blue]No stakes found for coldkey ss58: ({coldkey_address})" + ) + else: + # TODO: Temporarily returning till we update docs + return + display_table = Prompt.ask( + "\nPress Enter to view column descriptions or type 'q' to skip:", + choices=["", "q"], + default="", + show_choices=True, + ).lower() + + if display_table == "q": + console.print( + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]Column descriptions skipped." + ) + else: + header = """ + [bold white]Description[/bold white]: Each table displays information about stake associated with a hotkey. The columns are as follows: + """ + console.print(header) + description_table = Table( + show_header=False, box=box.SIMPLE, show_edge=False, show_lines=True + ) + + fields = [ + ("[bold tan]Netuid[/bold tan]", "The netuid of the subnet."), + ( + "[bold tan]Symbol[/bold tan]", + "The symbol for the subnet's dynamic TAO token.", + ), + ( + "[bold tan]Stake (α)[/bold tan]", + "The stake amount this hotkey holds in the subnet, expressed in subnet's alpha token currency. This can change whenever staking or unstaking occurs on this hotkey in this subnet. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#staking[/blue].", + ), + ( + "[bold tan]TAO Reserves (τ_in)[/bold tan]", + 'Number of TAO in the TAO reserves of the pool for this subnet. Attached to every subnet is a subnet pool, containing a TAO reserve and the alpha reserve. See also "Alpha Pool (α_in)" description. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#subnet-pool[/blue].', + ), + ( + "[bold tan]Alpha Reserves (α_in)[/bold tan]", + "Number of subnet alpha tokens in the alpha reserves of the pool for this subnet. This reserve, together with 'TAO Pool (τ_in)', form the subnet pool for every subnet. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#subnet-pool[/blue].", + ), + ( + "[bold tan]RATE (τ_in/α_in)[/bold tan]", + "Exchange rate between TAO and subnet dTAO token. Calculated as the reserve ratio: (TAO Pool (τ_in) / Alpha Pool (α_in)). Note that the terms relative price, alpha token price, alpha price are the same as exchange rate. This rate can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#rate-%CF%84_in%CE%B1_in[/blue].", + ), + ( + "[bold tan]Alpha out (α_out)[/bold tan]", + "Total stake in the subnet, expressed in subnet's alpha token currency. This is the sum of all the stakes present in all the hotkeys in this subnet. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#stake-%CE%B1_out-or-alpha-out-%CE%B1_out", + ), + ( + "[bold tan]TAO Equiv (τ_in x α/α_out)[/bold tan]", + 'TAO-equivalent value of the hotkeys stake α (i.e., Stake(α)). Calculated as (TAO Reserves(τ_in) x (Stake(α) / ALPHA Out(α_out)). This value is weighted with (1-γ), where γ is the local weight coefficient, and used in determining the overall stake weight of the hotkey in this subnet. Also see the "Local weight coeff (γ)" column of "btcli subnet list" command output. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#local-weight-or-tao-equiv-%CF%84_in-x-%CE%B1%CE%B1_out[/blue].', + ), + ( + "[bold tan]Exchange Value (α x τ/α)[/bold tan]", + "This is the potential τ you will receive, without considering slippage, if you unstake from this hotkey now on this subnet. See Swap(α → τ) column description. Note: The TAO Equiv(τ_in x α/α_out) indicates validator stake weight while this Exchange Value shows τ you will receive if you unstake now. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#exchange-value-%CE%B1-x-%CF%84%CE%B1[/blue].", + ), + ( + "[bold tan]Swap (α → τ)[/bold tan]", + "This is the actual τ you will receive, after factoring in the slippage charge, if you unstake from this hotkey now on this subnet. The slippage is calculated as 1 - (Swap(α → τ)/Exchange Value(α x τ/α)), and is displayed in brackets. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#swap-%CE%B1--%CF%84[/blue].", + ), + ( + "[bold tan]Registered[/bold tan]", + "Indicates if the hotkey is registered in this subnet or not. \nFor more, see [blue]https://docs.bittensor.com/learn/anatomy-of-incentive-mechanism#tempo[/blue].", + ), + ( + "[bold tan]Emission (α/block)[/bold tan]", + "Shows the portion of the one α/block emission into this subnet that is received by this hotkey, according to YC2 in this subnet. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#emissions[/blue].", + ), + ] + + description_table.add_column( + "Field", + no_wrap=True, + style="bold tan", + ) + description_table.add_column("Description", overflow="fold") + for field_name, description in fields: + description_table.add_row(field_name, description) + console.print(description_table) diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py new file mode 100644 index 00000000..5ce500f1 --- /dev/null +++ b/bittensor_cli/src/commands/stake/move.py @@ -0,0 +1,1000 @@ +import asyncio + +from typing import TYPE_CHECKING +import typer + +from bittensor_wallet import Wallet +from bittensor_wallet.errors import KeyFileError +from rich.table import Table +from rich.prompt import Confirm, Prompt + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, + print_error, + format_error_message, + group_subnets, + get_subnet_name, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + +MIN_STAKE_FEE = Balance.from_rao(50_000) + + +# Helpers +async def display_stake_movement_cross_subnets( + subtensor: "SubtensorInterface", + origin_netuid: int, + destination_netuid: int, + origin_hotkey: str, + destination_hotkey: str, + amount_to_move: Balance, +) -> tuple[Balance, float, str, str]: + """Calculate and display slippage information""" + + if origin_netuid == destination_netuid: + subnet = await subtensor.subnet(origin_netuid) + received_amount_tao = subnet.alpha_to_tao(amount_to_move) + received_amount_tao -= MIN_STAKE_FEE + received_amount = subnet.tao_to_alpha(received_amount_tao) + slippage_pct_float = ( + 100 * float(MIN_STAKE_FEE) / float(MIN_STAKE_FEE + received_amount_tao) + if received_amount_tao != 0 + else 0 + ) + slippage_pct = f"{slippage_pct_float:.4f}%" + price = Balance.from_tao(1).set_unit(origin_netuid) + price_str = ( + str(float(price.tao)) + + f"{Balance.get_unit(origin_netuid)}/{Balance.get_unit(origin_netuid)}" + ) + else: + dynamic_origin, dynamic_destination = await asyncio.gather( + subtensor.subnet(origin_netuid), + subtensor.subnet(destination_netuid), + ) + price = ( + float(dynamic_origin.price) * 1 / (float(dynamic_destination.price) or 1) + ) + received_amount_tao, _, _ = dynamic_origin.alpha_to_tao_with_slippage( + amount_to_move + ) + received_amount_tao -= MIN_STAKE_FEE + received_amount, _, _ = dynamic_destination.tao_to_alpha_with_slippage( + received_amount_tao + ) + received_amount.set_unit(destination_netuid) + + if received_amount < Balance.from_tao(0): + print_error("Not enough Alpha to pay the transaction fee.") + raise typer.Exit() + + ideal_amount = amount_to_move * price + total_slippage = ideal_amount - received_amount + slippage_pct_float = 100 * (total_slippage.tao / ideal_amount.tao) + slippage_pct = f"{slippage_pct_float:.4f} %" + price_str = ( + f"{price:.5f}" + + f"{Balance.get_unit(destination_netuid)}/{Balance.get_unit(origin_netuid)}" + ) + + # Create and display table + table = Table( + title=( + f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]" + f"Moving stake from: " + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{Balance.get_unit(origin_netuid)}(Netuid: {origin_netuid})" + f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"to: " + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{Balance.get_unit(destination_netuid)}(Netuid: {destination_netuid})" + f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\nNetwork: {subtensor.network}\n" + f"[/{COLOR_PALETTE['GENERAL']['HEADER']}]" + ), + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column( + "origin netuid", justify="center", style=COLOR_PALETTE["GENERAL"]["SYMBOL"] + ) + table.add_column( + "origin hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + ) + table.add_column( + "dest netuid", justify="center", style=COLOR_PALETTE["GENERAL"]["SYMBOL"] + ) + table.add_column( + "dest hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + ) + table.add_column( + f"amount ({Balance.get_unit(origin_netuid)})", + justify="center", + style=COLOR_PALETTE["STAKE"]["TAO"], + ) + table.add_column( + f"rate ({Balance.get_unit(destination_netuid)}/{Balance.get_unit(origin_netuid)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + f"received ({Balance.get_unit(destination_netuid)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], + ) + table.add_column( + "slippage", + justify="center", + style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"], + ) + + table.add_row( + f"{Balance.get_unit(origin_netuid)}({origin_netuid})", + f"{origin_hotkey[:3]}...{origin_hotkey[-3:]}", + f"{Balance.get_unit(destination_netuid)}({destination_netuid})", + f"{destination_hotkey[:3]}...{destination_hotkey[-3:]}", + str(amount_to_move), + price_str, + str(received_amount), + str(slippage_pct), + ) + + console.print(table) + # console.print( + # f"[dim]A fee of {MIN_STAKE_FEE} applies.[/dim]" + # ) + + # Display slippage warning if necessary + if slippage_pct_float > 5: + message = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]-------------------------------------------------------------------------------------------------------------------\n" + message += f"[bold]WARNING:\tSlippage is high: [{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{slippage_pct}[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}], this may result in a loss of funds.[/bold] \n" + message += "-------------------------------------------------------------------------------------------------------------------\n" + console.print(message) + + return received_amount, slippage_pct_float, slippage_pct, price_str + + +def prompt_stake_amount( + current_balance: Balance, netuid: int, action_name: str +) -> tuple[Balance, bool]: + """Prompts user to input a stake amount with validation. + + Args: + current_balance (Balance): The maximum available balance + netuid (int): The subnet id to get the correct unit + action_name (str): The name of the action (e.g. "transfer", "move", "unstake") + + Returns: + tuple[Balance, bool]: (The amount to use as Balance object, whether all balance was selected) + """ + while True: + amount_input = Prompt.ask( + f"\nEnter the amount to {action_name} " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{Balance.get_unit(netuid)}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}](max: {current_balance})[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"or " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]'all'[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"for entire balance" + ) + + if amount_input.lower() == "all": + return current_balance, True + + try: + amount = float(amount_input) + if amount <= 0: + console.print("[red]Amount must be greater than 0[/red]") + continue + if amount > current_balance.tao: + console.print( + f"[red]Amount exceeds available balance of " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f"[/red]" + ) + continue + return Balance.from_tao(amount), False + except ValueError: + console.print("[red]Please enter a valid number or 'all'[/red]") + + +async def stake_move_selection( + subtensor: "SubtensorInterface", + wallet: Wallet, +): + """Selection interface for moving stakes between hotkeys and subnets.""" + stakes, ck_hk_identities, old_identities = await asyncio.gather( + subtensor.get_stake_for_coldkey(coldkey_ss58=wallet.coldkeypub.ss58_address), + subtensor.fetch_coldkey_hotkey_identities(), + subtensor.get_delegate_identities(), + ) + + hotkey_stakes = {} + for stake in stakes: + if stake.stake.tao > 0: + hotkey = stake.hotkey_ss58 + netuid = stake.netuid + stake_balance = stake.stake + hotkey_stakes.setdefault(hotkey, {})[netuid] = stake_balance + + if not hotkey_stakes: + print_error("You have no stakes to move.") + raise typer.Exit() + + # Display hotkeys with stakes + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Hotkeys with Stakes\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + table.add_column("Index", justify="right") + table.add_column("Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]) + table.add_column("Netuids", style=COLOR_PALETTE["GENERAL"]["NETUID"]) + table.add_column("Hotkey Address", style=COLOR_PALETTE["GENERAL"]["HOTKEY"]) + + hotkeys_info = [] + for idx, (hotkey_ss58, netuid_stakes) in enumerate(hotkey_stakes.items()): + if hk_identity := ck_hk_identities["hotkeys"].get(hotkey_ss58): + hotkey_name = hk_identity.get("identity", {}).get( + "name", "" + ) or hk_identity.get("display", "~") + elif old_identity := old_identities.get(hotkey_ss58): + hotkey_name = old_identity.display + else: + hotkey_name = "~" + hotkeys_info.append( + { + "index": idx, + "identity": hotkey_name, + "hotkey_ss58": hotkey_ss58, + "netuids": list(netuid_stakes.keys()), + "stakes": netuid_stakes, + } + ) + table.add_row( + str(idx), + hotkey_name, + group_subnets([n for n in netuid_stakes.keys()]), + hotkey_ss58, + ) + + console.print("\n", table) + + # Select origin hotkey + origin_idx = Prompt.ask( + "\nEnter the index of the hotkey you want to move stake from", + choices=[str(i) for i in range(len(hotkeys_info))], + ) + origin_hotkey_info = hotkeys_info[int(origin_idx)] + origin_hotkey_ss58 = origin_hotkey_info["hotkey_ss58"] + + # Display available netuids for selected hotkey + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Available Stakes for Hotkey\n[/{COLOR_PALETTE['GENERAL']['HEADER']}]" + f"[{COLOR_PALETTE['GENERAL']['HOTKEY']}]{origin_hotkey_ss58}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]\n", + show_edge=False, + header_style="bold white", + border_style="bright_black", + title_justify="center", + width=len(origin_hotkey_ss58) + 20, + ) + table.add_column("Index", justify="right") + table.add_column("Netuid", style="cyan") + table.add_column("Stake Amount", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"]) + + available_netuids = [] + for idx, netuid in enumerate(origin_hotkey_info["netuids"]): + stake = origin_hotkey_info["stakes"][netuid] + if stake.tao > 0: + available_netuids.append(netuid) + table.add_row(str(idx), str(netuid), str(stake)) + + console.print("\n", table) + + # Select origin netuid + netuid_idx = Prompt.ask( + "\nEnter the index of the subnet you want to move stake from", + choices=[str(i) for i in range(len(available_netuids))], + ) + origin_netuid = available_netuids[int(netuid_idx)] + origin_stake = origin_hotkey_info["stakes"][origin_netuid] + + # Ask for amount to move + amount, stake_all = prompt_stake_amount(origin_stake, origin_netuid, "move") + + all_subnets = sorted(await subtensor.get_all_subnet_netuids()) + destination_netuid = Prompt.ask( + "\nEnter the netuid of the subnet you want to move stake to" + + f" ([dim]{group_subnets(all_subnets)}[/dim])", + choices=[str(netuid) for netuid in all_subnets], + show_choices=False, + ) + + return { + "origin_hotkey": origin_hotkey_ss58, + "origin_netuid": origin_netuid, + "amount": amount.tao, + "stake_all": stake_all, + "destination_netuid": int(destination_netuid), + } + + +async def stake_transfer_selection( + wallet: Wallet, + subtensor: "SubtensorInterface", +): + """Selection interface for transferring stakes.""" + ( + stakes, + all_netuids, + all_subnets, + ) = await asyncio.gather( + subtensor.get_stake_for_coldkey(coldkey_ss58=wallet.coldkeypub.ss58_address), + subtensor.get_all_subnet_netuids(), + subtensor.all_subnets(), + ) + all_netuids = sorted(all_netuids) + all_subnets = {di.netuid: di for di in all_subnets} + + available_stakes = {} + for stake in stakes: + if stake.stake.tao > 0 and stake.hotkey_ss58 == wallet.hotkey.ss58_address: + available_stakes[stake.netuid] = { + "hotkey_ss58": stake.hotkey_ss58, + "stake": stake.stake, + "is_registered": stake.is_registered, + } + + if not available_stakes: + console.print("[red]No stakes available to transfer.[/red]") + return None + + table = Table( + title=( + f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]" + f"Available Stakes to Transfer\n" + f"for wallet hotkey:\n" + f"[{COLOR_PALETTE['GENERAL']['HOTKEY']}]{wallet.hotkey_str}: {wallet.hotkey.ss58_address}" + f"[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]\n" + ), + show_edge=False, + header_style="bold white", + border_style="bright_black", + title_justify="center", + width=len(wallet.hotkey_str + wallet.hotkey.ss58_address) + 10, + ) + + table.add_column("Index", justify="right", style="cyan") + table.add_column("Netuid") + table.add_column("Name", style="cyan", justify="left") + table.add_column("Stake Amount", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"]) + table.add_column("Registered", justify="center") + + for idx, (netuid, stake_info) in enumerate(available_stakes.items()): + subnet_name_cell = ( + f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{all_subnets[netuid].symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f" {get_subnet_name(all_subnets[netuid])}" + ) + table.add_row( + str(idx), + str(netuid), + subnet_name_cell, + str(stake_info["stake"]), + "[dark_sea_green3]YES" + if stake_info["is_registered"] + else f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]NO", + ) + + console.print(table) + + if not available_stakes: + console.print("[red]No stakes available to transfer.[/red]") + return None + + # Prompt to select index of stake to transfer + selection = Prompt.ask( + "\nEnter the index of the stake you want to transfer", + choices=[str(i) for i in range(len(available_stakes))], + ) + selected_netuid = list(available_stakes.keys())[int(selection)] + selected_stake = available_stakes[selected_netuid] + + # Prompt for amount + stake_balance = selected_stake["stake"] + amount, _ = prompt_stake_amount(stake_balance, selected_netuid, "transfer") + + # Prompt for destination subnet + destination_netuid = Prompt.ask( + "\nEnter the netuid of the subnet you want to move stake to" + + f" ([dim]{group_subnets(all_netuids)}[/dim])", + choices=[str(netuid) for netuid in all_netuids], + show_choices=False, + ) + + return { + "origin_netuid": selected_netuid, + "amount": amount.tao, + "destination_netuid": int(destination_netuid), + } + + +async def stake_swap_selection( + subtensor: "SubtensorInterface", + wallet: Wallet, +) -> dict: + """Selection interface for swapping stakes between subnets.""" + block_hash = await subtensor.substrate.get_chain_head() + stakes, all_subnets = await asyncio.gather( + subtensor.get_stake_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address, block_hash=block_hash + ), + subtensor.all_subnets(block_hash=block_hash), + ) + subnet_dict = {di.netuid: di for di in all_subnets} + + # Filter stakes for this hotkey + hotkey_stakes = {} + for stake in stakes: + if stake.hotkey_ss58 == wallet.hotkey.ss58_address and stake.stake.tao > 0: + hotkey_stakes[stake.netuid] = { + "stake": stake.stake, + "is_registered": stake.is_registered, + } + + if not hotkey_stakes: + print_error(f"No stakes found for hotkey: {wallet.hotkey_str}") + raise typer.Exit() + + # Display available stakes + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Available Stakes for Hotkey\n[/{COLOR_PALETTE['GENERAL']['HEADER']}]" + f"[{COLOR_PALETTE['GENERAL']['HOTKEY']}]{wallet.hotkey_str}: {wallet.hotkey.ss58_address}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]\n", + show_edge=False, + header_style="bold white", + border_style="bright_black", + title_justify="center", + width=len(wallet.hotkey.ss58_address) + 20, + ) + + table.add_column("Index", justify="right", style="cyan") + table.add_column("Netuid", style=COLOR_PALETTE["GENERAL"]["NETUID"]) + table.add_column("Name", style="cyan", justify="left") + table.add_column("Stake Amount", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"]) + table.add_column("Registered", justify="center") + + available_netuids = [] + for idx, (netuid, stake_info) in enumerate(sorted(hotkey_stakes.items())): + subnet_info = subnet_dict[netuid] + subnet_name_cell = ( + f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{subnet_info.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f" {get_subnet_name(subnet_info)}" + ) + + available_netuids.append(netuid) + table.add_row( + str(idx), + str(netuid), + subnet_name_cell, + str(stake_info["stake"]), + "[dark_sea_green3]YES" + if stake_info["is_registered"] + else f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]NO", + ) + + console.print("\n", table) + + # Select origin netuid + origin_idx = Prompt.ask( + "\nEnter the index of the subnet you want to swap stake from", + choices=[str(i) for i in range(len(available_netuids))], + ) + origin_netuid = available_netuids[int(origin_idx)] + origin_stake = hotkey_stakes[origin_netuid]["stake"] + + # Ask for amount to swap + amount, all_balance = prompt_stake_amount(origin_stake, origin_netuid, "swap") + + all_netuids = sorted(await subtensor.get_all_subnet_netuids()) + destination_choices = [ + str(netuid) for netuid in all_netuids if netuid != origin_netuid + ] + destination_netuid = Prompt.ask( + "\nEnter the netuid of the subnet you want to swap stake to" + + f" ([dim]{group_subnets(all_netuids)}[/dim])", + choices=destination_choices, + show_choices=False, + ) + + return { + "origin_netuid": origin_netuid, + "amount": amount.tao, + "destination_netuid": int(destination_netuid), + } + + +# Commands +async def move_stake( + subtensor: "SubtensorInterface", + wallet: Wallet, + origin_netuid: int, + origin_hotkey: str, + destination_netuid: int, + destination_hotkey: str, + amount: float, + stake_all: bool, + interactive_selection: bool = False, + prompt: bool = True, +): + if interactive_selection: + selection = await stake_move_selection(subtensor, wallet) + origin_hotkey = selection["origin_hotkey"] + origin_netuid = selection["origin_netuid"] + amount = selection["amount"] + stake_all = selection["stake_all"] + destination_netuid = selection["destination_netuid"] + + # Get the wallet stake balances. + block_hash = await subtensor.substrate.get_chain_head() + origin_stake_balance, destination_stake_balance = await asyncio.gather( + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=origin_hotkey, + netuid=origin_netuid, + block_hash=block_hash, + ), + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=destination_hotkey, + netuid=destination_netuid, + block_hash=block_hash, + ), + ) + + if origin_stake_balance.tao == 0: + print_error( + f"Your balance is " + f"[{COLOR_PALETTE['POOLS']['TAO']}]0[/{COLOR_PALETTE['POOLS']['TAO']}] " + f"in Netuid: " + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{origin_netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + ) + raise typer.Exit() + + console.print( + f"\nOrigin Netuid: " + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{origin_netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}], " + f"Origin stake: " + f"[{COLOR_PALETTE['POOLS']['TAO']}]{origin_stake_balance}[/{COLOR_PALETTE['POOLS']['TAO']}]" + ) + console.print( + f"Destination netuid: " + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{destination_netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}], " + f"Destination stake: " + f"[{COLOR_PALETTE['POOLS']['TAO']}]{destination_stake_balance}[/{COLOR_PALETTE['POOLS']['TAO']}]\n" + ) + + # Determine the amount we are moving. + amount_to_move_as_balance = None + if amount: + amount_to_move_as_balance = Balance.from_tao(amount) + elif stake_all: + amount_to_move_as_balance = origin_stake_balance + else: + amount_to_move_as_balance, _ = prompt_stake_amount( + origin_stake_balance, origin_netuid, "move" + ) + + # Check enough to move. + amount_to_move_as_balance.set_unit(origin_netuid) + if amount_to_move_as_balance > origin_stake_balance: + err_console.print( + f"[red]Not enough stake[/red]:\n" + f" Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{origin_stake_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f" < Moving amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_to_move_as_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + ) + return False + + # Slippage warning + if prompt: + await display_stake_movement_cross_subnets( + subtensor=subtensor, + origin_netuid=origin_netuid, + destination_netuid=destination_netuid, + origin_hotkey=origin_hotkey, + destination_hotkey=destination_hotkey, + amount_to_move=amount_to_move_as_balance, + ) + if not Confirm.ask("Would you like to continue?"): + raise typer.Exit() + + # Perform moving operation. + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + with console.status( + f"\n:satellite: Moving [blue]{amount_to_move_as_balance}[/blue] from [blue]{origin_hotkey}[/blue] on netuid: [blue]{origin_netuid}[/blue] \nto " + f"[blue]{destination_hotkey}[/blue] on netuid: [blue]{destination_netuid}[/blue] ..." + ): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="move_stake", + call_params={ + "origin_hotkey": origin_hotkey, + "origin_netuid": origin_netuid, + "destination_hotkey": destination_hotkey, + "destination_netuid": destination_netuid, + "alpha_amount": amount_to_move_as_balance.rao, + }, + ) + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + response = await subtensor.substrate.submit_extrinsic( + extrinsic, wait_for_inclusion=True, wait_for_finalization=False + ) + + if not prompt: + console.print(":white_heavy_check_mark: [green]Sent[/green]") + return True + else: + await response.process_events() + if not await response.is_success: + err_console.print( + f"\n:cross_mark: [red]Failed[/red] with error:" + f" {format_error_message(await response.error_message)}" + ) + return + else: + console.print( + ":white_heavy_check_mark: [dark_sea_green3]Stake moved.[/dark_sea_green3]" + ) + block_hash = await subtensor.substrate.get_chain_head() + ( + new_origin_stake_balance, + new_destination_stake_balance, + ) = await asyncio.gather( + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=origin_hotkey, + netuid=origin_netuid, + block_hash=block_hash, + ), + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=destination_hotkey, + netuid=destination_netuid, + block_hash=block_hash, + ), + ) + + console.print( + f"Origin Stake:\n [blue]{origin_stake_balance}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_origin_stake_balance}" + ) + console.print( + f"Destination Stake:\n [blue]{destination_stake_balance}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_destination_stake_balance}" + ) + return + + +async def transfer_stake( + wallet: Wallet, + subtensor: "SubtensorInterface", + amount: float, + origin_netuid: int, + dest_netuid: int, + dest_coldkey_ss58: str, + interactive_selection: bool = False, + prompt: bool = True, +) -> bool: + """Transfers stake from one network to another. + + Args: + wallet (Wallet): Bittensor wallet object. + subtensor (SubtensorInterface): Subtensor interface instance. + amount (float): Amount to transfer. + origin_netuid (int): The netuid to transfer stake from. + dest_netuid (int): The netuid to transfer stake to. + dest_coldkey_ss58 (str): The destination coldkey to transfer stake to. + interactive_selection (bool): If true, prompts for selection of origin and destination subnets. + prompt (bool): If true, prompts for confirmation before executing transfer. + + Returns: + bool: True if transfer was successful, False otherwise. + """ + if interactive_selection: + selection = await stake_transfer_selection(wallet, subtensor) + origin_netuid = selection["origin_netuid"] + amount = selection["amount"] + dest_netuid = selection["destination_netuid"] + + # Check if both subnets exist + block_hash = await subtensor.substrate.get_chain_head() + dest_exists, origin_exists = await asyncio.gather( + subtensor.subnet_exists(netuid=dest_netuid, block_hash=block_hash), + subtensor.subnet_exists(netuid=origin_netuid, block_hash=block_hash), + ) + if not dest_exists: + err_console.print(f"[red]Subnet {dest_netuid} does not exist[/red]") + return False + + if not origin_exists: + err_console.print(f"[red]Subnet {origin_netuid} does not exist[/red]") + return False + + # Get current stake balances + hotkey_ss58 = wallet.hotkey.ss58_address + with console.status(f"Retrieving stake data from {subtensor.network}..."): + current_stake = await subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=origin_netuid, + ) + current_dest_stake = await subtensor.get_stake( + coldkey_ss58=dest_coldkey_ss58, + hotkey_ss58=hotkey_ss58, + netuid=dest_netuid, + ) + amount_to_transfer = Balance.from_tao(amount).set_unit(origin_netuid) + + # Check if enough stake to transfer + if amount_to_transfer > current_stake: + err_console.print( + f"[red]Not enough stake to transfer[/red]:\n" + f"Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_stake}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] < " + f"Transfer amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_to_transfer}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + ) + return False + + # Slippage warning + if prompt: + await display_stake_movement_cross_subnets( + subtensor=subtensor, + origin_netuid=origin_netuid, + destination_netuid=dest_netuid, + origin_hotkey=hotkey_ss58, + destination_hotkey=hotkey_ss58, + amount_to_move=amount_to_transfer, + ) + + if not Confirm.ask("Would you like to continue?"): + raise typer.Exit() + + # Perform transfer operation + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + + with console.status("\n:satellite: Transferring stake ..."): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="transfer_stake", + call_params={ + "destination_coldkey": dest_coldkey_ss58, + "hotkey": hotkey_ss58, + "origin_netuid": origin_netuid, + "destination_netuid": dest_netuid, + "alpha_amount": amount_to_transfer.rao, + }, + ) + + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + + response = await subtensor.substrate.submit_extrinsic( + extrinsic, wait_for_inclusion=True, wait_for_finalization=False + ) + + if not prompt: + console.print(":white_heavy_check_mark: [green]Sent[/green]") + return True + + await response.process_events() + if not await response.is_success: + err_console.print( + f":cross_mark: [red]Failed[/red] with error: " + f"{format_error_message(await response.error_message, subtensor.substrate)}" + ) + return False + + # Get and display new stake balances + new_stake, new_dest_stake = await asyncio.gather( + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=origin_netuid, + ), + subtensor.get_stake( + coldkey_ss58=dest_coldkey_ss58, + hotkey_ss58=hotkey_ss58, + netuid=dest_netuid, + ), + ) + + console.print( + f"Origin Stake:\n [blue]{current_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" + ) + console.print( + f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_dest_stake}" + ) + return True + + +async def swap_stake( + wallet: Wallet, + subtensor: "SubtensorInterface", + origin_netuid: int, + destination_netuid: int, + amount: float, + swap_all: bool = False, + interactive_selection: bool = False, + prompt: bool = True, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> bool: + """Swaps stake between subnets while keeping the same coldkey-hotkey pair ownership. + + Args: + wallet (Wallet): The wallet to swap stake from. + subtensor (SubtensorInterface): Subtensor interface instance. + hotkey_ss58 (str): The SS58 address of the hotkey whose stake is being swapped. + origin_netuid (int): The netuid from which stake is removed. + destination_netuid (int): The netuid to which stake is added. + amount (float): The amount to swap. + interactive_selection (bool): If true, prompts for selection of origin and destination subnets. + prompt (bool): If true, prompts for confirmation before executing swap. + wait_for_inclusion (bool): If true, waits for the transaction to be included in a block. + wait_for_finalization (bool): If true, waits for the transaction to be finalized. + + Returns: + bool: True if the swap was successful, False otherwise. + """ + hotkey_ss58 = wallet.hotkey.ss58_address + if interactive_selection: + selection = await stake_swap_selection(subtensor, wallet) + origin_netuid = selection["origin_netuid"] + amount = selection["amount"] + destination_netuid = selection["destination_netuid"] + + # Check if both subnets exist + block_hash = await subtensor.substrate.get_chain_head() + dest_exists, origin_exists = await asyncio.gather( + subtensor.subnet_exists(netuid=destination_netuid, block_hash=block_hash), + subtensor.subnet_exists(netuid=origin_netuid, block_hash=block_hash), + ) + if not dest_exists: + err_console.print(f"[red]Subnet {destination_netuid} does not exist[/red]") + return False + + if not origin_exists: + err_console.print(f"[red]Subnet {origin_netuid} does not exist[/red]") + return False + + # Get current stake balances + with console.status(f"Retrieving stake data from {subtensor.network}..."): + current_stake = await subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=origin_netuid, + ) + current_dest_stake = await subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=destination_netuid, + ) + + if swap_all: + amount_to_swap = Balance.from_tao(current_stake).set_unit(origin_netuid) + else: + amount_to_swap = Balance.from_tao(amount).set_unit(origin_netuid) + + # Check if enough stake to swap + if amount_to_swap > current_stake: + err_console.print( + f"[red]Not enough stake to swap[/red]:\n" + f"Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_stake}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] < " + f"Swap amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_to_swap}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + ) + return False + + # Slippage warning + if prompt: + await display_stake_movement_cross_subnets( + subtensor=subtensor, + origin_netuid=origin_netuid, + destination_netuid=destination_netuid, + origin_hotkey=hotkey_ss58, + destination_hotkey=hotkey_ss58, + amount_to_move=amount_to_swap, + ) + + if not Confirm.ask("Would you like to continue?"): + raise typer.Exit() + + # Perform swap operation + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + + with console.status( + f"\n:satellite: Swapping stake from netuid [blue]{origin_netuid}[/blue] to netuid [blue]{destination_netuid}[/blue]..." + ): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="swap_stake", + call_params={ + "hotkey": hotkey_ss58, + "origin_netuid": origin_netuid, + "destination_netuid": destination_netuid, + "alpha_amount": amount_to_swap.rao, + }, + ) + + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + + response = await subtensor.substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not prompt: + console.print(":white_heavy_check_mark: [green]Sent[/green]") + return True + + await response.process_events() + if not await response.is_success: + err_console.print( + f":cross_mark: [red]Failed[/red] with error: " + f"{format_error_message(await response.error_message, subtensor.substrate)}" + ) + return False + + # Get and display new stake balances + new_stake, new_dest_stake = await asyncio.gather( + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=origin_netuid, + ), + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=destination_netuid, + ), + ) + + console.print( + f"Origin Stake:\n [blue]{current_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" + ) + console.print( + f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_dest_stake}" + ) + return True diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py new file mode 100644 index 00000000..25f53e08 --- /dev/null +++ b/bittensor_cli/src/commands/stake/remove.py @@ -0,0 +1,1146 @@ +import asyncio +from functools import partial + +from typing import TYPE_CHECKING, Optional +import typer + +from bittensor_wallet import Wallet +from bittensor_wallet.errors import KeyFileError +from rich.prompt import Confirm, Prompt +from rich.table import Table + +from async_substrate_interface.errors import SubstrateRequestException +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, + print_verbose, + print_error, + get_hotkey_wallets_for_wallet, + is_valid_ss58_address, + format_error_message, + group_subnets, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +# Commands +async def unstake( + wallet: Wallet, + subtensor: "SubtensorInterface", + hotkey_ss58_address: str, + all_hotkeys: bool, + include_hotkeys: list[str], + exclude_hotkeys: list[str], + amount: float, + prompt: bool, + interactive: bool, + netuid: Optional[int], + safe_staking: bool, + rate_tolerance: float, + allow_partial_stake: bool, +): + """Unstake from hotkey(s).""" + unstake_all_from_hk = False + with console.status( + f"Retrieving subnet data & identities from {subtensor.network}...", + spinner="earth", + ): + all_sn_dynamic_info_, ck_hk_identities, old_identities = await asyncio.gather( + subtensor.all_subnets(), + subtensor.fetch_coldkey_hotkey_identities(), + subtensor.get_delegate_identities(), + ) + all_sn_dynamic_info = {info.netuid: info for info in all_sn_dynamic_info_} + + if interactive: + hotkeys_to_unstake_from, unstake_all_from_hk = await _unstake_selection( + subtensor, + wallet, + all_sn_dynamic_info, + ck_hk_identities, + old_identities, + netuid=netuid, + ) + if unstake_all_from_hk: + hotkey_to_unstake_all = hotkeys_to_unstake_from[0] + unstake_all_alpha = Confirm.ask( + "\nUnstake [blue]all alpha stakes[/blue] and stake back to [blue]root[/blue]? (No will unstake everything)", + default=True, + ) + return await unstake_all( + wallet=wallet, + subtensor=subtensor, + hotkey_ss58_address=hotkey_to_unstake_all[1], + unstake_all_alpha=unstake_all_alpha, + prompt=prompt, + ) + + if not hotkeys_to_unstake_from: + console.print("[red]No unstake operations to perform.[/red]") + return False + netuids = list({netuid for _, _, netuid in hotkeys_to_unstake_from}) + + else: + netuids = ( + [int(netuid)] + if netuid is not None + else await subtensor.get_all_subnet_netuids() + ) + hotkeys_to_unstake_from = _get_hotkeys_to_unstake( + wallet=wallet, + hotkey_ss58_address=hotkey_ss58_address, + all_hotkeys=all_hotkeys, + include_hotkeys=include_hotkeys, + exclude_hotkeys=exclude_hotkeys, + ) + + with console.status( + f"Retrieving stake data from {subtensor.network}...", + spinner="earth", + ): + # Fetch stake balances + chain_head = await subtensor.substrate.get_chain_head() + stake_info_list = await subtensor.get_stake_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address, + block_hash=chain_head, + ) + stake_in_netuids = {} + for stake_info in stake_info_list: + if stake_info.hotkey_ss58 not in stake_in_netuids: + stake_in_netuids[stake_info.hotkey_ss58] = {} + stake_in_netuids[stake_info.hotkey_ss58][stake_info.netuid] = ( + stake_info.stake + ) + + # Flag to check if user wants to quit + skip_remaining_subnets = False + if len(netuids) > 1 and not amount: + console.print( + "[dark_sea_green3]Tip: Enter 'q' any time to stop going over remaining subnets and process current unstakes.\n" + ) + + # Iterate over hotkeys and netuids to collect unstake operations + unstake_operations = [] + total_received_amount = Balance.from_tao(0) + max_float_slippage = 0 + table_rows = [] + for hotkey in hotkeys_to_unstake_from: + if skip_remaining_subnets: + break + + if interactive: + staking_address_name, staking_address_ss58, netuid = hotkey + netuids_to_process = [netuid] + else: + staking_address_name, staking_address_ss58 = hotkey + netuids_to_process = netuids + + initial_amount = amount + + for netuid in netuids_to_process: + if skip_remaining_subnets: + break # Exit the loop over netuids + + subnet_info = all_sn_dynamic_info.get(netuid) + if staking_address_ss58 not in stake_in_netuids: + print_error( + f"No stake found for hotkey: {staking_address_ss58} on netuid: {netuid}" + ) + continue # Skip to next hotkey + + current_stake_balance = stake_in_netuids[staking_address_ss58].get(netuid) + if current_stake_balance is None or current_stake_balance.tao == 0: + print_error( + f"No stake to unstake from {staking_address_ss58} on netuid: {netuid}" + ) + continue # No stake to unstake + + # Determine the amount we are unstaking. + if initial_amount: + amount_to_unstake_as_balance = Balance.from_tao(initial_amount) + else: + amount_to_unstake_as_balance = _ask_unstake_amount( + current_stake_balance, + netuid, + staking_address_name + if staking_address_name + else staking_address_ss58, + staking_address_ss58, + interactive, + ) + if amount_to_unstake_as_balance is None: + skip_remaining_subnets = True + break + + # Check enough stake to remove. + amount_to_unstake_as_balance.set_unit(netuid) + if amount_to_unstake_as_balance > current_stake_balance: + err_console.print( + f"[red]Not enough stake to remove[/red]:\n Stake balance: [dark_orange]{current_stake_balance}[/dark_orange]" + f" < Unstaking amount: [dark_orange]{amount_to_unstake_as_balance}[/dark_orange] on netuid: {netuid}" + ) + continue # Skip to the next subnet - useful when single amount is specified for all subnets + + received_amount, slippage_pct, slippage_pct_float = _calculate_slippage( + subnet_info=subnet_info, amount=amount_to_unstake_as_balance + ) + total_received_amount += received_amount + max_float_slippage = max(max_float_slippage, slippage_pct_float) + + base_unstake_op = { + "netuid": netuid, + "hotkey_name": staking_address_name + if staking_address_name + else staking_address_ss58, + "hotkey_ss58": staking_address_ss58, + "amount_to_unstake": amount_to_unstake_as_balance, + "current_stake_balance": current_stake_balance, + "received_amount": received_amount, + "slippage_pct": slippage_pct, + "slippage_pct_float": slippage_pct_float, + "dynamic_info": subnet_info, + } + + base_table_row = [ + str(netuid), # Netuid + staking_address_name, # Hotkey Name + str(amount_to_unstake_as_balance), # Amount to Unstake + str(subnet_info.price.tao) + + f"({Balance.get_unit(0)}/{Balance.get_unit(netuid)})", # Rate + str(received_amount), # Received Amount + slippage_pct, # Slippage Percent + ] + + # Additional fields for safe unstaking + if safe_staking: + if subnet_info.is_dynamic: + rate = subnet_info.price.tao or 1 + rate_with_tolerance = rate * ( + 1 - rate_tolerance + ) # Rate only for display + price_with_tolerance = subnet_info.price.rao * ( + 1 - rate_tolerance + ) # Actual price to pass to extrinsic + else: + rate_with_tolerance = 1 + price_with_tolerance = 1 + + base_unstake_op["price_with_tolerance"] = price_with_tolerance + base_table_row.extend( + [ + f"{rate_with_tolerance:.4f} {Balance.get_unit(0)}/{Balance.get_unit(netuid)}", # Rate with tolerance + f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", # Partial unstake + ] + ) + + unstake_operations.append(base_unstake_op) + table_rows.append(base_table_row) + + if not unstake_operations: + console.print("[red]No unstake operations to perform.[/red]") + return False + + table = _create_unstake_table( + wallet_name=wallet.name, + wallet_coldkey_ss58=wallet.coldkeypub.ss58_address, + network=subtensor.network, + total_received_amount=total_received_amount, + safe_staking=safe_staking, + rate_tolerance=rate_tolerance, + ) + for row in table_rows: + table.add_row(*row) + + _print_table_and_slippage(table, max_float_slippage, safe_staking) + if prompt: + if not Confirm.ask("Would you like to continue?"): + raise typer.Exit() + + # Execute extrinsics + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + + with console.status("\n:satellite: Performing unstaking operations...") as status: + if safe_staking: + for op in unstake_operations: + if op["netuid"] == 0: + await _unstake_extrinsic( + wallet=wallet, + subtensor=subtensor, + netuid=op["netuid"], + amount=op["amount_to_unstake"], + current_stake=op["current_stake_balance"], + hotkey_ss58=op["hotkey_ss58"], + status=status, + ) + else: + await _safe_unstake_extrinsic( + wallet=wallet, + subtensor=subtensor, + netuid=op["netuid"], + amount=op["amount_to_unstake"], + current_stake=op["current_stake_balance"], + hotkey_ss58=op["hotkey_ss58"], + price_limit=op["price_with_tolerance"], + allow_partial_stake=allow_partial_stake, + status=status, + ) + else: + for op in unstake_operations: + await _unstake_extrinsic( + wallet=wallet, + subtensor=subtensor, + netuid=op["netuid"], + amount=op["amount_to_unstake"], + current_stake=op["current_stake_balance"], + hotkey_ss58=op["hotkey_ss58"], + status=status, + ) + console.print( + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]Unstaking operations completed." + ) + + +async def unstake_all( + wallet: Wallet, + subtensor: "SubtensorInterface", + hotkey_ss58_address: str, + unstake_all_alpha: bool = False, + prompt: bool = True, +) -> bool: + """Unstakes all stakes from all hotkeys in all subnets.""" + + with console.status( + f"Retrieving stake information & identities from {subtensor.network}...", + spinner="earth", + ): + ( + stake_info, + ck_hk_identities, + old_identities, + all_sn_dynamic_info_, + current_wallet_balance, + ) = await asyncio.gather( + subtensor.get_stake_for_coldkey(wallet.coldkeypub.ss58_address), + subtensor.fetch_coldkey_hotkey_identities(), + subtensor.get_delegate_identities(), + subtensor.all_subnets(), + subtensor.get_balance(wallet.coldkeypub.ss58_address), + ) + if not hotkey_ss58_address: + hotkey_ss58_address = wallet.hotkey.ss58_address + stake_info = [ + stake for stake in stake_info if stake.hotkey_ss58 == hotkey_ss58_address + ] + + if unstake_all_alpha: + stake_info = [stake for stake in stake_info if stake.netuid != 0] + + if not stake_info: + console.print("[red]No stakes found to unstake[/red]") + return False + + all_sn_dynamic_info = {info.netuid: info for info in all_sn_dynamic_info_} + + # Create table for unstaking all + table_title = ( + "Unstaking Summary - All Stakes" + if not unstake_all_alpha + else "Unstaking Summary - All Alpha Stakes" + ) + table = Table( + title=( + f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{table_title}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n" + f"Wallet: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.name}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}], " + f"Coldkey ss58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n" + f"Network: [{COLOR_PALETTE['GENERAL']['HEADER']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n" + ), + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + table.add_column("Netuid", justify="center", style="grey89") + table.add_column( + "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + ) + table.add_column( + f"Current Stake ({Balance.get_unit(1)})", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + ) + table.add_column( + f"Rate ({Balance.unit}/{Balance.get_unit(1)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + f"Recieved ({Balance.unit})", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], + ) + table.add_column( + "Slippage", + justify="center", + style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"], + ) + + # Calculate slippage and total received + max_slippage = 0.0 + total_received_value = Balance(0) + for stake in stake_info: + if stake.stake.rao == 0: + continue + + # Get hotkey identity + if hk_identity := ck_hk_identities["hotkeys"].get(stake.hotkey_ss58): + hotkey_name = hk_identity.get("identity", {}).get( + "name", "" + ) or hk_identity.get("display", "~") + hotkey_display = f"{hotkey_name}" + elif old_identity := old_identities.get(stake.hotkey_ss58): + hotkey_name = old_identity.display + hotkey_display = f"{hotkey_name}" + else: + hotkey_display = stake.hotkey_ss58 + + subnet_info = all_sn_dynamic_info.get(stake.netuid) + stake_amount = stake.stake + received_amount, slippage_pct, slippage_pct_float = _calculate_slippage( + subnet_info=subnet_info, amount=stake_amount + ) + max_slippage = max(max_slippage, slippage_pct_float) + total_received_value += received_amount + + table.add_row( + str(stake.netuid), + hotkey_display, + str(stake_amount), + str(float(subnet_info.price)) + + f"({Balance.get_unit(0)}/{Balance.get_unit(stake.netuid)})", + str(received_amount), + slippage_pct, + ) + console.print(table) + message = "" + if max_slippage > 5: + message += f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]-------------------------------------------------------------------------------------------------------------------\n" + message += f"[bold]WARNING:[/bold] The slippage on one of your operations is high: [{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{max_slippage:.4f}%[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}], this may result in a loss of funds.\n" + message += "-------------------------------------------------------------------------------------------------------------------\n" + console.print(message) + + console.print( + f"Expected return after slippage: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{total_received_value}" + ) + + if prompt and not Confirm.ask( + "\nDo you want to proceed with unstaking everything?" + ): + return False + + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + + console_status = ( + ":satellite: Unstaking all Alpha stakes..." + if unstake_all_alpha + else ":satellite: Unstaking all stakes..." + ) + previous_root_stake = await subtensor.get_stake( + hotkey_ss58=hotkey_ss58_address, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=0, + ) + with console.status(console_status): + call_function = "unstake_all_alpha" if unstake_all_alpha else "unstake_all" + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function=call_function, + call_params={"hotkey": hotkey_ss58_address}, + ) + success, error_message = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + ) + + if success: + success_message = ( + ":white_heavy_check_mark: [green]Successfully unstaked all stakes[/green]" + if not unstake_all_alpha + else ":white_heavy_check_mark: [green]Successfully unstaked all Alpha stakes[/green]" + ) + console.print(success_message) + new_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + console.print( + f"Balance:\n [blue]{current_wallet_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + if unstake_all_alpha: + root_stake = await subtensor.get_stake( + hotkey_ss58=hotkey_ss58_address, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=0, + ) + console.print( + f"Root Stake:\n [blue]{previous_root_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{root_stake}" + ) + return True + else: + err_console.print( + f":cross_mark: [red]Failed to unstake[/red]: {error_message}" + ) + return False + + +# Extrinsics +async def _unstake_extrinsic( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + amount: Balance, + current_stake: Balance, + hotkey_ss58: str, + status=None, +) -> None: + """Execute a standard unstake extrinsic. + + Args: + netuid: The subnet ID + amount: Amount to unstake + current_stake: Current stake balance + hotkey_ss58: Hotkey SS58 address + wallet: Wallet instance + subtensor: Subtensor interface + status: Optional status for console updates + """ + err_out = partial(print_error, status=status) + failure_prelude = ( + f":cross_mark: [red]Failed[/red] to unstake {amount} on Netuid {netuid}" + ) + + if status: + status.update( + f"\n:satellite: Unstaking {amount} from {hotkey_ss58} on netuid: {netuid} ..." + ) + + current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="remove_stake", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount_unstaked": amount.rao, + }, + ) + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + + try: + response = await subtensor.substrate.submit_extrinsic( + extrinsic, wait_for_inclusion=True, wait_for_finalization=False + ) + await response.process_events() + + if not await response.is_success: + err_out( + f"{failure_prelude} with error: " + f"{format_error_message(await response.error_message, subtensor.substrate)}" + ) + return + + # Fetch latest balance and stake + block_hash = await subtensor.substrate.get_chain_head() + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), + subtensor.get_stake( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + block_hash=block_hash, + ), + ) + + console.print(":white_heavy_check_mark: [green]Finalized[/green]") + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + console.print( + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f" Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" + ) + + except Exception as e: + err_out(f"{failure_prelude} with error: {str(e)}") + + +async def _safe_unstake_extrinsic( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + amount: Balance, + current_stake: Balance, + hotkey_ss58: str, + price_limit: Balance, + allow_partial_stake: bool, + status=None, +) -> None: + """Execute a safe unstake extrinsic with price limit. + + Args: + netuid: The subnet ID + amount: Amount to unstake + current_stake: Current stake balance + hotkey_ss58: Hotkey SS58 address + price_limit: Maximum acceptable price + wallet: Wallet instance + subtensor: Subtensor interface + allow_partial_stake: Whether to allow partial unstaking + status: Optional status for console updates + """ + err_out = partial(print_error, status=status) + failure_prelude = ( + f":cross_mark: [red]Failed[/red] to unstake {amount} on Netuid {netuid}" + ) + + if status: + status.update( + f"\n:satellite: Unstaking {amount} from {hotkey_ss58} on netuid: {netuid} ..." + ) + + block_hash = await subtensor.substrate.get_chain_head() + + current_balance, next_nonce, current_stake = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), + subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), + subtensor.get_stake( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + ), + ) + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="remove_stake_limit", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount_unstaked": amount.rao, + "limit_price": price_limit, + "allow_partial": allow_partial_stake, + }, + ) + + extrinsic = await subtensor.substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey, nonce=next_nonce + ) + + try: + response = await subtensor.substrate.submit_extrinsic( + extrinsic, wait_for_inclusion=True, wait_for_finalization=False + ) + except SubstrateRequestException as e: + if "Custom error: 8" in str(e): + print_error( + f"\n{failure_prelude}: Price exceeded tolerance limit. " + f"Transaction rejected because partial unstaking is disabled. " + f"Either increase price tolerance or enable partial unstaking.", + status=status, + ) + return + else: + err_out( + f"\n{failure_prelude} with error: {format_error_message(e)}" + ) + return + + await response.process_events() + if not await response.is_success: + err_out( + f"\n{failure_prelude} with error: {format_error_message(await response.error_message, subtensor.substrate)}" + ) + return + + block_hash = await subtensor.substrate.get_chain_head() + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), + subtensor.get_stake( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + block_hash=block_hash, + ), + ) + + console.print(":white_heavy_check_mark: [green]Finalized[/green]") + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + + amount_unstaked = current_stake - new_stake + if allow_partial_stake and (amount_unstaked != amount): + console.print( + "Partial unstake transaction. Unstaked:\n" + f" [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_unstaked.set_unit(netuid=netuid)}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"instead of " + f"[blue]{amount}[/blue]" + ) + + console.print( + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" + ) + + +# Helpers +def _calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, float]: + """Calculate slippage and received amount for unstaking operation. + + Args: + dynamic_info: Subnet information containing price data + amount: Amount being unstaked + + Returns: + tuple containing: + - received_amount: Balance after slippage + - slippage_pct: Formatted string of slippage percentage + - slippage_pct_float: Float value of slippage percentage + """ + received_amount, _, slippage_pct_float = subnet_info.alpha_to_tao_with_slippage( + amount + ) + + if subnet_info.is_dynamic: + slippage_pct = f"{slippage_pct_float:.4f} %" + else: + slippage_pct_float = 0 + slippage_pct = "[red]N/A[/red]" + + return received_amount, slippage_pct, slippage_pct_float + + +async def _unstake_selection( + subtensor: "SubtensorInterface", + wallet: Wallet, + dynamic_info, + identities, + old_identities, + netuid: Optional[int] = None, +): + stake_infos = await subtensor.get_stake_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + + if not stake_infos: + print_error("You have no stakes to unstake.") + raise typer.Exit() + + hotkey_stakes = {} + for stake_info in stake_infos: + if netuid is not None and stake_info.netuid != netuid: + continue + hotkey_ss58 = stake_info.hotkey_ss58 + netuid_ = stake_info.netuid + stake_amount = stake_info.stake + if stake_amount.tao > 0: + hotkey_stakes.setdefault(hotkey_ss58, {})[netuid_] = stake_amount + + if not hotkey_stakes: + if netuid is not None: + print_error(f"You have no stakes to unstake in subnet {netuid}.") + else: + print_error("You have no stakes to unstake.") + raise typer.Exit() + + hotkeys_info = [] + for idx, (hotkey_ss58, netuid_stakes) in enumerate(hotkey_stakes.items()): + if hk_identity := identities["hotkeys"].get(hotkey_ss58): + hotkey_name = hk_identity.get("identity", {}).get( + "name", "" + ) or hk_identity.get("display", "~") + elif old_identity := old_identities.get(hotkey_ss58): + hotkey_name = old_identity.display + else: + hotkey_name = "~" + # TODO: Add wallet ids here. + + hotkeys_info.append( + { + "index": idx, + "identity": hotkey_name, + "netuids": list(netuid_stakes.keys()), + "hotkey_ss58": hotkey_ss58, + } + ) + + # Display existing hotkeys, id, and staked netuids. + subnet_filter = f" for Subnet {netuid}" if netuid is not None else "" + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Hotkeys with Stakes{subnet_filter}\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + table.add_column("Index", justify="right") + table.add_column("Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]) + table.add_column("Netuids", style=COLOR_PALETTE["GENERAL"]["NETUID"]) + table.add_column("Hotkey Address", style=COLOR_PALETTE["GENERAL"]["HOTKEY"]) + + for hotkey_info in hotkeys_info: + index = str(hotkey_info["index"]) + identity = hotkey_info["identity"] + netuids = group_subnets([n for n in hotkey_info["netuids"]]) + hotkey_ss58 = hotkey_info["hotkey_ss58"] + table.add_row(index, identity, netuids, hotkey_ss58) + + console.print("\n", table) + + # Prompt to select hotkey to unstake. + hotkey_options = [str(hotkey_info["index"]) for hotkey_info in hotkeys_info] + hotkey_idx = Prompt.ask( + "\nEnter the index of the hotkey you want to unstake from", + choices=hotkey_options, + ) + selected_hotkey_info = hotkeys_info[int(hotkey_idx)] + selected_hotkey_ss58 = selected_hotkey_info["hotkey_ss58"] + selected_hotkey_name = selected_hotkey_info["identity"] + netuid_stakes = hotkey_stakes[selected_hotkey_ss58] + + # Display hotkey's staked netuids with amount. + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Stakes for hotkey \n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{selected_hotkey_name}\n{selected_hotkey_ss58}\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + table.add_column("Subnet", justify="right") + table.add_column("Symbol", style=COLOR_PALETTE["GENERAL"]["SYMBOL"]) + table.add_column("Stake Amount", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"]) + table.add_column( + f"[bold white]RATE ({Balance.get_unit(0)}_in/{Balance.get_unit(1)}_in)", + style=COLOR_PALETTE["POOLS"]["RATE"], + justify="left", + ) + + for netuid_, stake_amount in netuid_stakes.items(): + symbol = dynamic_info[netuid_].symbol + rate = f"{dynamic_info[netuid_].price.tao:.4f} τ/{symbol}" + table.add_row(str(netuid_), symbol, str(stake_amount), rate) + console.print("\n", table, "\n") + + # Ask which netuids to unstake from for the selected hotkey. + unstake_all = False + if netuid is not None: + selected_netuids = [netuid] + else: + while True: + netuid_input = Prompt.ask( + "\nEnter the netuids of the [blue]subnets to unstake[/blue] from (comma-separated), or '[blue]all[/blue]' to unstake from all", + default="all", + ) + + if netuid_input.lower() == "all": + selected_netuids = list(netuid_stakes.keys()) + unstake_all = True + break + else: + try: + netuid_list = [int(n.strip()) for n in netuid_input.split(",")] + invalid_netuids = [n for n in netuid_list if n not in netuid_stakes] + if invalid_netuids: + print_error( + f"The following netuids are invalid or not available: {', '.join(map(str, invalid_netuids))}. Please try again." + ) + else: + selected_netuids = netuid_list + break + except ValueError: + print_error( + "Please enter valid netuids (numbers), separated by commas, or 'all'." + ) + + hotkeys_to_unstake_from = [] + for netuid_ in selected_netuids: + hotkeys_to_unstake_from.append( + (selected_hotkey_name, selected_hotkey_ss58, netuid_) + ) + return hotkeys_to_unstake_from, unstake_all + + +def _ask_unstake_amount( + current_stake_balance: Balance, + netuid: int, + staking_address_name: str, + staking_address_ss58: str, + interactive: bool, +) -> Optional[Balance]: + """Prompt the user to decide the amount to unstake. + + Args: + current_stake_balance: The current stake balance available to unstake + netuid: The subnet ID + staking_address_name: Display name of the staking address + staking_address_ss58: SS58 address of the staking address + interactive: Whether in interactive mode (affects default choice) + + Returns: + Balance amount to unstake, or None if user chooses to quit + """ + stake_color = COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"] + display_address = ( + staking_address_name if staking_address_name else staking_address_ss58 + ) + + # First prompt: Ask if user wants to unstake all + unstake_all_prompt = ( + f"Unstake all: [{stake_color}]{current_stake_balance}[/{stake_color}]" + f" from [{stake_color}]{display_address}[/{stake_color}]" + f" on netuid: [{stake_color}]{netuid}[/{stake_color}]? [y/n/q]" + ) + + while True: + response = Prompt.ask( + unstake_all_prompt, + choices=["y", "n", "q"], + default="n", + show_choices=True, + ).lower() + + if response == "q": + return None + if response == "y": + return current_stake_balance + if response != "n": + console.print("[red]Invalid input. Please enter 'y', 'n', or 'q'.[/red]") + continue + + amount_prompt = ( + f"Enter amount to unstake in [{stake_color}]{Balance.get_unit(netuid)}[/{stake_color}]" + f" from subnet: [{stake_color}]{netuid}[/{stake_color}]" + f" (Max: [{stake_color}]{current_stake_balance}[/{stake_color}])" + ) + + while True: + amount_input = Prompt.ask(amount_prompt) + if amount_input.lower() == "q": + return None + + try: + amount_value = float(amount_input) + + # Validate amount + if amount_value <= 0: + console.print("[red]Amount must be greater than zero.[/red]") + continue + + amount_to_unstake = Balance.from_tao(amount_value) + amount_to_unstake.set_unit(netuid) + + if amount_to_unstake > current_stake_balance: + console.print( + f"[red]Amount exceeds current stake balance of {current_stake_balance}.[/red]" + ) + continue + + return amount_to_unstake + + except ValueError: + console.print( + "[red]Invalid input. Please enter a numeric value or 'q' to quit.[/red]" + ) + + +def _get_hotkeys_to_unstake( + wallet: Wallet, + hotkey_ss58_address: Optional[str], + all_hotkeys: bool, + include_hotkeys: list[str], + exclude_hotkeys: list[str], +) -> list[tuple[Optional[str], str]]: + """Get list of hotkeys to unstake from based on input parameters. + + Args: + wallet: The wallet to unstake from + hotkey_ss58_address: Specific hotkey SS58 address to unstake from + all_hotkeys: Whether to unstake from all hotkeys + include_hotkeys: List of hotkey names/addresses to include + exclude_hotkeys: List of hotkey names to exclude + + Returns: + List of tuples containing (hotkey_name, hotkey_ss58) pairs to unstake from + """ + if hotkey_ss58_address: + print_verbose(f"Unstaking from ss58 ({hotkey_ss58_address})") + return [(None, hotkey_ss58_address)] + + if all_hotkeys: + print_verbose("Unstaking from all hotkeys") + all_hotkeys_: list[Wallet] = get_hotkey_wallets_for_wallet(wallet=wallet) + return [ + (wallet.hotkey_str, wallet.hotkey.ss58_address) + for wallet in all_hotkeys_ + if wallet.hotkey_str not in exclude_hotkeys + ] + + if include_hotkeys: + print_verbose("Unstaking from included hotkeys") + result = [] + for hotkey_identifier in include_hotkeys: + if is_valid_ss58_address(hotkey_identifier): + result.append((None, hotkey_identifier)) + else: + wallet_ = Wallet( + name=wallet.name, + path=wallet.path, + hotkey=hotkey_identifier, + ) + result.append((wallet_.hotkey_str, wallet_.hotkey.ss58_address)) + return result + + # Only cli.config.wallet.hotkey is specified + print_verbose( + f"Unstaking from wallet: ({wallet.name}) from hotkey: ({wallet.hotkey_str})" + ) + assert wallet.hotkey is not None + return [(wallet.hotkey_str, wallet.hotkey.ss58_address)] + + +def _create_unstake_table( + wallet_name: str, + wallet_coldkey_ss58: str, + network: str, + total_received_amount: Balance, + safe_staking: bool, + rate_tolerance: float, +) -> Table: + """Create a table summarizing unstake operations. + + Args: + wallet_name: Name of the wallet + wallet_coldkey_ss58: Coldkey SS58 address + network: Network name + total_received_amount: Total amount to be received after unstaking + + Returns: + Rich Table object configured for unstake summary + """ + title = ( + f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Unstaking to: \n" + f"Wallet: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet_name}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}], " + f"Coldkey ss58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet_coldkey_ss58}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n" + f"Network: {network}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n" + ) + table = Table( + title=title, + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column("Netuid", justify="center", style="grey89") + table.add_column( + "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + ) + table.add_column( + f"Amount ({Balance.get_unit(1)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO"], + ) + table.add_column( + f"Rate ({Balance.get_unit(0)}/{Balance.get_unit(1)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + f"Received ({Balance.get_unit(0)})", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], + footer=str(total_received_amount), + ) + table.add_column( + "Slippage", justify="center", style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"] + ) + if safe_staking: + table.add_column( + f"Rate with tolerance: [blue]({rate_tolerance*100}%)[/blue]", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + "Partial unstake enabled", + justify="center", + style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"], + ) + + return table + + +def _print_table_and_slippage( + table: Table, + max_float_slippage: float, + safe_staking: bool, +) -> None: + """Print the unstake summary table and additional information. + + Args: + table: The Rich table containing unstake details + max_float_slippage: Maximum slippage percentage across all operations + """ + console.print(table) + + if max_float_slippage > 5: + console.print( + "\n" + f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]-------------------------------------------------------------------------------------------------------------------\n" + f"[bold]WARNING:[/bold] The slippage on one of your operations is high: [{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{max_float_slippage} %[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]," + " this may result in a loss of funds.\n" + f"-------------------------------------------------------------------------------------------------------------------\n" + ) + base_description = """ +[bold white]Description[/bold white]: +The table displays information about the stake remove operation you are about to perform. +The columns are as follows: + - [bold white]Netuid[/bold white]: The netuid of the subnet you are unstaking from. + - [bold white]Hotkey[/bold white]: The ss58 address or identity of the hotkey you are unstaking from. + - [bold white]Amount to Unstake[/bold white]: The stake amount you are removing from this key. + - [bold white]Rate[/bold white]: The rate of exchange between TAO and the subnet's stake. + - [bold white]Received[/bold white]: The amount of free balance TAO you will receive on this subnet after slippage. + - [bold white]Slippage[/bold white]: The slippage percentage of the unstake operation. (0% if the subnet is not dynamic i.e. root).""" + + safe_staking_description = """ + - [bold white]Rate Tolerance[/bold white]: Maximum acceptable alpha rate. If the rate reduces below this tolerance, the transaction will be limited or rejected. + - [bold white]Partial unstaking[/bold white]: If True, allows unstaking up to the rate tolerance limit. If False, the entire transaction will fail if rate tolerance is exceeded.\n""" + + console.print(base_description + (safe_staking_description if safe_staking else "")) diff --git a/bittensor_cli/src/commands/stake/stake.py b/bittensor_cli/src/commands/stake/stake.py deleted file mode 100644 index 7c7c83fe..00000000 --- a/bittensor_cli/src/commands/stake/stake.py +++ /dev/null @@ -1,1448 +0,0 @@ -import asyncio -import copy -import json -import sqlite3 -from contextlib import suppress - -from typing import TYPE_CHECKING, Optional, Sequence, Union, cast - -from bittensor_wallet import Wallet -from rich.prompt import Confirm -from rich.table import Table, Column -import typer - - -from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.bittensor.utils import ( - console, - create_table, - err_console, - print_verbose, - print_error, - get_coldkey_wallets_for_path, - get_hotkey_wallets_for_wallet, - is_valid_ss58_address, - get_metadata_table, - update_metadata_table, - render_tree, - u16_normalized_float, - validate_coldkey_presence, - unlock_key, -) - -if TYPE_CHECKING: - from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface - - -# Helpers and Extrinsics - - -async def _get_threshold_amount( - subtensor: "SubtensorInterface", block_hash: str -) -> Balance: - mrs = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="NominatorMinRequiredStake", - block_hash=block_hash, - ) - min_req_stake: Balance = Balance.from_rao(mrs) - return min_req_stake - - -async def _check_threshold_amount( - subtensor: "SubtensorInterface", - sb: Balance, - block_hash: str, - min_req_stake: Optional[Balance] = None, -) -> tuple[bool, Balance]: - """ - Checks if the new stake balance will be above the minimum required stake threshold. - - :param sb: the balance to check for threshold limits. - - :return: (success, threshold) - `True` if the staking balance is above the threshold, or `False` if the staking balance is below the - threshold. - The threshold balance required to stake. - """ - if not min_req_stake: - min_req_stake = await _get_threshold_amount(subtensor, block_hash) - - if min_req_stake > sb: - return False, min_req_stake - else: - return True, min_req_stake - - -async def add_stake_extrinsic( - subtensor: "SubtensorInterface", - wallet: Wallet, - old_balance: Balance, - hotkey_ss58: Optional[str] = None, - amount: Optional[Balance] = None, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, - prompt: bool = False, -) -> bool: - """ - Adds the specified amount of stake to passed hotkey `uid`. - - :param subtensor: the initialized SubtensorInterface object to use - :param wallet: Bittensor wallet object. - :param old_balance: the balance prior to the staking - :param hotkey_ss58: The `ss58` address of the hotkey account to stake to defaults to the wallet's hotkey. - :param amount: Amount to stake as Bittensor balance, `None` if staking all. - :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns - `False` if the extrinsic fails to enter the block within the timeout. - :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, - or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. - - :return: success: Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for - finalization/inclusion, the response is `True`. - """ - - # Decrypt keys, - if not unlock_key(wallet).success: - return False - - # Default to wallet's own hotkey if the value is not passed. - if hotkey_ss58 is None: - hotkey_ss58 = wallet.hotkey.ss58_address - - # Flag to indicate if we are using the wallet's own hotkey. - own_hotkey: bool - - with console.status( - f":satellite: Syncing with chain: [white]{subtensor}[/white] ...", - spinner="aesthetic", - ) as status: - block_hash = await subtensor.substrate.get_chain_head() - # Get hotkey owner - print_verbose("Confirming hotkey owner", status) - hotkey_owner = await subtensor.get_hotkey_owner( - hotkey_ss58=hotkey_ss58, block_hash=block_hash - ) - own_hotkey = wallet.coldkeypub.ss58_address == hotkey_owner - if not own_hotkey: - # This is not the wallet's own hotkey, so we are delegating. - if not await subtensor.is_hotkey_delegate( - hotkey_ss58, block_hash=block_hash - ): - err_console.print( - f"Hotkey {hotkey_ss58} is not a delegate on the chain." - ) - return False - - # Get hotkey take - hk_result = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="Delegates", - params=[hotkey_ss58], - block_hash=block_hash, - ) - hotkey_take = u16_normalized_float(hk_result or 0) - else: - hotkey_take = None - - # Get current stake - print_verbose("Fetching current stake", status) - old_stake = await subtensor.get_stake_for_coldkey_and_hotkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=hotkey_ss58, - block_hash=block_hash, - ) - - print_verbose("Fetching existential deposit", status) - # Grab the existential deposit. - existential_deposit = await subtensor.get_existential_deposit() - - # Convert to bittensor.Balance - if amount is None: - # Stake it all. - staking_balance = Balance.from_tao(old_balance.tao) - else: - staking_balance = Balance.from_tao(amount) - - # Leave existential balance to keep key alive. - if staking_balance > old_balance - existential_deposit: - # If we are staking all, we need to leave at least the existential deposit. - staking_balance = old_balance - existential_deposit - else: - staking_balance = staking_balance - - # Check enough to stake. - if staking_balance > old_balance: - err_console.print( - f":cross_mark: [red]Not enough stake[/red]:[bold white]\n" - f"\tbalance:\t{old_balance}\n" - f"\tamount:\t{staking_balance}\n" - f"\tcoldkey:\t{wallet.name}[/bold white]" - ) - return False - - # If nominating, we need to check if the new stake balance will be above the minimum required stake threshold. - if not own_hotkey: - new_stake_balance = old_stake + staking_balance - print_verbose("Fetching threshold amount") - is_above_threshold, threshold = await _check_threshold_amount( - subtensor, new_stake_balance, block_hash - ) - if not is_above_threshold: - err_console.print( - f":cross_mark: [red]New stake balance of {new_stake_balance} is below the minimum required nomination" - f" stake threshold {threshold}.[/red]" - ) - return False - - # Ask before moving on. - if prompt: - if not own_hotkey: - # We are delegating. - if not Confirm.ask( - f"Do you want to delegate:[bold white]\n" - f"\tamount: {staking_balance}\n" - f"\tto: {hotkey_ss58}\n" - f"\ttake: {hotkey_take}\n[/bold white]" - f"\towner: {hotkey_owner}\n" - ): - return False - else: - if not Confirm.ask( - f"Do you want to stake:[bold white]\n" - f"\tamount: {staking_balance}\n" - f"\tto: {wallet.hotkey_str}\n" - f"\taddress: {hotkey_ss58}[/bold white]\n" - ): - return False - - with console.status( - f":satellite: Staking to: [bold white]{subtensor}[/bold white] ...", - spinner="earth", - ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="add_stake", - call_params={"hotkey": hotkey_ss58, "amount_staked": staking_balance.rao}, - ) - staking_response, err_msg = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization - ) - if staking_response is True: # If we successfully staked. - # We only wait here if we expect finalization. - if not wait_for_finalization and not wait_for_inclusion: - return True - - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - with console.status( - f":satellite: Checking Balance on: [white]{subtensor}[/white] ..." - ): - new_block_hash = await subtensor.substrate.get_chain_head() - new_balance, new_stake = await asyncio.gather( - subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=new_block_hash - ), - subtensor.get_stake_for_coldkey_and_hotkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=hotkey_ss58, - block_hash=new_block_hash, - ), - ) - - console.print( - f"Balance:\n" - f"\t[blue]{old_balance}[/blue] :arrow_right: " - f"[green]{new_balance[wallet.coldkeypub.ss58_address]}[/green]" - ) - console.print( - f"Stake:\n" - f"\t[blue]{old_stake}[/blue] :arrow_right: [green]{new_stake}[/green]" - ) - return True - else: - err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - return False - - -async def add_stake_multiple_extrinsic( - subtensor: "SubtensorInterface", - wallet: Wallet, - old_balance: Balance, - hotkey_ss58s: list[str], - amounts: Optional[list[Balance]] = None, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, - prompt: bool = False, -) -> bool: - """Adds stake to each ``hotkey_ss58`` in the list, using each amount, from a common coldkey. - - :param subtensor: The initialized SubtensorInterface object. - :param wallet: Bittensor wallet object for the coldkey. - :param old_balance: The balance of the wallet prior to staking. - :param hotkey_ss58s: List of hotkeys to stake to. - :param amounts: List of amounts to stake. If `None`, stake all to the first hotkey. - :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns - `False` if the extrinsic fails to enter the block within the timeout. - :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, - or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. - - :return: success: `True` if extrinsic was finalized or included in the block. `True` if any wallet was staked. If - we did not wait for finalization/inclusion, the response is `True`. - """ - - if len(hotkey_ss58s) == 0: - return True - - if amounts is not None and len(amounts) != len(hotkey_ss58s): - raise ValueError("amounts must be a list of the same length as hotkey_ss58s") - - new_amounts: Sequence[Optional[Balance]] - if amounts is None: - new_amounts = [None] * len(hotkey_ss58s) - else: - new_amounts = [Balance.from_tao(amount) for amount in amounts] - if sum(amount.tao for amount in new_amounts) == 0: - # Staking 0 tao - return True - - # Decrypt coldkey. - if not unlock_key(wallet).success: - return False - - with console.status( - f":satellite: Syncing with chain: [white]{subtensor}[/white] ..." - ): - block_hash = await subtensor.substrate.get_chain_head() - old_stakes = await asyncio.gather( - *[ - subtensor.get_stake_for_coldkey_and_hotkey( - hk, wallet.coldkeypub.ss58_address, block_hash=block_hash - ) - for hk in hotkey_ss58s - ] - ) - - # Remove existential balance to keep key alive. - ## Keys must maintain a balance of at least 1000 rao to stay alive. - total_staking_rao = sum( - [amount.rao if amount is not None else 0 for amount in new_amounts] - ) - if total_staking_rao == 0: - # Staking all to the first wallet. - if old_balance.rao > 1000: - old_balance -= Balance.from_rao(1000) - - elif total_staking_rao < 1000: - # Staking less than 1000 rao to the wallets. - pass - else: - # Staking more than 1000 rao to the wallets. - ## Reduce the amount to stake to each wallet to keep the balance above 1000 rao. - percent_reduction = 1 - (1000 / total_staking_rao) - new_amounts = [ - Balance.from_tao(amount.tao * percent_reduction) - for amount in cast(Sequence[Balance], new_amounts) - ] - - successful_stakes = 0 - for idx, (hotkey_ss58, amount, old_stake) in enumerate( - zip(hotkey_ss58s, new_amounts, old_stakes) - ): - staking_all = False - # Convert to bittensor.Balance - if amount is None: - # Stake it all. - staking_balance = Balance.from_tao(old_balance.tao) - staking_all = True - else: - # Amounts are cast to balance earlier in the function - assert isinstance(amount, Balance) - staking_balance = amount - - # Check enough to stake - if staking_balance > old_balance: - err_console.print( - f":cross_mark: [red]Not enough balance[/red]:" - f" [green]{old_balance}[/green] to stake: [blue]{staking_balance}[/blue]" - f" from coldkey: [white]{wallet.name}[/white]" - ) - continue - - # Ask before moving on. - if prompt: - if not Confirm.ask( - f"Do you want to stake:\n" - f"\t[bold white]amount: {staking_balance}\n" - f"\thotkey: {wallet.hotkey_str}[/bold white ]?" - ): - continue - - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="add_stake", - call_params={"hotkey": hotkey_ss58, "amount_staked": staking_balance.rao}, - ) - staking_response, err_msg = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization - ) - - if staking_response is True: # If we successfully staked. - # We only wait here if we expect finalization. - - if idx < len(hotkey_ss58s) - 1: - # Wait for tx rate limit. - tx_query = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="TxRateLimit", - block_hash=block_hash, - ) - tx_rate_limit_blocks: int = tx_query - if tx_rate_limit_blocks > 0: - with console.status( - f":hourglass: [yellow]Waiting for tx rate limit:" - f" [white]{tx_rate_limit_blocks}[/white] blocks[/yellow]" - ): - await asyncio.sleep( - tx_rate_limit_blocks * 12 - ) # 12 seconds per block - - if not wait_for_finalization and not wait_for_inclusion: - old_balance -= staking_balance - successful_stakes += 1 - if staking_all: - # If staked all, no need to continue - break - - continue - - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - - new_block_hash = await subtensor.substrate.get_chain_head() - new_stake, new_balance_ = await asyncio.gather( - subtensor.get_stake_for_coldkey_and_hotkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=hotkey_ss58, - block_hash=new_block_hash, - ), - subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=new_block_hash - ), - ) - new_balance = new_balance_[wallet.coldkeypub.ss58_address] - console.print( - "Stake ({}): [blue]{}[/blue] :arrow_right: [green]{}[/green]".format( - hotkey_ss58, old_stake, new_stake - ) - ) - old_balance = new_balance - successful_stakes += 1 - if staking_all: - # If staked all, no need to continue - break - - else: - err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - continue - - if successful_stakes != 0: - with console.status( - f":satellite: Checking Balance on: ([white]{subtensor}[/white] ..." - ): - new_balance_ = await subtensor.get_balance( - wallet.coldkeypub.ss58_address, reuse_block=False - ) - new_balance = new_balance_[wallet.coldkeypub.ss58_address] - console.print( - f"Balance: [blue]{old_balance}[/blue] :arrow_right: [green]{new_balance}[/green]" - ) - return True - - return False - - -async def unstake_extrinsic( - subtensor: "SubtensorInterface", - wallet: Wallet, - hotkey_ss58: Optional[str] = None, - amount: Optional[Balance] = None, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, - prompt: bool = False, -) -> bool: - """Removes stake into the wallet coldkey from the specified hotkey ``uid``. - - :param subtensor: the initialized SubtensorInterface object to use - :param wallet: Bittensor wallet object. - :param hotkey_ss58: The `ss58` address of the hotkey to unstake from. By default, the wallet hotkey is used. - :param amount: Amount to stake as Bittensor balance, or `None` is unstaking all - :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns - `False` if the extrinsic fails to enter the block within the timeout. - :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, - or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. - - :return: success: `True` if extrinsic was finalized or included in the block. If we did not wait for - finalization/inclusion, the response is `True`. - """ - # Decrypt coldkey - if not unlock_key(wallet).success: - return False - - if hotkey_ss58 is None: - hotkey_ss58 = wallet.hotkey.ss58_address # Default to wallet's own hotkey. - - with console.status( - f":satellite: Syncing with chain: [white]{subtensor}[/white] ...", - spinner="aesthetic", - ) as status: - print_verbose("Fetching balance and stake", status) - block_hash = await subtensor.substrate.get_chain_head() - old_balance, old_stake, hotkey_owner = await asyncio.gather( - subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=block_hash - ), - subtensor.get_stake_for_coldkey_and_hotkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=hotkey_ss58, - block_hash=block_hash, - ), - subtensor.get_hotkey_owner(hotkey_ss58, block_hash), - ) - - own_hotkey: bool = wallet.coldkeypub.ss58_address == hotkey_owner - - # Convert to bittensor.Balance - if amount is None: - # Unstake it all. - unstaking_balance = old_stake - else: - unstaking_balance = Balance.from_tao(amount) - - # Check enough to unstake. - stake_on_uid = old_stake - if unstaking_balance > stake_on_uid: - err_console.print( - f":cross_mark: [red]Not enough stake[/red]: " - f"[green]{stake_on_uid}[/green] to unstake: " - f"[blue]{unstaking_balance}[/blue] from hotkey:" - f" [white]{wallet.hotkey_str}[/white]" - ) - return False - - print_verbose("Fetching threshold amount") - # If nomination stake, check threshold. - if not own_hotkey and not await _check_threshold_amount( - subtensor=subtensor, - sb=(stake_on_uid - unstaking_balance), - block_hash=block_hash, - ): - console.print( - ":warning: [yellow]This action will unstake the entire staked balance![/yellow]" - ) - unstaking_balance = stake_on_uid - - # Ask before moving on. - if prompt: - if not Confirm.ask( - f"Do you want to unstake:\n" - f"[bold white]\tamount: {unstaking_balance}\n" - f"\thotkey: {wallet.hotkey_str}[/bold white ]?" - ): - return False - - with console.status( - f":satellite: Unstaking from chain: [white]{subtensor}[/white] ...", - spinner="earth", - ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="remove_stake", - call_params={ - "hotkey": hotkey_ss58, - "amount_unstaked": unstaking_balance.rao, - }, - ) - staking_response, err_msg = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization - ) - - if staking_response is True: # If we successfully unstaked. - # We only wait here if we expect finalization. - if not wait_for_finalization and not wait_for_inclusion: - return True - - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - with console.status( - f":satellite: Checking Balance on: [white]{subtensor}[/white] ..." - ): - new_block_hash = await subtensor.substrate.get_chain_head() - new_balance, new_stake = await asyncio.gather( - subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=new_block_hash - ), - subtensor.get_stake_for_coldkey_and_hotkey( - hotkey_ss58, wallet.coldkeypub.ss58_address, new_block_hash - ), - ) - console.print( - f"Balance:\n" - f" [blue]{old_balance[wallet.coldkeypub.ss58_address]}[/blue] :arrow_right:" - f" [green]{new_balance[wallet.coldkeypub.ss58_address]}[/green]" - ) - console.print( - f"Stake:\n [blue]{old_stake}[/blue] :arrow_right: [green]{new_stake}[/green]" - ) - return True - else: - err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - return False - - -async def unstake_multiple_extrinsic( - subtensor: "SubtensorInterface", - wallet: Wallet, - hotkey_ss58s: list[str], - amounts: Optional[list[Union[Balance, float]]] = None, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, - prompt: bool = False, -) -> bool: - """ - Removes stake from each `hotkey_ss58` in the list, using each amount, to a common coldkey. - - :param subtensor: the initialized SubtensorInterface object to use - :param wallet: The wallet with the coldkey to unstake to. - :param hotkey_ss58s: List of hotkeys to unstake from. - :param amounts: List of amounts to unstake. If ``None``, unstake all. - :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns - `False` if the extrinsic fails to enter the block within the timeout. - :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, - or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. - - :return: success: `True` if extrinsic was finalized or included in the block. Flag is `True` if any wallet was - unstaked. If we did not wait for finalization/inclusion, the response is `True`. - """ - if not isinstance(hotkey_ss58s, list) or not all( - isinstance(hotkey_ss58, str) for hotkey_ss58 in hotkey_ss58s - ): - raise TypeError("hotkey_ss58s must be a list of str") - - if len(hotkey_ss58s) == 0: - return True - - if amounts is not None and len(amounts) != len(hotkey_ss58s): - raise ValueError("amounts must be a list of the same length as hotkey_ss58s") - - if amounts is not None and not all( - isinstance(amount, (Balance, float)) for amount in amounts - ): - raise TypeError( - "amounts must be a [list of bittensor.Balance or float] or None" - ) - - new_amounts: Sequence[Optional[Balance]] - if amounts is None: - new_amounts = [None] * len(hotkey_ss58s) - else: - new_amounts = [ - Balance(amount) if not isinstance(amount, Balance) else amount - for amount in (amounts or [None] * len(hotkey_ss58s)) - ] - if sum(amount.tao for amount in new_amounts if amount is not None) == 0: - return True - - # Unlock coldkey. - if not unlock_key(wallet).success: - return False - - with console.status( - f":satellite: Syncing with chain: [white]{subtensor}[/white] ..." - ): - block_hash = await subtensor.substrate.get_chain_head() - - old_balance_ = subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=block_hash - ) - old_stakes_ = asyncio.gather( - *[ - subtensor.get_stake_for_coldkey_and_hotkey( - h, wallet.coldkeypub.ss58_address, block_hash - ) - for h in hotkey_ss58s - ] - ) - hotkey_owners_ = asyncio.gather( - *[subtensor.get_hotkey_owner(h, block_hash) for h in hotkey_ss58s] - ) - - old_balance, old_stakes, hotkey_owners, threshold = await asyncio.gather( - old_balance_, - old_stakes_, - hotkey_owners_, - _get_threshold_amount(subtensor, block_hash), - ) - own_hotkeys = [ - wallet.coldkeypub.ss58_address == hotkey_owner - for hotkey_owner in hotkey_owners - ] - - successful_unstakes = 0 - for idx, (hotkey_ss58, amount, old_stake, own_hotkey) in enumerate( - zip(hotkey_ss58s, new_amounts, old_stakes, own_hotkeys) - ): - # Covert to bittensor.Balance - if amount is None: - # Unstake it all. - unstaking_balance = old_stake - else: - unstaking_balance = amount - - # Check enough to unstake. - stake_on_uid = old_stake - if unstaking_balance > stake_on_uid: - err_console.print( - f":cross_mark: [red]Not enough stake[/red]:" - f" [green]{stake_on_uid}[/green] to unstake:" - f" [blue]{unstaking_balance}[/blue] from hotkey:" - f" [white]{wallet.hotkey_str}[/white]" - ) - continue - - # If nomination stake, check threshold. - if ( - not own_hotkey - and ( - await _check_threshold_amount( - subtensor=subtensor, - sb=(stake_on_uid - unstaking_balance), - block_hash=block_hash, - min_req_stake=threshold, - ) - )[0] - is False - ): - console.print( - ":warning: [yellow]This action will unstake the entire staked balance![/yellow]" - ) - unstaking_balance = stake_on_uid - - # Ask before moving on. - if prompt: - if not Confirm.ask( - f"Do you want to unstake:\n" - f"[bold white]\tamount: {unstaking_balance}\n" - f"ss58: {hotkey_ss58}[/bold white ]?" - ): - continue - - with console.status( - f":satellite: Unstaking from chain: [white]{subtensor}[/white] ..." - ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="remove_stake", - call_params={ - "hotkey": hotkey_ss58, - "amount_unstaked": unstaking_balance.rao, - }, - ) - staking_response, err_msg = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization - ) - - if staking_response is True: # If we successfully unstaked. - # We only wait here if we expect finalization. - - if idx < len(hotkey_ss58s) - 1: - # Wait for tx rate limit. - tx_query = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="TxRateLimit", - block_hash=block_hash, - ) - tx_rate_limit_blocks: int = tx_query - - # TODO: Handle in-case we have fast blocks - if tx_rate_limit_blocks > 0: - console.print( - ":hourglass: [yellow]Waiting for tx rate limit:" - f" [white]{tx_rate_limit_blocks}[/white] blocks," - f" estimated time: [white]{tx_rate_limit_blocks * 12} [/white] seconds[/yellow]" - ) - await asyncio.sleep( - tx_rate_limit_blocks * 12 - ) # 12 seconds per block - - if not wait_for_finalization and not wait_for_inclusion: - successful_unstakes += 1 - continue - - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - with console.status( - f":satellite: Checking stake balance on: [white]{subtensor}[/white] ..." - ): - new_stake = await subtensor.get_stake_for_coldkey_and_hotkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=hotkey_ss58, - block_hash=(await subtensor.substrate.get_chain_head()), - ) - console.print( - "Stake ({}): [blue]{}[/blue] :arrow_right: [green]{}[/green]".format( - hotkey_ss58, stake_on_uid, new_stake - ) - ) - successful_unstakes += 1 - else: - err_console.print(":cross_mark: [red]Failed[/red]: Unknown Error.") - continue - - if successful_unstakes != 0: - with console.status( - f":satellite: Checking balance on: ([white]{subtensor}[/white] ..." - ): - new_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) - console.print( - f"Balance: [blue]{old_balance[wallet.coldkeypub.ss58_address]}[/blue]" - f" :arrow_right: [green]{new_balance[wallet.coldkeypub.ss58_address]}[/green]" - ) - return True - - return False - - -# Commands - - -async def show( - wallet: Wallet, - subtensor: Optional["SubtensorInterface"], - all_wallets: bool, - reuse_last: bool, - html_output: bool, - no_cache: bool, -): - """Show all stake accounts.""" - - async def get_stake_accounts( - wallet_, block_hash: str - ) -> dict[str, Union[str, Balance, dict[str, Union[str, Balance]]]]: - """Get stake account details for the given wallet. - - :param wallet_: The wallet object to fetch the stake account details for. - - :return: A dictionary mapping SS58 addresses to their respective stake account details. - """ - - wallet_stake_accounts = {} - - # Get this wallet's coldkey balance. - cold_balance_, stakes_from_hk, stakes_from_d = await asyncio.gather( - subtensor.get_balance( - wallet_.coldkeypub.ss58_address, block_hash=block_hash - ), - get_stakes_from_hotkeys(wallet_, block_hash=block_hash), - get_stakes_from_delegates(wallet_), - ) - - cold_balance = cold_balance_[wallet_.coldkeypub.ss58_address] - - # Populate the stake accounts with local hotkeys data. - wallet_stake_accounts.update(stakes_from_hk) - - # Populate the stake accounts with delegations data. - wallet_stake_accounts.update(stakes_from_d) - - return { - "name": wallet_.name, - "balance": cold_balance, - "accounts": wallet_stake_accounts, - } - - async def get_stakes_from_hotkeys( - wallet_, block_hash: str - ) -> dict[str, dict[str, Union[str, Balance]]]: - """Fetch stakes from hotkeys for the provided wallet. - - :param wallet_: The wallet object to fetch the stakes for. - - :return: A dictionary of stakes related to hotkeys. - """ - - async def get_all_neurons_for_pubkey(hk): - netuids = await subtensor.get_netuids_for_hotkey(hk, block_hash=block_hash) - uid_query = await asyncio.gather( - *[ - subtensor.substrate.query( - module="SubtensorModule", - storage_function="Uids", - params=[netuid, hk], - block_hash=block_hash, - ) - for netuid in netuids - ] - ) - uids = [_result for _result in uid_query] - neurons = await asyncio.gather( - *[ - subtensor.neuron_for_uid(uid, net) - for (uid, net) in zip(uids, netuids) - ] - ) - return neurons - - async def get_emissions_and_stake(hk: str): - neurons, stake = await asyncio.gather( - get_all_neurons_for_pubkey(hk), - subtensor.substrate.query( - module="SubtensorModule", - storage_function="Stake", - params=[hk, wallet_.coldkeypub.ss58_address], - block_hash=block_hash, - ), - ) - emission_ = sum([n.emission for n in neurons]) if neurons else 0.0 - return emission_, Balance.from_rao(stake) if stake else Balance(0) - - hotkeys = cast(list[Wallet], get_hotkey_wallets_for_wallet(wallet_)) - stakes = {} - query = await asyncio.gather( - *[get_emissions_and_stake(hot.hotkey.ss58_address) for hot in hotkeys] - ) - for hot, (emission, hotkey_stake) in zip(hotkeys, query): - stakes[hot.hotkey.ss58_address] = { - "name": hot.hotkey_str, - "stake": hotkey_stake, - "rate": emission, - } - return stakes - - async def get_stakes_from_delegates( - wallet_, - ) -> dict[str, dict[str, Union[str, Balance]]]: - """Fetch stakes from delegates for the provided wallet. - - :param wallet_: The wallet object to fetch the stakes for. - - :return: A dictionary of stakes related to delegates. - """ - delegates = await subtensor.get_delegated( - coldkey_ss58=wallet_.coldkeypub.ss58_address, block_hash=None - ) - stakes = {} - for dele, staked in delegates: - for nom in dele.nominators: - if nom[0] == wallet_.coldkeypub.ss58_address: - delegate_name = ( - registered_delegate_info[dele.hotkey_ss58].display - if dele.hotkey_ss58 in registered_delegate_info - else None - ) - stakes[dele.hotkey_ss58] = { - "name": delegate_name if delegate_name else dele.hotkey_ss58, - "stake": nom[1], - "rate": dele.total_daily_return.tao - * (nom[1] / dele.total_stake.tao), - } - return stakes - - async def get_all_wallet_accounts( - block_hash: str, - ) -> list[dict[str, Union[str, Balance, dict[str, Union[str, Balance]]]]]: - """Fetch stake accounts for all provided wallets using a ThreadPool. - - :param block_hash: The block hash to fetch the stake accounts for. - - :return: A list of dictionaries, each dictionary containing stake account details for each wallet. - """ - - accounts_ = await asyncio.gather( - *[get_stake_accounts(w, block_hash=block_hash) for w in wallets] - ) - return accounts_ - - if not reuse_last: - cast("SubtensorInterface", subtensor) - if all_wallets: - wallets = get_coldkey_wallets_for_path(wallet.path) - valid_wallets, invalid_wallets = validate_coldkey_presence(wallets) - wallets = valid_wallets - for invalid_wallet in invalid_wallets: - print_error(f"No coldkeypub found for wallet: ({invalid_wallet.name})") - else: - wallets = [wallet] - - with console.status( - ":satellite: Retrieving account data...", spinner="aesthetic" - ): - block_hash_ = await subtensor.substrate.get_chain_head() - registered_delegate_info = await subtensor.get_delegate_identities( - block_hash=block_hash_ - ) - accounts = await get_all_wallet_accounts(block_hash=block_hash_) - - total_stake: float = 0.0 - total_balance: float = 0.0 - total_rate: float = 0.0 - rows = [] - db_rows = [] - for acc in accounts: - cast(str, acc["name"]) - cast(Balance, acc["balance"]) - rows.append([acc["name"], str(acc["balance"]), "", "", ""]) - db_rows.append( - [acc["name"], float(acc["balance"]), None, None, None, None, 0] - ) - total_balance += cast(Balance, acc["balance"]).tao - for key, value in cast(dict, acc["accounts"]).items(): - if value["name"] and value["name"] != key: - account_display_name = f"{value['name']}" - else: - account_display_name = "(~)" - rows.append( - [ - "", - "", - account_display_name, - key, - str(value["stake"]), - str(value["rate"]), - ] - ) - db_rows.append( - [ - acc["name"], - None, - value["name"], - float(value["stake"]), - float(value["rate"]), - key, - 1, - ] - ) - total_stake += cast(Balance, value["stake"]).tao - total_rate += float(value["rate"]) - metadata = { - "total_stake": "\u03c4{:.5f}".format(total_stake), - "total_balance": "\u03c4{:.5f}".format(total_balance), - "total_rate": "\u03c4{:.5f}/d".format(total_rate), - "rows": json.dumps(rows), - } - if not no_cache: - create_table( - "stakeshow", - [ - ("COLDKEY", "TEXT"), - ("BALANCE", "REAL"), - ("ACCOUNT", "TEXT"), - ("STAKE", "REAL"), - ("RATE", "REAL"), - ("HOTKEY", "TEXT"), - ("CHILD", "INTEGER"), - ], - db_rows, - ) - update_metadata_table("stakeshow", metadata) - else: - try: - metadata = get_metadata_table("stakeshow") - rows = json.loads(metadata["rows"]) - except sqlite3.OperationalError: - err_console.print( - "[red]Error[/red] Unable to retrieve table data. This is usually caused by attempting to use " - "`--reuse-last` before running the command a first time. In rare cases, this could also be due to " - "a corrupted database. Re-run the command (do not use `--reuse-last`) and see if that resolves your " - "issue." - ) - return - if not html_output: - table = Table( - Column("[bold white]Coldkey", style="dark_orange", ratio=1), - Column( - "[bold white]Balance", - metadata["total_balance"], - style="dark_sea_green", - ratio=1, - ), - Column("[bold white]Account", style="bright_cyan", ratio=3), - Column("[bold white]Hotkey", ratio=7, no_wrap=True, style="bright_magenta"), - Column( - "[bold white]Stake", - metadata["total_stake"], - style="light_goldenrod2", - ratio=1, - ), - Column( - "[bold white]Rate /d", - metadata["total_rate"], - style="rgb(42,161,152)", - ratio=1, - ), - title=f"[underline dark_orange]Stake Show[/underline dark_orange]\n[dark_orange]Network: {subtensor.network}\n", - show_footer=True, - show_edge=False, - expand=False, - border_style="bright_black", - ) - - for i, row in enumerate(rows): - is_last_row = i + 1 == len(rows) - table.add_row(*row) - - # If last row or new coldkey starting next - if is_last_row or (rows[i + 1][0] != ""): - table.add_row(end_section=True) - console.print(table) - - else: - render_tree( - "stakeshow", - f"Stakes | Total Balance: {metadata['total_balance']} - Total Stake: {metadata['total_stake']} " - f"Total Rate: {metadata['total_rate']}", - [ - {"title": "Coldkey", "field": "COLDKEY"}, - { - "title": "Balance", - "field": "BALANCE", - "formatter": "money", - "formatterParams": {"symbol": "τ", "precision": 5}, - }, - { - "title": "Account", - "field": "ACCOUNT", - "width": 425, - }, - { - "title": "Stake", - "field": "STAKE", - "formatter": "money", - "formatterParams": {"symbol": "τ", "precision": 5}, - }, - { - "title": "Daily Rate", - "field": "RATE", - "formatter": "money", - "formatterParams": {"symbol": "τ", "precision": 5}, - }, - { - "title": "Hotkey", - "field": "HOTKEY", - "width": 425, - }, - ], - 0, - ) - - -async def stake_add( - wallet: Wallet, - subtensor: "SubtensorInterface", - amount: float, - stake_all: bool, - max_stake: float, - include_hotkeys: list[str], - exclude_hotkeys: list[str], - all_hotkeys: bool, - prompt: bool, - hotkey_ss58: Optional[str] = None, -) -> None: - """Stake token of amount to hotkey(s).""" - - async def is_hotkey_registered_any(hk: str, bh: str) -> bool: - return len(await subtensor.get_netuids_for_hotkey(hk, bh)) > 0 - - # Get the hotkey_names (if any) and the hotkey_ss58s. - hotkeys_to_stake_to: list[tuple[Optional[str], str]] = [] - if hotkey_ss58: - if not is_valid_ss58_address(hotkey_ss58): - print_error("The entered ss58 address is incorrect") - typer.Exit() - - # Stake to specific hotkey. - hotkeys_to_stake_to = [(None, hotkey_ss58)] - elif all_hotkeys: - # Stake to all hotkeys. - all_hotkeys_: list[Wallet] = get_hotkey_wallets_for_wallet(wallet=wallet) - # Get the hotkeys to exclude. (d)efault to no exclusions. - # Exclude hotkeys that are specified. - hotkeys_to_stake_to = [ - (wallet.hotkey_str, wallet.hotkey.ss58_address) - for wallet in all_hotkeys_ - if wallet.hotkey_str not in exclude_hotkeys - and wallet.hotkey.ss58_address not in exclude_hotkeys - ] # definitely wallets - - elif include_hotkeys: - print_verbose("Staking to only included hotkeys") - # Stake to specific hotkeys. - for hotkey_ss58_or_hotkey_name in include_hotkeys: - if is_valid_ss58_address(hotkey_ss58_or_hotkey_name): - # If the hotkey is a valid ss58 address, we add it to the list. - hotkeys_to_stake_to.append((None, hotkey_ss58_or_hotkey_name)) - else: - # If the hotkey is not a valid ss58 address, we assume it is a hotkey name. - # We then get the hotkey from the wallet and add it to the list. - wallet_ = Wallet( - path=wallet.path, - name=wallet.name, - hotkey=hotkey_ss58_or_hotkey_name, - ) - hotkeys_to_stake_to.append( - (wallet_.hotkey_str, wallet_.hotkey.ss58_address) - ) - else: - # Only config.wallet.hotkey is specified. - # so we stake to that single hotkey. - print_verbose( - f"Staking to hotkey: ({wallet.hotkey_str}) in wallet: ({wallet.name})" - ) - assert wallet.hotkey is not None - hotkey_ss58_or_name = wallet.hotkey.ss58_address - hotkeys_to_stake_to = [(None, hotkey_ss58_or_name)] - - try: - # Get coldkey balance - print_verbose("Fetching coldkey balance") - wallet_balance_: dict[str, Balance] = await subtensor.get_balance( - wallet.coldkeypub.ss58_address - ) - block_hash = subtensor.substrate.last_block_hash - wallet_balance: Balance = wallet_balance_[wallet.coldkeypub.ss58_address] - old_balance = copy.copy(wallet_balance) - final_hotkeys: list[tuple[Optional[str], str]] = [] - final_amounts: list[Union[float, Balance]] = [] - hotkey: tuple[Optional[str], str] # (hotkey_name (or None), hotkey_ss58) - - print_verbose("Checking if hotkeys are registered") - registered_ = asyncio.gather( - *[is_hotkey_registered_any(h[1], block_hash) for h in hotkeys_to_stake_to] - ) - if max_stake: - hotkey_stakes_ = asyncio.gather( - *[ - subtensor.get_stake_for_coldkey_and_hotkey( - hotkey_ss58=h[1], - coldkey_ss58=wallet.coldkeypub.ss58_address, - block_hash=block_hash, - ) - for h in hotkeys_to_stake_to - ] - ) - else: - - async def null(): - return [None] * len(hotkeys_to_stake_to) - - hotkey_stakes_ = null() - registered: list[bool] - hotkey_stakes: list[Optional[Balance]] - registered, hotkey_stakes = await asyncio.gather(registered_, hotkey_stakes_) - - for hotkey, reg, hotkey_stake in zip( - hotkeys_to_stake_to, registered, hotkey_stakes - ): - if not reg: - # Hotkey is not registered. - if len(hotkeys_to_stake_to) == 1: - # Only one hotkey, error - err_console.print( - f"[red]Hotkey [bold]{hotkey[1]}[/bold] is not registered. Aborting.[/red]" - ) - raise ValueError - else: - # Otherwise, print warning and skip - console.print( - f"[yellow]Hotkey [bold]{hotkey[1]}[/bold] is not registered. Skipping.[/yellow]" - ) - continue - - stake_amount_tao: float = amount - if max_stake: - stake_amount_tao = max_stake - hotkey_stake.tao - - # If the max_stake is greater than the current wallet balance, stake the entire balance. - stake_amount_tao = min(stake_amount_tao, wallet_balance.tao) - if ( - stake_amount_tao <= 0.00001 - ): # Threshold because of fees, might create a loop otherwise - # Skip hotkey if max_stake is less than current stake. - continue - wallet_balance = Balance.from_tao(wallet_balance.tao - stake_amount_tao) - - if wallet_balance.tao < 0: - # No more balance to stake. - break - - final_amounts.append(stake_amount_tao) - final_hotkeys.append(hotkey) # add both the name and the ss58 address. - - if len(final_hotkeys) == 0: - # No hotkeys to stake to. - err_console.print( - "Not enough balance to stake to any hotkeys or max_stake is less than current stake." - ) - raise ValueError - - if len(final_hotkeys) == 1: - # do regular stake - await add_stake_extrinsic( - subtensor, - wallet=wallet, - old_balance=old_balance, - hotkey_ss58=final_hotkeys[0][1], - amount=None if stake_all else final_amounts[0], - wait_for_inclusion=True, - prompt=prompt, - ) - else: - await add_stake_multiple_extrinsic( - subtensor, - wallet=wallet, - old_balance=old_balance, - hotkey_ss58s=[hotkey_ss58 for _, hotkey_ss58 in final_hotkeys], - amounts=None if stake_all else final_amounts, - wait_for_inclusion=True, - prompt=prompt, - ) - except ValueError: - pass - - -async def unstake( - wallet: Wallet, - subtensor: "SubtensorInterface", - hotkey_ss58_address: str, - all_hotkeys: bool, - include_hotkeys: list[str], - exclude_hotkeys: list[str], - amount: float, - keep_stake: float, - unstake_all: bool, - prompt: bool, -): - """Unstake token of amount from hotkey(s).""" - - # Get the hotkey_names (if any) and the hotkey_ss58s. - hotkeys_to_unstake_from: list[tuple[Optional[str], str]] = [] - if hotkey_ss58_address: - print_verbose(f"Unstaking from ss58 ({hotkey_ss58_address})") - # Unstake to specific hotkey. - hotkeys_to_unstake_from = [(None, hotkey_ss58_address)] - elif all_hotkeys: - print_verbose("Unstaking from all hotkeys") - # Unstake to all hotkeys. - all_hotkeys_: list[Wallet] = get_hotkey_wallets_for_wallet(wallet=wallet) - # Exclude hotkeys that are specified. - hotkeys_to_unstake_from = [ - (wallet.hotkey_str, wallet.hotkey.ss58_address) - for wallet in all_hotkeys_ - if wallet.hotkey_str not in exclude_hotkeys - and wallet.hotkey.ss58_address not in hotkeys_to_unstake_from - ] # definitely wallets - - elif include_hotkeys: - print_verbose("Unstaking from included hotkeys") - # Unstake to specific hotkeys. - for hotkey_ss58_or_hotkey_name in include_hotkeys: - if is_valid_ss58_address(hotkey_ss58_or_hotkey_name): - # If the hotkey is a valid ss58 address, we add it to the list. - hotkeys_to_unstake_from.append((None, hotkey_ss58_or_hotkey_name)) - else: - # If the hotkey is not a valid ss58 address, we assume it is a hotkey name. - # We then get the hotkey from the wallet and add it to the list. - wallet_ = Wallet( - name=wallet.name, - path=wallet.path, - hotkey=hotkey_ss58_or_hotkey_name, - ) - hotkeys_to_unstake_from.append( - (wallet_.hotkey_str, wallet_.hotkey.ss58_address) - ) - else: - # Only cli.config.wallet.hotkey is specified. - # so we stake to that single hotkey. - print_verbose( - f"Unstaking from wallet: ({wallet.name}) from hotkey: ({wallet.hotkey_str})" - ) - assert wallet.hotkey is not None - hotkeys_to_unstake_from = [(None, wallet.hotkey.ss58_address)] - - final_hotkeys: list[tuple[str, str]] = [] - final_amounts: list[Union[float, Balance]] = [] - hotkey: tuple[Optional[str], str] # (hotkey_name (or None), hotkey_ss58) - with suppress(ValueError): - with console.status( - f":satellite:Syncing with chain {subtensor}", spinner="earth" - ) as status: - print_verbose("Fetching stake", status) - block_hash = await subtensor.substrate.get_chain_head() - hotkey_stakes = await asyncio.gather( - *[ - subtensor.get_stake_for_coldkey_and_hotkey( - hotkey_ss58=hotkey[1], - coldkey_ss58=wallet.coldkeypub.ss58_address, - block_hash=block_hash, - ) - for hotkey in hotkeys_to_unstake_from - ] - ) - for hotkey, hotkey_stake in zip(hotkeys_to_unstake_from, hotkey_stakes): - unstake_amount_tao: float = amount - - if unstake_all: - unstake_amount_tao = hotkey_stake.tao - if keep_stake: - # Get the current stake of the hotkey from this coldkey. - unstake_amount_tao = hotkey_stake.tao - keep_stake - amount = unstake_amount_tao - if unstake_amount_tao < 0: - # Skip if max_stake is greater than current stake. - continue - else: - if unstake_amount_tao > hotkey_stake.tao: - # Skip if the specified amount is greater than the current stake. - continue - - final_amounts.append(unstake_amount_tao) - final_hotkeys.append(hotkey) # add both the name and the ss58 address. - - if len(final_hotkeys) == 0: - # No hotkeys to unstake from. - err_console.print( - "Not enough stake to unstake from any hotkeys or max_stake is more than current stake." - ) - return None - - # Ask to unstake - if prompt: - if not Confirm.ask( - f"Do you want to unstake from the following keys to {wallet.name}:\n" - + "".join( - [ - f" [bold white]- {hotkey[0] + ':' if hotkey[0] else ''}{hotkey[1]}: " - f"{f'{amount} {Balance.unit}' if amount else 'All'}[/bold white]\n" - for hotkey, amount in zip(final_hotkeys, final_amounts) - ] - ) - ): - return None - if len(final_hotkeys) == 1: - # do regular unstake - await unstake_extrinsic( - subtensor, - wallet=wallet, - hotkey_ss58=final_hotkeys[0][1], - amount=None if unstake_all else final_amounts[0], - wait_for_inclusion=True, - prompt=prompt, - ) - else: - await unstake_multiple_extrinsic( - subtensor, - wallet=wallet, - hotkey_ss58s=[hotkey_ss58 for _, hotkey_ss58 in final_hotkeys], - amounts=None if unstake_all else final_amounts, - wait_for_inclusion=True, - prompt=prompt, - ) diff --git a/bittensor_cli/src/commands/subnets.py b/bittensor_cli/src/commands/subnets.py deleted file mode 100644 index 39e1231b..00000000 --- a/bittensor_cli/src/commands/subnets.py +++ /dev/null @@ -1,897 +0,0 @@ -import asyncio -import json -import sqlite3 -from textwrap import dedent -from typing import TYPE_CHECKING, Optional, cast - -from bittensor_wallet import Wallet -from rich.prompt import Confirm -from rich.table import Column, Table - -from bittensor_cli.src import DelegatesDetails -from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.bittensor.chain_data import SubnetInfo -from bittensor_cli.src.bittensor.extrinsics.registration import register_extrinsic -from bittensor_cli.src.bittensor.minigraph import MiniGraph -from bittensor_cli.src.commands.root import burned_register_extrinsic -from bittensor_cli.src.commands.wallets import set_id, set_id_prompts -from bittensor_cli.src.bittensor.utils import ( - RAO_PER_TAO, - console, - create_table, - err_console, - print_verbose, - print_error, - format_error_message, - get_metadata_table, - millify, - render_table, - update_metadata_table, - unlock_key, - hex_to_bytes, -) - -if TYPE_CHECKING: - from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface - - -# helpers and extrinsics - - -async def register_subnetwork_extrinsic( - subtensor: "SubtensorInterface", - wallet: Wallet, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = True, - prompt: bool = False, -) -> bool: - """Registers a new subnetwork. - - wallet (bittensor.wallet): - bittensor wallet object. - wait_for_inclusion (bool): - If set, waits for the extrinsic to enter a block before returning ``true``, or returns ``false`` if the extrinsic fails to enter the block within the timeout. - wait_for_finalization (bool): - If set, waits for the extrinsic to be finalized on the chain before returning ``true``, or returns ``false`` if the extrinsic fails to be finalized within the timeout. - prompt (bool): - If true, the call waits for confirmation from the user before proceeding. - Returns: - success (bool): - Flag is ``true`` if extrinsic was finalized or included in the block. - If we did not wait for finalization / inclusion, the response is ``true``. - """ - - async def _find_event_attributes_in_extrinsic_receipt( - response_, event_name: str - ) -> list: - """ - Searches for the attributes of a specified event within an extrinsic receipt. - - :param response_: (substrateinterface.base.ExtrinsicReceipt): The receipt of the extrinsic to be searched. - :param event_name: The name of the event to search for. - - :return: A list of attributes for the specified event. Returns [-1] if the event is not found. - """ - for event in await response_.triggered_events: - # Access the event details - event_details = event["event"] - # Check if the event_id is 'NetworkAdded' - if event_details["event_id"] == event_name: - # Once found, you can access the attributes of the event_name - return event_details["attributes"] - return [-1] - - print_verbose("Fetching balance") - your_balance_ = await subtensor.get_balance(wallet.coldkeypub.ss58_address) - your_balance = your_balance_[wallet.coldkeypub.ss58_address] - - print_verbose("Fetching lock_cost") - burn_cost = await lock_cost(subtensor) - if burn_cost > your_balance: - err_console.print( - f"Your balance of: [green]{your_balance}[/green] is not enough to pay the subnet lock cost of: " - f"[green]{burn_cost}[/green]" - ) - return False - - if prompt: - console.print(f"Your balance is: [green]{your_balance}[/green]") - if not Confirm.ask( - f"Do you want to register a subnet for [green]{burn_cost}[/green]?" - ): - return False - - if not unlock_key(wallet).success: - return False - - with console.status(":satellite: Registering subnet...", spinner="earth"): - substrate = subtensor.substrate - # create extrinsic call - call = await substrate.compose_call( - call_module="SubtensorModule", - call_function="register_network", - call_params={"immunity_period": 0, "reg_allowed": True}, - ) - extrinsic = await substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey - ) - response = await substrate.submit_extrinsic( - extrinsic, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) - - # We only wait here if we expect finalization. - if not wait_for_finalization and not wait_for_inclusion: - return True - - await response.process_events() - if not await response.is_success: - err_console.print( - f":cross_mark: [red]Failed[/red]: {format_error_message(await response.error_message)}" - ) - await asyncio.sleep(0.5) - return False - - # Successful registration, final check for membership - else: - attributes = await _find_event_attributes_in_extrinsic_receipt( - response, "NetworkAdded" - ) - console.print( - f":white_heavy_check_mark: [green]Registered subnetwork with netuid: {attributes[0]}[/green]" - ) - return True - - -# commands - - -async def subnets_list( - subtensor: "SubtensorInterface", reuse_last: bool, html_output: bool, no_cache: bool -): - """List all subnet netuids in the network.""" - - async def _get_all_subnets_info(): - hex_bytes_result = await subtensor.query_runtime_api( - runtime_api="SubnetInfoRuntimeApi", method="get_subnets_info", params=[] - ) - - return SubnetInfo.list_from_vec_u8(hex_to_bytes(hex_bytes_result)) - - if not reuse_last: - subnets: list[SubnetInfo] - delegate_info: dict[str, DelegatesDetails] - - print_verbose("Fetching subnet and delegate information") - subnets, delegate_info = await asyncio.gather( - _get_all_subnets_info(), - subtensor.get_delegate_identities(), - ) - - if not subnets: - err_console.print("[red]No subnets found[/red]") - return - - rows = [] - db_rows = [] - total_neurons = 0 - max_neurons = 0 - - for subnet in subnets: - total_neurons += subnet.subnetwork_n - max_neurons += subnet.max_n - rows.append( - ( - str(subnet.netuid), - str(subnet.subnetwork_n), - str(millify(subnet.max_n)), - f"{subnet.emission_value / RAO_PER_TAO * 100:0.2f}%", - str(subnet.tempo), - f"{subnet.burn!s:8.8}", - str(millify(subnet.difficulty)), - str( - delegate_info[subnet.owner_ss58].display - if subnet.owner_ss58 in delegate_info - else subnet.owner_ss58 - ), - ) - ) - db_rows.append( - [ - int(subnet.netuid), - int(subnet.subnetwork_n), - int(subnet.max_n), # millified in HTML table - float( - subnet.emission_value / RAO_PER_TAO * 100 - ), # shown as percentage in HTML table - int(subnet.tempo), - float(subnet.burn), - int(subnet.difficulty), # millified in HTML table - str( - delegate_info[subnet.owner_ss58].display - if subnet.owner_ss58 in delegate_info - else subnet.owner_ss58 - ), - ] - ) - metadata = { - "network": subtensor.network, - "netuid_count": len(subnets), - "N": total_neurons, - "MAX_N": max_neurons, - "rows": json.dumps(rows), - } - if not no_cache: - create_table( - "subnetslist", - [ - ("NETUID", "INTEGER"), - ("N", "INTEGER"), - ("MAX_N", "BLOB"), - ("EMISSION", "REAL"), - ("TEMPO", "INTEGER"), - ("RECYCLE", "REAL"), - ("DIFFICULTY", "BLOB"), - ("SUDO", "TEXT"), - ], - db_rows, - ) - update_metadata_table("subnetslist", values=metadata) - else: - try: - metadata = get_metadata_table("subnetslist") - rows = json.loads(metadata["rows"]) - except sqlite3.OperationalError: - err_console.print( - "[red]Error[/red] Unable to retrieve table data. This is usually caused by attempting to use " - "`--reuse-last` before running the command a first time. In rare cases, this could also be due to " - "a corrupted database. Re-run the command (do not use `--reuse-last`) and see if that resolves your " - "issue." - ) - return - if not html_output: - table = Table( - title=f"[underline dark_orange]Subnets[/underline dark_orange]\n[dark_orange]Network: {metadata['network']}[/dark_orange]\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, - ) - - table.add_column( - "[bold white]NETUID", - footer=f"[white]{metadata['netuid_count']}[/white]", - style="white", - justify="center", - ) - table.add_column( - "[bold white]N", - footer=f"[white]{metadata['N']}[/white]", - style="bright_cyan", - justify="right", - ) - table.add_column( - "[bold white]MAX_N", - footer=f"[white]{metadata['MAX_N']}[/white]", - style="bright_cyan", - justify="right", - ) - table.add_column( - "[bold white]EMISSION", style="light_goldenrod2", justify="right" - ) - table.add_column("[bold white]TEMPO", style="rgb(42,161,152)", justify="right") - table.add_column("[bold white]RECYCLE", style="light_salmon3", justify="right") - table.add_column("[bold white]POW", style="medium_purple", justify="right") - table.add_column( - "[bold white]SUDO", style="bright_magenta", justify="right", overflow="fold" - ) - - for row in rows: - table.add_row(*row) - - console.print(table) - console.print( - dedent( - """ - Description: - The table displays the list of subnets registered in the Bittensor network. - - NETUID: The network identifier of the subnet. - - N: The current UIDs registered to the network. - - MAX_N: The total UIDs allowed on the network. - - EMISSION: The emission accrued by this subnet in the network. - - TEMPO: A duration of a number of blocks. Several subnet events occur at the end of every tempo period. - - RECYCLE: Cost to register to the subnet. - - POW: Proof of work metric of the subnet. - - SUDO: Owner's identity. - """ - ) - ) - else: - render_table( - "subnetslist", - f"Subnets List | Network: {metadata['network']} - " - f"Netuids: {metadata['netuid_count']} - N: {metadata['N']}", - columns=[ - {"title": "NetUID", "field": "NETUID"}, - {"title": "N", "field": "N"}, - {"title": "MAX_N", "field": "MAX_N", "customFormatter": "millify"}, - { - "title": "EMISSION", - "field": "EMISSION", - "formatter": "money", - "formatterParams": { - "symbolAfter": "p", - "symbol": "%", - "precision": 2, - }, - }, - {"title": "Tempo", "field": "TEMPO"}, - { - "title": "Recycle", - "field": "RECYCLE", - "formatter": "money", - "formatterParams": {"symbol": "τ", "precision": 5}, - }, - { - "title": "Difficulty", - "field": "DIFFICULTY", - "customFormatter": "millify", - }, - {"title": "sudo", "field": "SUDO"}, - ], - ) - - -async def lock_cost(subtensor: "SubtensorInterface") -> Optional[Balance]: - """View locking cost of creating a new subnetwork""" - with console.status( - f":satellite:Retrieving lock cost from {subtensor.network}...", - spinner="aesthetic", - ): - lc = await subtensor.query_runtime_api( - runtime_api="SubnetRegistrationRuntimeApi", - method="get_network_registration_cost", - params=[], - ) - if lc: - lock_cost_ = Balance(lc) - console.print(f"Subnet lock cost: [green]{lock_cost_}[/green]") - return lock_cost_ - else: - err_console.print("Subnet lock cost: [red]Failed to get subnet lock cost[/red]") - return None - - -async def create(wallet: Wallet, subtensor: "SubtensorInterface", prompt: bool): - """Register a subnetwork""" - - # Call register command. - success = await register_subnetwork_extrinsic(subtensor, wallet, prompt=prompt) - if success and prompt: - # Prompt for user to set identity. - do_set_identity = Confirm.ask( - "Subnetwork registered successfully. Would you like to set your identity?" - ) - - if do_set_identity: - id_prompts = set_id_prompts(validator=False) - await set_id(wallet, subtensor, *id_prompts, prompt=prompt) - - -async def pow_register( - wallet: Wallet, - subtensor: "SubtensorInterface", - netuid, - processors, - update_interval, - output_in_place, - verbose, - use_cuda, - dev_id, - threads_per_block, - prompt: bool, -): - """Register neuron.""" - - await register_extrinsic( - subtensor, - wallet=wallet, - netuid=netuid, - prompt=prompt, - tpb=threads_per_block, - update_interval=update_interval, - num_processes=processors, - cuda=use_cuda, - dev_id=dev_id, - output_in_place=output_in_place, - log_verbose=verbose, - ) - - -async def register( - wallet: Wallet, subtensor: "SubtensorInterface", netuid: int, prompt: bool -): - """Register neuron by recycling some TAO.""" - - # Verify subnet exists - print_verbose("Checking subnet status") - block_hash = await subtensor.substrate.get_chain_head() - if not await subtensor.subnet_exists(netuid=netuid, block_hash=block_hash): - err_console.print(f"[red]Subnet {netuid} does not exist[/red]") - return - - # Check current recycle amount - print_verbose("Fetching recycle amount") - current_recycle_, balance_ = await asyncio.gather( - subtensor.get_hyperparameter( - param_name="Burn", netuid=netuid, block_hash=block_hash - ), - subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash=block_hash), - ) - current_recycle = ( - Balance.from_rao(int(current_recycle_)) if current_recycle_ else Balance(0) - ) - balance = balance_[wallet.coldkeypub.ss58_address] - - # Check balance is sufficient - if balance < current_recycle: - err_console.print( - f"[red]Insufficient balance {balance} to register neuron. Current recycle is {current_recycle} TAO[/red]" - ) - return - - if prompt: - if not ( - Confirm.ask( - f"Your balance is: [bold green]{balance}[/bold green]\nThe cost to register by recycle is " - f"[bold red]{current_recycle}[/bold red]\nDo you want to continue?", - default=False, - ) - ): - return - - await burned_register_extrinsic( - subtensor, - wallet=wallet, - netuid=netuid, - prompt=False, - recycle_amount=current_recycle, - old_balance=balance, - ) - - -async def metagraph_cmd( - subtensor: Optional["SubtensorInterface"], - netuid: Optional[int], - reuse_last: bool, - html_output: bool, - no_cache: bool, - display_cols: dict, -): - """Prints an entire metagraph.""" - # TODO allow config to set certain columns - if not reuse_last: - cast("SubtensorInterface", subtensor) - cast(int, netuid) - with console.status( - f":satellite: Syncing with chain: [white]{subtensor.network}[/white] ...", - spinner="aesthetic", - ) as status: - block_hash = await subtensor.substrate.get_chain_head() - - if not await subtensor.subnet_exists(netuid, block_hash): - print_error(f"Subnet with netuid: {netuid} does not exist", status) - return False - - neurons, difficulty_, total_issuance_, block = await asyncio.gather( - subtensor.neurons(netuid, block_hash=block_hash), - subtensor.get_hyperparameter( - param_name="Difficulty", netuid=netuid, block_hash=block_hash - ), - subtensor.substrate.query( - module="SubtensorModule", - storage_function="TotalIssuance", - params=[], - block_hash=block_hash, - ), - subtensor.substrate.get_block_number(block_hash=block_hash), - ) - - difficulty = int(difficulty_) - total_issuance = Balance.from_rao(total_issuance_) - metagraph = MiniGraph( - netuid=netuid, neurons=neurons, subtensor=subtensor, block=block - ) - table_data = [] - db_table = [] - total_stake = 0.0 - total_rank = 0.0 - total_validator_trust = 0.0 - total_trust = 0.0 - total_consensus = 0.0 - total_incentive = 0.0 - total_dividends = 0.0 - total_emission = 0 - for uid in metagraph.uids: - neuron = metagraph.neurons[uid] - ep = metagraph.axons[uid] - row = [ - str(neuron.uid), - "{:.5f}".format(metagraph.total_stake[uid]), - "{:.5f}".format(metagraph.ranks[uid]), - "{:.5f}".format(metagraph.trust[uid]), - "{:.5f}".format(metagraph.consensus[uid]), - "{:.5f}".format(metagraph.incentive[uid]), - "{:.5f}".format(metagraph.dividends[uid]), - "{}".format(int(metagraph.emission[uid] * 1000000000)), - "{:.5f}".format(metagraph.validator_trust[uid]), - "*" if metagraph.validator_permit[uid] else "", - str(metagraph.block.item() - metagraph.last_update[uid].item()), - str(metagraph.active[uid].item()), - ( - ep.ip + ":" + str(ep.port) - if ep.is_serving - else "[light_goldenrod2]none[/light_goldenrod2]" - ), - ep.hotkey[:10], - ep.coldkey[:10], - ] - db_row = [ - neuron.uid, - float(metagraph.total_stake[uid]), - float(metagraph.ranks[uid]), - float(metagraph.trust[uid]), - float(metagraph.consensus[uid]), - float(metagraph.incentive[uid]), - float(metagraph.dividends[uid]), - int(metagraph.emission[uid] * 1000000000), - float(metagraph.validator_trust[uid]), - bool(metagraph.validator_permit[uid]), - metagraph.block.item() - metagraph.last_update[uid].item(), - metagraph.active[uid].item(), - (ep.ip + ":" + str(ep.port) if ep.is_serving else "ERROR"), - ep.hotkey[:10], - ep.coldkey[:10], - ] - db_table.append(db_row) - total_stake += metagraph.total_stake[uid] - total_rank += metagraph.ranks[uid] - total_validator_trust += metagraph.validator_trust[uid] - total_trust += metagraph.trust[uid] - total_consensus += metagraph.consensus[uid] - total_incentive += metagraph.incentive[uid] - total_dividends += metagraph.dividends[uid] - total_emission += int(metagraph.emission[uid] * 1000000000) - table_data.append(row) - metadata_info = { - "stake": str(Balance.from_tao(total_stake)), - "total_stake": "\u03c4{:.5f}".format(total_stake), - "rank": "{:.5f}".format(total_rank), - "validator_trust": "{:.5f}".format(total_validator_trust), - "trust": "{:.5f}".format(total_trust), - "consensus": "{:.5f}".format(total_consensus), - "incentive": "{:.5f}".format(total_incentive), - "dividends": "{:.5f}".format(total_dividends), - "emission": "\u03c1{}".format(int(total_emission)), - "net": f"{subtensor.network}:{metagraph.netuid}", - "block": str(metagraph.block.item()), - "N": f"{sum(metagraph.active.tolist())}/{metagraph.n.item()}", - "N0": str(sum(metagraph.active.tolist())), - "N1": str(metagraph.n.item()), - "issuance": str(total_issuance), - "difficulty": str(difficulty), - "total_neurons": str(len(metagraph.uids)), - "table_data": json.dumps(table_data), - } - if not no_cache: - update_metadata_table("metagraph", metadata_info) - create_table( - "metagraph", - columns=[ - ("UID", "INTEGER"), - ("STAKE", "REAL"), - ("RANK", "REAL"), - ("TRUST", "REAL"), - ("CONSENSUS", "REAL"), - ("INCENTIVE", "REAL"), - ("DIVIDENDS", "REAL"), - ("EMISSION", "INTEGER"), - ("VTRUST", "REAL"), - ("VAL", "INTEGER"), - ("UPDATED", "INTEGER"), - ("ACTIVE", "INTEGER"), - ("AXON", "TEXT"), - ("HOTKEY", "TEXT"), - ("COLDKEY", "TEXT"), - ], - rows=db_table, - ) - else: - try: - metadata_info = get_metadata_table("metagraph") - table_data = json.loads(metadata_info["table_data"]) - except sqlite3.OperationalError: - err_console.print( - "[red]Error[/red] Unable to retrieve table data. This is usually caused by attempting to use " - "`--reuse-last` before running the command a first time. In rare cases, this could also be due to " - "a corrupted database. Re-run the command (do not use `--reuse-last`) and see if that resolves your " - "issue." - ) - return - - if html_output: - try: - render_table( - table_name="metagraph", - table_info=f"Metagraph | " - f"net: {metadata_info['net']}, " - f"block: {metadata_info['block']}, " - f"N: {metadata_info['N']}, " - f"stake: {metadata_info['stake']}, " - f"issuance: {metadata_info['issuance']}, " - f"difficulty: {metadata_info['difficulty']}", - columns=[ - {"title": "UID", "field": "UID"}, - { - "title": "Stake", - "field": "STAKE", - "formatter": "money", - "formatterParams": {"symbol": "τ", "precision": 5}, - }, - { - "title": "Rank", - "field": "RANK", - "formatter": "money", - "formatterParams": {"precision": 5}, - }, - { - "title": "Trust", - "field": "TRUST", - "formatter": "money", - "formatterParams": {"precision": 5}, - }, - { - "title": "Consensus", - "field": "CONSENSUS", - "formatter": "money", - "formatterParams": {"precision": 5}, - }, - { - "title": "Incentive", - "field": "INCENTIVE", - "formatter": "money", - "formatterParams": {"precision": 5}, - }, - { - "title": "Dividends", - "field": "DIVIDENDS", - "formatter": "money", - "formatterParams": {"precision": 5}, - }, - {"title": "Emission", "field": "EMISSION"}, - { - "title": "VTrust", - "field": "VTRUST", - "formatter": "money", - "formatterParams": {"precision": 5}, - }, - {"title": "Validated", "field": "VAL"}, - {"title": "Updated", "field": "UPDATED"}, - {"title": "Active", "field": "ACTIVE"}, - {"title": "Axon", "field": "AXON"}, - {"title": "Hotkey", "field": "HOTKEY"}, - {"title": "Coldkey", "field": "COLDKEY"}, - ], - ) - except sqlite3.OperationalError: - err_console.print( - "[red]Error[/red] Unable to retrieve table data. This may indicate that your database is corrupted, " - "or was not able to load with the most recent data." - ) - return - else: - cols: dict[str, tuple[int, Column]] = { - "UID": ( - 0, - Column( - "[bold white]UID", - footer=f"[white]{metadata_info['total_neurons']}[/white]", - style="white", - justify="right", - ratio=0.75, - ), - ), - "STAKE": ( - 1, - Column( - "[bold white]STAKE(\u03c4)", - footer=metadata_info["total_stake"], - style="bright_cyan", - justify="right", - no_wrap=True, - ratio=1.5, - ), - ), - "RANK": ( - 2, - Column( - "[bold white]RANK", - footer=metadata_info["rank"], - style="medium_purple", - justify="right", - no_wrap=True, - ratio=1, - ), - ), - "TRUST": ( - 3, - Column( - "[bold white]TRUST", - footer=metadata_info["trust"], - style="dark_sea_green", - justify="right", - no_wrap=True, - ratio=1, - ), - ), - "CONSENSUS": ( - 4, - Column( - "[bold white]CONSENSUS", - footer=metadata_info["consensus"], - style="rgb(42,161,152)", - justify="right", - no_wrap=True, - ratio=1, - ), - ), - "INCENTIVE": ( - 5, - Column( - "[bold white]INCENTIVE", - footer=metadata_info["incentive"], - style="#5fd7ff", - justify="right", - no_wrap=True, - ratio=1, - ), - ), - "DIVIDENDS": ( - 6, - Column( - "[bold white]DIVIDENDS", - footer=metadata_info["dividends"], - style="#8787d7", - justify="right", - no_wrap=True, - ratio=1, - ), - ), - "EMISSION": ( - 7, - Column( - "[bold white]EMISSION(\u03c1)", - footer=metadata_info["emission"], - style="#d7d7ff", - justify="right", - no_wrap=True, - ratio=1.5, - ), - ), - "VTRUST": ( - 8, - Column( - "[bold white]VTRUST", - footer=metadata_info["validator_trust"], - style="magenta", - justify="right", - no_wrap=True, - ratio=1, - ), - ), - "VAL": ( - 9, - Column( - "[bold white]VAL", - justify="center", - style="bright_white", - no_wrap=True, - ratio=0.4, - ), - ), - "UPDATED": ( - 10, - Column("[bold white]UPDATED", justify="right", no_wrap=True, ratio=1), - ), - "ACTIVE": ( - 11, - Column( - "[bold white]ACTIVE", - justify="center", - style="#8787ff", - no_wrap=True, - ratio=1, - ), - ), - "AXON": ( - 12, - Column( - "[bold white]AXON", - justify="left", - style="dark_orange", - overflow="fold", - ratio=2, - ), - ), - "HOTKEY": ( - 13, - Column( - "[bold white]HOTKEY", - justify="center", - style="bright_magenta", - overflow="fold", - ratio=1.5, - ), - ), - "COLDKEY": ( - 14, - Column( - "[bold white]COLDKEY", - justify="center", - style="bright_magenta", - overflow="fold", - ratio=1.5, - ), - ), - } - table_cols: list[Column] = [] - table_cols_indices: list[int] = [] - for k, (idx, v) in cols.items(): - if display_cols[k] is True: - table_cols_indices.append(idx) - table_cols.append(v) - - table = Table( - *table_cols, - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_style="bold white", - title_justify="center", - show_lines=False, - expand=True, - title=( - f"[underline dark_orange]Metagraph[/underline dark_orange]\n\n" - f"Net: [bright_cyan]{metadata_info['net']}[/bright_cyan], " - f"Block: [bright_cyan]{metadata_info['block']}[/bright_cyan], " - f"N: [bright_green]{metadata_info['N0']}[/bright_green]/[bright_red]{metadata_info['N1']}[/bright_red], " - f"Stake: [dark_orange]{metadata_info['stake']}[/dark_orange], " - f"Issuance: [bright_blue]{metadata_info['issuance']}[/bright_blue], " - f"Difficulty: [bright_cyan]{metadata_info['difficulty']}[/bright_cyan]\n" - ), - pad_edge=True, - ) - - if all(x is False for x in display_cols.values()): - console.print("You have selected no columns to display in your config.") - table.add_row(" " * 256) # allows title to be printed - elif any(x is False for x in display_cols.values()): - console.print( - "Limiting column display output based on your config settings. Hiding columns " - f"{', '.join([k for (k, v) in display_cols.items() if v is False])}" - ) - for row in table_data: - new_row = [row[idx] for idx in table_cols_indices] - table.add_row(*new_row) - else: - for row in table_data: - table.add_row(*row) - - console.print(table) diff --git a/bittensor_cli/src/commands/subnets/__init__.py b/bittensor_cli/src/commands/subnets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bittensor_cli/src/commands/subnets/price.py b/bittensor_cli/src/commands/subnets/price.py new file mode 100644 index 00000000..eb6b7b5c --- /dev/null +++ b/bittensor_cli/src/commands/subnets/price.py @@ -0,0 +1,867 @@ +import asyncio +import json +import math +from pywry import PyWry +from typing import TYPE_CHECKING + +import plotille +import plotly.graph_objects as go + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, + get_subnet_name, + print_error, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def price( + subtensor: "SubtensorInterface", + netuids: list[int], + all_netuids: bool = False, + interval_hours: int = 24, + html_output: bool = False, + log_scale: bool = False, +): + """ + Fetch historical price data for subnets and display it in a chart. + """ + if all_netuids: + netuids = [nid for nid in await subtensor.get_all_subnet_netuids() if nid != 0] + + blocks_per_hour = int(3600 / 12) # ~300 blocks per hour + total_blocks = blocks_per_hour * interval_hours + + with console.status(":chart_increasing: Fetching historical price data..."): + current_block_hash = await subtensor.substrate.get_chain_head() + current_block = await subtensor.substrate.get_block_number(current_block_hash) + + step = 300 + start_block = max(0, current_block - total_blocks) + block_numbers = list(range(start_block, current_block + 1, step)) + + # Block hashes + block_hash_cors = [ + subtensor.substrate.get_block_hash(bn) for bn in block_numbers + ] + block_hashes = await asyncio.gather(*block_hash_cors) + + # We fetch all subnets when there is more than one netuid + if all_netuids or len(netuids) > 1: + subnet_info_cors = [subtensor.all_subnets(bh) for bh in block_hashes] + else: + # If there is only one netuid, we fetch the subnet info for that netuid + netuid = netuids[0] + subnet_info_cors = [subtensor.subnet(netuid, bh) for bh in block_hashes] + all_subnet_infos = await asyncio.gather(*subnet_info_cors) + + subnet_data = _process_subnet_data( + block_numbers, all_subnet_infos, netuids, all_netuids, interval_hours + ) + + if not subnet_data: + err_console.print("[red]No valid price data found for any subnet[/red]") + return + + if html_output: + await _generate_html_output( + subnet_data, block_numbers, interval_hours, log_scale + ) + else: + _generate_cli_output(subnet_data, block_numbers, interval_hours, log_scale) + + +def _process_subnet_data( + block_numbers, + all_subnet_infos, + netuids, + all_netuids, + interval_hours, +): + """ + Process subnet data into a structured format for price analysis. + """ + subnet_data = {} + if all_netuids or len(netuids) > 1: + for netuid in netuids: + prices = [] + valid_subnet_infos = [] + for _, subnet_infos in zip(block_numbers, all_subnet_infos): + subnet_info = next( + (s for s in subnet_infos if s.netuid == netuid), None + ) + if subnet_info: + prices.append(subnet_info.price.tao) + valid_subnet_infos.append(subnet_info) + + if not valid_subnet_infos or not prices: + # No valid data found for this netuid + continue + + if len(prices) < 5: + err_console.print( + f"[red]Insufficient price data for subnet {netuid}. " + f"Need at least 5 data points but only found {len(prices)}.[/red]" + ) + continue + + # Most recent data for statistics + latest_subnet_data = valid_subnet_infos[-1] + stats = { + "current_price": prices[-1], + "high": max(prices), + "low": min(prices), + "change_pct": ((prices[-1] - prices[0]) / prices[0] * 100), + "supply": latest_subnet_data.alpha_in.tao + + latest_subnet_data.alpha_out.tao, + "market_cap": latest_subnet_data.price.tao + * (latest_subnet_data.alpha_in.tao + latest_subnet_data.alpha_out.tao), + "emission": latest_subnet_data.emission.tao, + "stake": latest_subnet_data.alpha_out.tao, + "symbol": latest_subnet_data.symbol, + "name": get_subnet_name(latest_subnet_data), + } + subnet_data[netuid] = { + "prices": prices, + "stats": stats, + } + + else: + prices = [] + valid_subnet_infos = [] + for _, subnet_info in zip(block_numbers, all_subnet_infos): + if subnet_info: + prices.append(subnet_info.price.tao) + valid_subnet_infos.append(subnet_info) + + if not valid_subnet_infos or not prices: + err_console.print("[red]No valid price data found for any subnet[/red]") + return {} + + if len(prices) < 5: + err_console.print( + f"[red]Insufficient price data for subnet {netuids[0]}. " + f"Need at least 5 data points but only found {len(prices)}.[/red]" + ) + return {} + + # Most recent data for statistics + latest_subnet_data = valid_subnet_infos[-1] + stats = { + "current_price": prices[-1], + "high": max(prices), + "low": min(prices), + "change_pct": ((prices[-1] - prices[0]) / prices[0] * 100), + "supply": latest_subnet_data.alpha_in.tao + + latest_subnet_data.alpha_out.tao, + "market_cap": latest_subnet_data.price.tao + * (latest_subnet_data.alpha_in.tao + latest_subnet_data.alpha_out.tao), + "emission": latest_subnet_data.emission.tao, + "stake": latest_subnet_data.alpha_out.tao, + "symbol": latest_subnet_data.symbol, + "name": get_subnet_name(latest_subnet_data), + } + subnet_data[netuids[0]] = { + "prices": prices, + "stats": stats, + } + + # Sort results by market cap + sorted_subnet_data = dict( + sorted( + subnet_data.items(), + key=lambda x: x[1]["stats"]["market_cap"], + reverse=True, + ) + ) + return sorted_subnet_data + + +def _generate_html_single_subnet( + netuid, + data, + block_numbers, + interval_hours, + log_scale, +): + """ + Generate an HTML chart for a single subnet. + """ + stats = data["stats"] + prices = data["prices"] + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=block_numbers, + y=prices, + mode="lines", + name=f"Subnet {netuid} - {stats['name']}" + if stats["name"] + else f"Subnet {netuid}", + line=dict(width=2, color="#50C878"), + ) + ) + + fig.update_layout( + template="plotly_dark", + paper_bgcolor="#000000", + plot_bgcolor="#000000", + font=dict(color="white"), + showlegend=True, + legend=dict( + x=1.02, + y=1.0, + xanchor="left", + yanchor="top", + bgcolor="rgba(0,0,0,0)", + bordercolor="rgba(255,255,255,0.2)", + borderwidth=1, + ), + margin=dict(t=160, r=50, b=50, l=50), + height=600, + ) + + price_title = f"Price ({stats['symbol']})" + if log_scale: + price_title += " Log Scale" + + # Label axes + fig.update_xaxes( + title="Block", + gridcolor="rgba(128,128,128,0.2)", + zerolinecolor="rgba(128,128,128,0.2)", + type="log" if log_scale else "linear", + ) + fig.update_yaxes( + title=price_title, + gridcolor="rgba(128,128,128,0.2)", + zerolinecolor="rgba(128,128,128,0.2)", + type="log" if log_scale else "linear", + ) + + # Price change color + price_change_class = "text-green" if stats["change_pct"] > 0 else "text-red" + # Change sign + sign_icon = "▲" if stats["change_pct"] > 0 else "▼" + + fig_dict = fig.to_dict() + fig_json = json.dumps(fig_dict) + html_content = f""" + + + + + Subnet Price View + + + + +
+
+
+ {stats['current_price']:.6f} {stats['symbol']} + + {sign_icon} {abs(stats['change_pct']):.2f}% + +
+
+
+ {interval_hours}h High: {stats['high']:.6f} {stats['symbol']} +
+
+ {interval_hours}h Low: {stats['low']:.6f} {stats['symbol']} +
+
+
+
+
Supply: {stats['supply']:.2f} {stats['symbol']}
+
Market Cap: {stats['market_cap']:.2f} τ
+
Emission: {stats['emission']:.2f} {stats['symbol']}
+
Stake: {stats['stake']:.2f} {stats['symbol']}
+
+
+
+ + + + """ + + return html_content + + +def _generate_html_multi_subnet(subnet_data, block_numbers, interval_hours, log_scale): + """ + Generate an HTML chart for multiple subnets. + """ + # Pick top subnet by market cap + top_subnet_netuid = max( + subnet_data.keys(), + key=lambda k: subnet_data[k]["stats"]["market_cap"], + ) + top_subnet_stats = subnet_data[top_subnet_netuid]["stats"] + + fig = go.Figure() + fig.update_layout( + template="plotly_dark", + paper_bgcolor="#000000", + plot_bgcolor="#000000", + font=dict(color="white"), + showlegend=True, + legend=dict( + x=1.02, + y=1.0, + xanchor="left", + yanchor="top", + bgcolor="rgba(0,0,0,0)", + bordercolor="rgba(255,255,255,0.2)", + borderwidth=1, + ), + margin=dict(t=200, r=80, b=50, l=50), + height=700, + ) + + price_title = "Price (τ)" + if log_scale: + price_title += " Log Scale" + + # Label axes + fig.update_xaxes( + title="Block", + gridcolor="rgba(128,128,128,0.2)", + zerolinecolor="rgba(128,128,128,0.2)", + type="log" if log_scale else "linear", + ) + fig.update_yaxes( + title=price_title, + gridcolor="rgba(128,128,128,0.2)", + zerolinecolor="rgba(128,128,128,0.2)", + type="log" if log_scale else "linear", + ) + + # Create annotation for top subnet + sign_icon = "▲" if top_subnet_stats["change_pct"] > 0 else "▼" + change_color = "#00FF00" if top_subnet_stats["change_pct"] > 0 else "#FF5555" + + left_text = ( + f"Top subnet: Subnet {top_subnet_netuid}" + + (f" - {top_subnet_stats['name']}" if top_subnet_stats["name"] else "") + + "

" + + f"{top_subnet_stats['current_price']:.6f} {top_subnet_stats['symbol']}" + + f" {sign_icon} {abs(top_subnet_stats['change_pct']):.2f}%

" + + f"{interval_hours}h High: {top_subnet_stats['high']:.6f}, " + + f"Low: {top_subnet_stats['low']:.6f}" + ) + + right_text = ( + f"Supply: {top_subnet_stats['supply']:.2f} {top_subnet_stats['symbol']}
" + f"Market Cap: {top_subnet_stats['market_cap']:.2f} τ
" + f"Emission: {top_subnet_stats['emission']:.2f} {top_subnet_stats['symbol']}
" + f"Stake: {top_subnet_stats['stake']:.2f} {top_subnet_stats['symbol']}" + ) + + all_annotations = [ + dict( + text=left_text, + x=0.0, + y=1.3, + xref="paper", + yref="paper", + align="left", + showarrow=False, + font=dict(size=14), + xanchor="left", + yanchor="top", + ), + dict( + text=right_text, + x=1.02, + y=1.3, + xref="paper", + yref="paper", + align="left", + showarrow=False, + font=dict(size=14), + xanchor="left", + yanchor="top", + ), + ] + + fig.update_layout(annotations=all_annotations) + + # Generate colors for subnets + def generate_color_palette(n): + """Generate n distinct colors using a variation of HSV color space.""" + colors = [] + for i in range(n): + hue = i * 0.618033988749895 % 1 + saturation = 0.6 + (i % 3) * 0.2 + value = 0.8 + (i % 2) * 0.2 # Brightness + + h = hue * 6 + c = value * saturation + x = c * (1 - abs(h % 2 - 1)) + m = value - c + + if h < 1: + r, g, b = c, x, 0 + elif h < 2: + r, g, b = x, c, 0 + elif h < 3: + r, g, b = 0, c, x + elif h < 4: + r, g, b = 0, x, c + elif h < 5: + r, g, b = x, 0, c + else: + r, g, b = c, 0, x + + rgb = ( + int((r + m) * 255), + int((g + m) * 255), + int((b + m) * 255), + ) + colors.append(f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}") + return colors + + base_colors = generate_color_palette(len(subnet_data) + 1) + + # Plot each subnet as a separate trace + subnet_keys = list(subnet_data.keys()) + for i, netuid in enumerate(subnet_keys): + d = subnet_data[netuid] + fig.add_trace( + go.Scatter( + x=block_numbers, + y=d["prices"], + mode="lines", + name=( + f"Subnet {netuid} - {d['stats']['name']}" + if d["stats"]["name"] + else f"Subnet {netuid}" + ), + line=dict(width=2, color=base_colors[i]), + visible=True, + ) + ) + + # Annotations for each subnet + def build_single_subnet_annotations(netuid): + s = subnet_data[netuid]["stats"] + name_line = f"Subnet {netuid}" + (f" - {s['name']}" if s["name"] else "") + + sign_icon = "▲" if s["change_pct"] > 0 else "▼" + change_color = "#00FF00" if s["change_pct"] > 0 else "#FF5555" + + left_text = ( + f"{name_line}

" + f"{s['current_price']:.6f} {s['symbol']}" + f" {sign_icon} {abs(s['change_pct']):.2f}%

" + f"{interval_hours}h High: {s['high']:.6f}, " + f"Low: {s['low']:.6f}" + ) + + right_text = ( + f"Supply: {s['supply']:.2f} {s['symbol']}
" + f"Market Cap: {s['market_cap']:.2f} τ
" + f"Emission: {s['emission']:.2f} {s['symbol']}
" + f"Stake: {s['stake']:.2f} {s['symbol']}" + ) + + left_annot = dict( + text=left_text, + x=0.0, + y=1.3, + xref="paper", + yref="paper", + align="left", + showarrow=False, + font=dict(size=14), + xanchor="left", + yanchor="top", + ) + right_annot = dict( + text=right_text, + x=1.02, + y=1.3, + xref="paper", + yref="paper", + align="left", + showarrow=False, + font=dict(size=14), + xanchor="left", + yanchor="top", + ) + return [left_annot, right_annot] + + # "All" visibility mask + all_visibility = [True] * len(subnet_keys) + + # Build visibility masks for each subnet + subnet_modes = {} + for idx, netuid in enumerate(subnet_keys): + single_vis = [False] * len(subnet_keys) + single_vis[idx] = True + single_annots = build_single_subnet_annotations(netuid) + subnet_modes[netuid] = { + "visible": single_vis, + "annotations": single_annots, + } + + fig_json = fig.to_json() + all_visibility_json = json.dumps(all_visibility) + all_annotations_json = json.dumps(all_annotations) + + subnet_modes_json = {} + for netuid, mode_data in subnet_modes.items(): + subnet_modes_json[netuid] = { + "visible": json.dumps(mode_data["visible"]), + "annotations": json.dumps(mode_data["annotations"]), + } + + # We sort netuids by market cap but for buttons, they are ordered by netuid + sorted_subnet_keys = sorted(subnet_data.keys()) + all_button_html = ( + '' + ) + subnet_buttons_html = "" + for netuid in sorted_subnet_keys: + subnet_buttons_html += f' ' + + html_content = f""" + + + + Multi-Subnet Price Chart + + + + +
+
+
+ {all_button_html} + {subnet_buttons_html} +
+
+ + + + """ + return html_content + + +async def _generate_html_output( + subnet_data, + block_numbers, + interval_hours, + log_scale: bool = False, +): + """ + Start PyWry and display the price chart in a window. + """ + try: + subnet_keys = list(subnet_data.keys()) + + # Single subnet + if len(subnet_keys) == 1: + netuid = subnet_keys[0] + data = subnet_data[netuid] + html_content = _generate_html_single_subnet( + netuid, data, block_numbers, interval_hours, log_scale + ) + title = f"Subnet {netuid} Price View" + else: + # Multi-subnet + html_content = _generate_html_multi_subnet( + subnet_data, block_numbers, interval_hours, log_scale + ) + title = "Subnets Price Chart" + console.print( + "[dark_sea_green3]Opening price chart in a window. Press Ctrl+C to close.[/dark_sea_green3]" + ) + handler = PyWry() + handler.send_html( + html=html_content, + title=title, + width=1200, + height=800, + ) + handler.start() + await asyncio.sleep(5) + + # TODO: Improve this logic + try: + while True: + if _has_exited(handler): + break + await asyncio.sleep(1) + except KeyboardInterrupt: + pass + finally: + if not _has_exited(handler): + try: + handler.close() + except Exception: + pass + except Exception as e: + print_error(f"Error generating price chart: {e}") + + +def _generate_cli_output(subnet_data, block_numbers, interval_hours, log_scale): + """ + Render the price data in a textual CLI style with plotille ASCII charts. + """ + for netuid, data in subnet_data.items(): + fig = plotille.Figure() + fig.width = 60 + fig.height = 20 + fig.color_mode = "rgb" + fig.background = None + + def color_label(text): + return plotille.color(text, fg=(186, 233, 143), mode="rgb") + + fig.x_label = color_label("Block") + y_label_text = f"Price ({data['stats']['symbol']})" + fig.y_label = color_label(y_label_text) + + prices = data["prices"] + if log_scale: + prices = [math.log10(p) for p in prices] + + fig.set_x_limits(min_=min(block_numbers), max_=max(block_numbers)) + fig.set_y_limits( + min_=data["stats"]["low"] * 0.99, + max_=data["stats"]["high"] * 1.01, + ) + + fig.plot( + block_numbers, + data["prices"], + label=f"Subnet {netuid} Price", + interp="linear", + lc="bae98f", + ) + + stats = data["stats"] + change_color = "dark_sea_green3" if stats["change_pct"] > 0 else "red" + + if netuid != 0: + console.print( + f"\n[{COLOR_PALETTE['GENERAL']['SYMBOL']}]Subnet {netuid} - {stats['symbol']} " + f"[cyan]{stats['name']}[/cyan][/{COLOR_PALETTE['GENERAL']['SYMBOL']}]\n" + f"Current: [blue]{stats['current_price']:.6f}{stats['symbol']}[/blue]\n" + f"{interval_hours}h High: [dark_sea_green3]{stats['high']:.6f}{stats['symbol']}[/dark_sea_green3]\n" + f"{interval_hours}h Low: [red]{stats['low']:.6f}{stats['symbol']}[/red]\n" + f"{interval_hours}h Change: [{change_color}]{stats['change_pct']:.2f}%[/{change_color}]\n" + ) + else: + console.print( + f"\n[{COLOR_PALETTE['GENERAL']['SYMBOL']}]Subnet {netuid} - {stats['symbol']} " + f"[cyan]{stats['name']}[/cyan][/{COLOR_PALETTE['GENERAL']['SYMBOL']}]\n" + f"Current: [blue]{stats['symbol']} {stats['current_price']:.6f}[/blue]\n" + f"{interval_hours}h High: [dark_sea_green3]{stats['symbol']} {stats['high']:.6f}[/dark_sea_green3]\n" + f"{interval_hours}h Low: [red]{stats['symbol']} {stats['low']:.6f}[/red]\n" + f"{interval_hours}h Change: [{change_color}]{stats['change_pct']:.2f}%[/{change_color}]\n" + ) + + print(fig.show()) + + if netuid != 0: + stats_text = ( + "\nLatest stats:\n" + f"Supply: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" + f"{stats['supply']:,.2f} {stats['symbol']}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]\n" + f"Market Cap: [steel_blue3]{stats['market_cap']:,.2f} {stats['symbol']} / 21M[/steel_blue3]\n" + f"Emission: [{COLOR_PALETTE['POOLS']['EMISSION']}]" + f"{stats['emission']:,.2f} {stats['symbol']}[/{COLOR_PALETTE['POOLS']['EMISSION']}]\n" + f"Stake: [{COLOR_PALETTE['STAKE']['TAO']}]" + f"{stats['stake']:,.2f} {stats['symbol']}[/{COLOR_PALETTE['STAKE']['TAO']}]" + ) + else: + stats_text = ( + "\nLatest stats:\n" + f"Supply: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" + f"{stats['symbol']} {stats['supply']:,.2f}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]\n" + f"Market Cap: [steel_blue3]{stats['symbol']} {stats['market_cap']:,.2f} / 21M[/steel_blue3]\n" + f"Emission: [{COLOR_PALETTE['POOLS']['EMISSION']}]" + f"{stats['symbol']} {stats['emission']:,.2f}[/{COLOR_PALETTE['POOLS']['EMISSION']}]\n" + f"Stake: [{COLOR_PALETTE['STAKE']['TAO']}]" + f"{stats['symbol']} {stats['stake']:,.2f}[/{COLOR_PALETTE['STAKE']['TAO']}]" + ) + + console.print(stats_text) + + +def _has_exited(handler) -> bool: + """Check if PyWry process has cleanly exited with returncode 0.""" + return ( + hasattr(handler, "runner") + and handler.runner is not None + and handler.runner.returncode == 0 + ) diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py new file mode 100644 index 00000000..4f116e62 --- /dev/null +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -0,0 +1,2028 @@ +import asyncio +import json +import sqlite3 +from typing import TYPE_CHECKING, Optional, cast +import typer + +from bittensor_wallet import Wallet +from bittensor_wallet.errors import KeyFileError +from rich.prompt import Confirm, Prompt +from rich.console import Group +from rich.progress import Progress, BarColumn, TextColumn +from rich.table import Column, Table +from rich import box + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.extrinsics.registration import ( + register_extrinsic, + burned_register_extrinsic, +) +from bittensor_cli.src.bittensor.extrinsics.root import root_register_extrinsic +from rich.live import Live +from bittensor_cli.src.bittensor.minigraph import MiniGraph +from bittensor_cli.src.commands.wallets import set_id, get_id +from bittensor_cli.src.bittensor.utils import ( + console, + create_table, + err_console, + print_verbose, + print_error, + format_error_message, + get_metadata_table, + millify_tao, + render_table, + update_metadata_table, + prompt_for_identity, + get_subnet_name, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + +TAO_WEIGHT = 0.18 + +# helpers and extrinsics + + +async def register_subnetwork_extrinsic( + subtensor: "SubtensorInterface", + wallet: Wallet, + subnet_identity: dict, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = True, + prompt: bool = False, +) -> bool: + """Registers a new subnetwork. + + wallet (bittensor.wallet): + bittensor wallet object. + wait_for_inclusion (bool): + If set, waits for the extrinsic to enter a block before returning ``true``, or returns ``false`` if the extrinsic fails to enter the block within the timeout. + wait_for_finalization (bool): + If set, waits for the extrinsic to be finalized on the chain before returning ``true``, or returns ``false`` if the extrinsic fails to be finalized within the timeout. + prompt (bool): + If true, the call waits for confirmation from the user before proceeding. + Returns: + success (bool): + Flag is ``true`` if extrinsic was finalized or included in the block. + If we did not wait for finalization / inclusion, the response is ``true``. + """ + + async def _find_event_attributes_in_extrinsic_receipt( + response_, event_name: str + ) -> list: + """ + Searches for the attributes of a specified event within an extrinsic receipt. + + :param response_: The receipt of the extrinsic to be searched. + :param event_name: The name of the event to search for. + + :return: A list of attributes for the specified event. Returns [-1] if the event is not found. + """ + for event in await response_.triggered_events: + # Access the event details + event_details = event["event"] + # Check if the event_id is 'NetworkAdded' + if event_details["event_id"] == event_name: + # Once found, you can access the attributes of the event_name + return event_details["attributes"] + return [-1] + + print_verbose("Fetching balance") + your_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + + print_verbose("Fetching burn_cost") + sn_burn_cost = await burn_cost(subtensor) + if sn_burn_cost > your_balance: + err_console.print( + f"Your balance of: [{COLOR_PALETTE['POOLS']['TAO']}]{your_balance}[{COLOR_PALETTE['POOLS']['TAO']}] is not enough to pay the subnet lock cost of: " + f"[{COLOR_PALETTE['POOLS']['TAO']}]{sn_burn_cost}[{COLOR_PALETTE['POOLS']['TAO']}]" + ) + return False + + if prompt: + console.print( + f"Your balance is: [{COLOR_PALETTE['POOLS']['TAO']}]{your_balance}" + ) + if not Confirm.ask( + f"Do you want to register a subnet for [{COLOR_PALETTE['POOLS']['TAO']}]{sn_burn_cost}?" + ): + return False + + has_identity = any(subnet_identity.values()) + if has_identity: + identity_data = { + "subnet_name": subnet_identity["subnet_name"].encode() + if subnet_identity.get("subnet_name") + else b"", + "github_repo": subnet_identity["github_repo"].encode() + if subnet_identity.get("github_repo") + else b"", + "subnet_contact": subnet_identity["subnet_contact"].encode() + if subnet_identity.get("subnet_contact") + else b"", + "subnet_url": subnet_identity["subnet_url"].encode() + if subnet_identity.get("subnet_url") + else b"", + "discord": subnet_identity["discord"].encode() + if subnet_identity.get("discord") + else b"", + "description": subnet_identity["description"].encode() + if subnet_identity.get("description") + else b"", + "additional": subnet_identity["additional"].encode() + if subnet_identity.get("additional") + else b"", + } + for field, value in identity_data.items(): + max_size = 64 # bytes + if len(value) > max_size: + err_console.print( + f"[red]Error:[/red] Identity field [white]{field}[/white] must be <= {max_size} bytes.\n" + f"Value '{value.decode()}' is {len(value)} bytes." + ) + return False + + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + + with console.status(":satellite: Registering subnet...", spinner="earth"): + call_params = { + "hotkey": wallet.hotkey.ss58_address, + "mechid": 1, + } + call_function = "register_network" + if has_identity: + call_params["identity"] = identity_data + call_function = "register_network_with_identity" + + substrate = subtensor.substrate + # create extrinsic call + call = await substrate.compose_call( + call_module="SubtensorModule", + call_function=call_function, + call_params=call_params, + ) + extrinsic = await substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + response = await substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + # We only wait here if we expect finalization. + if not wait_for_finalization and not wait_for_inclusion: + return True + + await response.process_events() + if not await response.is_success: + err_console.print( + f":cross_mark: [red]Failed[/red]: {format_error_message(await response.error_message, substrate)}" + ) + await asyncio.sleep(0.5) + return False + + # Successful registration, final check for membership + else: + attributes = await _find_event_attributes_in_extrinsic_receipt( + response, "NetworkAdded" + ) + console.print( + f":white_heavy_check_mark: [dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" + ) + return True + + +# commands + + +async def subnets_list( + subtensor: "SubtensorInterface", + reuse_last: bool, + html_output: bool, + no_cache: bool, + verbose: bool, + live: bool, +): + """List all subnet netuids in the network.""" + + async def fetch_subnet_data(): + block_number = await subtensor.substrate.get_block_number(None) + subnets = await subtensor.all_subnets() + + # Sort subnets by market cap, keeping the root subnet in the first position + root_subnet = next(s for s in subnets if s.netuid == 0) + other_subnets = sorted( + [s for s in subnets if s.netuid != 0], + key=lambda x: (x.alpha_in.tao + x.alpha_out.tao) * x.price.tao, + reverse=True, + ) + sorted_subnets = [root_subnet] + other_subnets + return sorted_subnets, block_number + + def calculate_emission_stats( + subnets: list, block_number: int + ) -> tuple[Balance, str]: + # We do not include the root subnet in the emission calculation + total_tao_emitted = sum( + subnet.tao_in.tao for subnet in subnets if subnet.netuid != 0 + ) + emission_percentage = (total_tao_emitted / block_number) * 100 + percentage_color = "dark_sea_green" if emission_percentage < 100 else "red" + formatted_percentage = ( + f"[{percentage_color}]{emission_percentage:.2f}%[/{percentage_color}]" + ) + if not verbose: + percentage_string = f"τ {millify_tao(total_tao_emitted)}/{millify_tao(block_number)} ({formatted_percentage})" + else: + percentage_string = ( + f"τ {total_tao_emitted:.1f}/{block_number} ({formatted_percentage})" + ) + return total_tao_emitted, percentage_string + + def define_table( + total_emissions: float, + total_rate: float, + total_netuids: int, + tao_emission_percentage: str, + ): + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnets" + f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}\n\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column( + "[bold white]Netuid", + style="grey89", + justify="center", + footer=str(total_netuids), + ) + table.add_column("[bold white]Name", style="cyan", justify="left") + table.add_column( + f"[bold white]Price \n({Balance.get_unit(0)}_in/{Balance.get_unit(1)}_in)", + style="dark_sea_green2", + justify="left", + footer=f"τ {total_rate}", + ) + table.add_column( + f"[bold white]Market Cap \n({Balance.get_unit(1)} * Price)", + style="steel_blue3", + justify="left", + ) + table.add_column( + f"[bold white]Emission ({Balance.get_unit(0)})", + style=COLOR_PALETTE["POOLS"]["EMISSION"], + justify="left", + footer=f"τ {total_emissions}", + ) + table.add_column( + f"[bold white]P ({Balance.get_unit(0)}_in, {Balance.get_unit(1)}_in)", + style=COLOR_PALETTE["STAKE"]["TAO"], + justify="left", + footer=f"{tao_emission_percentage}", + ) + table.add_column( + f"[bold white]Stake ({Balance.get_unit(1)}_out)", + style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + justify="left", + ) + table.add_column( + f"[bold white]Supply ({Balance.get_unit(1)})", + style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], + justify="left", + ) + + table.add_column( + "[bold white]Tempo (k/n)", + style=COLOR_PALETTE["GENERAL"]["TEMPO"], + justify="left", + overflow="fold", + ) + return table + + # Non-live mode + def create_table(subnets, block_number): + rows = [] + _, percentage_string = calculate_emission_stats(subnets, block_number) + + for subnet in subnets: + netuid = subnet.netuid + symbol = f"{subnet.symbol}\u200e" + + if netuid == 0: + emission_tao = 0.0 + else: + emission_tao = subnet.tao_in_emission.tao + + alpha_in_value = ( + f"{millify_tao(subnet.alpha_in.tao)}" + if not verbose + else f"{subnet.alpha_in.tao:,.4f}" + ) + alpha_out_value = ( + f"{millify_tao(subnet.alpha_out.tao)}" + if not verbose + else f"{subnet.alpha_out.tao:,.4f}" + ) + price_value = f"{subnet.price.tao:,.4f}" + + # Market Cap + market_cap = (subnet.alpha_in.tao + subnet.alpha_out.tao) * subnet.price.tao + market_cap_value = ( + f"{millify_tao(market_cap)}" if not verbose else f"{market_cap:,.4f}" + ) + + # Liquidity + tao_in_cell = ( + ( + f"τ {millify_tao(subnet.tao_in.tao)}" + if not verbose + else f"τ {subnet.tao_in.tao:,.4f}" + ) + if netuid != 0 + else "-" + ) + alpha_in_cell = f"{alpha_in_value} {symbol}" if netuid != 0 else "-" + liquidity_cell = f"{tao_in_cell}, {alpha_in_cell}" + + # Supply + supply = subnet.alpha_in.tao + subnet.alpha_out.tao + supply_value = f"{millify_tao(supply)}" if not verbose else f"{supply:,.4f}" + + # Prepare cells + netuid_cell = str(netuid) + subnet_name_cell = ( + f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f" {get_subnet_name(subnet)}" + ) + emission_cell = f"τ {emission_tao:,.4f}" + price_cell = f"{price_value} τ/{symbol}" + alpha_out_cell = ( + f"{alpha_out_value} {symbol}" + if netuid != 0 + else f"{symbol} {alpha_out_value}" + ) + market_cap_cell = f"τ {market_cap_value}" + supply_cell = f"{supply_value} {symbol} [#806DAF]/21M" + + if netuid != 0: + tempo_cell = f"{subnet.blocks_since_last_step}/{subnet.tempo}" + else: + tempo_cell = "-/-" + + rows.append( + ( + netuid_cell, # Netuid + subnet_name_cell, # Name + price_cell, # Rate τ_in/α_in + market_cap_cell, # Market Cap + emission_cell, # Emission (τ) + liquidity_cell, # Liquidity (t_in, a_in) + alpha_out_cell, # Stake α_out + supply_cell, # Supply + tempo_cell, # Tempo k/n + ) + ) + + total_emissions = round( + sum(subnet.tao_in_emission.tao for subnet in subnets if subnet.netuid != 0), + 4, + ) + total_rate = round( + sum(float(subnet.price.tao) for subnet in subnets if subnet.netuid != 0), 4 + ) + total_netuids = len(subnets) + table = define_table( + total_emissions, total_rate, total_netuids, percentage_string + ) + + for row in rows: + table.add_row(*row) + return table + + # Live mode + def create_table_live(subnets, previous_data, block_number): + def format_cell( + value, previous_value, unit="", unit_first=False, precision=4, millify=False + ): + if previous_value is not None: + change = value - previous_value + if abs(change) > 10 ** (-precision): + formatted_change = ( + f"{change:.{precision}f}" + if not millify + else f"{millify_tao(change)}" + ) + change_text = ( + f" [pale_green3](+{formatted_change})[/pale_green3]" + if change > 0 + else f" [hot_pink3]({formatted_change})[/hot_pink3]" + ) + else: + change_text = "" + else: + change_text = "" + formatted_value = ( + f"{value:,.{precision}f}" if not millify else millify_tao(value) + ) + return ( + f"{formatted_value} {unit}{change_text}" + if not unit_first + else f"{unit} {formatted_value}{change_text}" + ) + + def format_liquidity_cell( + tao_val, + alpha_val, + prev_tao, + prev_alpha, + symbol, + precision=4, + millify=False, + netuid=None, + ): + """Format liquidity cell with combined changes""" + + tao_str = ( + f"τ {millify_tao(tao_val)}" + if millify + else f"τ {tao_val:,.{precision}f}" + ) + _alpha_str = f"{millify_tao(alpha_val) if millify else f'{alpha_val:,.{precision}f}'}" + alpha_str = ( + f"{_alpha_str} {symbol}" if netuid != 0 else f"{symbol} {_alpha_str}" + ) + + # Show delta + if prev_tao is not None and prev_alpha is not None: + tao_change = tao_val - prev_tao + alpha_change = alpha_val - prev_alpha + + # Show changes if either value changed + if abs(tao_change) > 10 ** (-precision) or abs(alpha_change) > 10 ** ( + -precision + ): + if millify: + tao_change_str = ( + f"+{millify_tao(tao_change)}" + if tao_change > 0 + else f"{millify_tao(tao_change)}" + ) + alpha_change_str = ( + f"+{millify_tao(alpha_change)}" + if alpha_change > 0 + else f"{millify_tao(alpha_change)}" + ) + else: + tao_change_str = ( + f"+{tao_change:.{precision}f}" + if tao_change > 0 + else f"{tao_change:.{precision}f}" + ) + alpha_change_str = ( + f"+{alpha_change:.{precision}f}" + if alpha_change > 0 + else f"{alpha_change:.{precision}f}" + ) + + changes_str = ( + f" [pale_green3]({tao_change_str}[/pale_green3]" + if tao_change > 0 + else f" [hot_pink3]({tao_change_str}[/hot_pink3]" + if tao_change < 0 + else f" [white]({tao_change_str}[/white]" + ) + changes_str += ( + f"[pale_green3],{alpha_change_str})[/pale_green3]" + if alpha_change > 0 + else f"[hot_pink3],{alpha_change_str})[/hot_pink3]" + if alpha_change < 0 + else f"[white],{alpha_change_str})[/white]" + ) + return f"{tao_str}, {alpha_str}{changes_str}" + + return f"{tao_str}, {alpha_str}" + + rows = [] + current_data = {} # To store current values for comparison in the next update + _, percentage_string = calculate_emission_stats(subnets, block_number) + + for subnet in subnets: + netuid = subnet.netuid + symbol = f"{subnet.symbol}\u200e" + + if netuid == 0: + emission_tao = 0.0 + else: + emission_tao = subnet.tao_in_emission.tao + + market_cap = (subnet.alpha_in.tao + subnet.alpha_out.tao) * subnet.price.tao + supply = subnet.alpha_in.tao + subnet.alpha_out.tao + + # Store current values for comparison + current_data[netuid] = { + "market_cap": market_cap, + "emission_tao": emission_tao, + "alpha_out": subnet.alpha_out.tao, + "tao_in": subnet.tao_in.tao, + "alpha_in": subnet.alpha_in.tao, + "price": subnet.price.tao, + "supply": supply, + "blocks_since_last_step": subnet.blocks_since_last_step, + } + prev = previous_data.get(netuid, {}) if previous_data else {} + + # Prepare cells + if netuid == 0: + unit_first = True + else: + unit_first = False + + netuid_cell = str(netuid) + subnet_name_cell = ( + f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f" {get_subnet_name(subnet)}" + ) + emission_cell = format_cell( + emission_tao, + prev.get("emission_tao"), + unit="τ", + unit_first=True, + precision=4, + ) + price_cell = format_cell( + subnet.price.tao, + prev.get("price"), + unit=f"τ/{symbol}", + precision=4, + millify=False, + ) + + alpha_out_cell = format_cell( + subnet.alpha_out.tao, + prev.get("alpha_out"), + unit=f"{symbol}", + unit_first=unit_first, + precision=5, + millify=True if not verbose else False, + ) + liquidity_cell = ( + format_liquidity_cell( + subnet.tao_in.tao, + subnet.alpha_in.tao, + prev.get("tao_in"), + prev.get("alpha_in"), + symbol, + precision=4, + millify=not verbose, + netuid=netuid, + ) + if netuid != 0 + else "-, -" + ) + + market_cap_cell = format_cell( + market_cap, + prev.get("market_cap"), + unit="τ", + unit_first=True, + precision=4, + millify=True if not verbose else False, + ) + + # Supply cell + supply_cell = format_cell( + supply, + prev.get("supply"), + unit=f"{symbol} [#806DAF]/21M", + unit_first=False, + precision=2, + millify=True if not verbose else False, + ) + + # Tempo cell + prev_blocks_since_last_step = prev.get("blocks_since_last_step") + if prev_blocks_since_last_step is not None: + if subnet.blocks_since_last_step >= prev_blocks_since_last_step: + block_change = ( + subnet.blocks_since_last_step - prev_blocks_since_last_step + ) + else: + # Tempo restarted + block_change = ( + subnet.blocks_since_last_step + subnet.tempo + 1 + ) - prev_blocks_since_last_step + if block_change > 0: + block_change_text = f" [pale_green3](+{block_change})[/pale_green3]" + elif block_change < 0: + block_change_text = f" [hot_pink3]({block_change})[/hot_pink3]" + else: + block_change_text = "" + else: + block_change_text = "" + tempo_cell = ( + (f"{subnet.blocks_since_last_step}/{subnet.tempo}{block_change_text}") + if netuid != 0 + else "-/-" + ) + + rows.append( + ( + netuid_cell, # Netuid + subnet_name_cell, # Name + price_cell, # Rate τ_in/α_in + market_cap_cell, # Market Cap + emission_cell, # Emission (τ) + liquidity_cell, # Liquidity (t_in, a_in) + alpha_out_cell, # Stake α_out + supply_cell, # Supply + tempo_cell, # Tempo k/n + ) + ) + + # Calculate totals + total_netuids = len(subnets) + _total_emissions = sum( + subnet.tao_in_emission.tao for subnet in subnets if subnet.netuid != 0 + ) + total_emissions = ( + f"{millify_tao(_total_emissions)}" + if not verbose + else f"{_total_emissions:,.2f}" + ) + + total_rate = sum(subnet.price.tao for subnet in subnets if subnet.netuid != 0) + total_rate = ( + f"{millify_tao(total_rate)}" if not verbose else f"{total_rate:,.2f}" + ) + table = define_table( + total_emissions, total_rate, total_netuids, percentage_string + ) + + for row in rows: + table.add_row(*row) + return table, current_data + + # Live mode + if live: + refresh_interval = 10 # seconds + + progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(bar_width=20, style="green", complete_style="green"), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + console=console, + auto_refresh=True, + ) + progress_task = progress.add_task("Updating:", total=refresh_interval) + + previous_block = None + current_block = None + previous_data = None + + with Live(console=console, screen=True, auto_refresh=True) as live: + try: + while True: + subnets, block_number = await fetch_subnet_data() + + # Update block numbers + previous_block = current_block + current_block = block_number + new_blocks = ( + "N/A" + if previous_block is None + else str(current_block - previous_block) + ) + + table, current_data = create_table_live( + subnets, previous_data, block_number + ) + previous_data = current_data + progress.reset(progress_task) + start_time = asyncio.get_event_loop().time() + + block_info = ( + f"Previous: [dark_sea_green]{previous_block if previous_block else 'N/A'}[/dark_sea_green] " + f"Current: [dark_sea_green]{current_block}[/dark_sea_green] " + f"Diff: [dark_sea_green]{new_blocks}[/dark_sea_green] " + ) + + message = f"Live view active. Press [bold red]Ctrl + C[/bold red] to exit\n{block_info}" + + live_render = Group(message, progress, table) + live.update(live_render) + + while not progress.finished: + await asyncio.sleep(0.1) + elapsed = asyncio.get_event_loop().time() - start_time + progress.update(progress_task, completed=elapsed) + + except KeyboardInterrupt: + pass # Ctrl + C + else: + # Non-live mode + subnets, block_number = await fetch_subnet_data() + table = create_table(subnets, block_number) + console.print(table) + + return + # TODO: Temporarily returning till we update docs + display_table = Prompt.ask( + "\nPress Enter to view column descriptions or type 'q' to skip:", + choices=["", "q"], + default="", + ).lower() + + if display_table == "q": + console.print( + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]Column descriptions skipped." + ) + else: + header = """ + [bold white]Description[/bold white]: The table displays information about each subnet. The columns are as follows: + """ + console.print(header) + description_table = Table( + show_header=False, box=box.SIMPLE, show_edge=False, show_lines=True + ) + + fields = [ + ("[bold tan]Netuid[/bold tan]", "The netuid of the subnet."), + ( + "[bold tan]Symbol[/bold tan]", + "The symbol for the subnet's dynamic TAO token.", + ), + ( + "[bold tan]Emission (τ)[/bold tan]", + "Shows how the one τ per block emission is distributed among all the subnet pools. For each subnet, this fraction is first calculated by dividing the subnet's alpha token price by the sum of all alpha prices across all the subnets. This fraction of TAO is then added to the TAO Pool (τ_in) of the subnet. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#emissions[/blue].", + ), + ( + "[bold tan]TAO Pool (τ_in)[/bold tan]", + 'Number of TAO in the TAO reserves of the pool for this subnet. Attached to every subnet is a subnet pool, containing a TAO reserve and the alpha reserve. See also "Alpha Pool (α_in)" description. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#subnet-pool[/blue].', + ), + ( + "[bold tan]Alpha Pool (α_in)[/bold tan]", + "Number of subnet alpha tokens in the alpha reserves of the pool for this subnet. This reserve, together with 'TAO Pool (τ_in)', form the subnet pool for every subnet. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#subnet-pool[/blue].", + ), + ( + "[bold tan]STAKE (α_out)[/bold tan]", + "Total stake in the subnet, expressed in the subnet's alpha token currency. This is the sum of all the stakes present in all the hotkeys in this subnet. This can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#stake-%CE%B1_out-or-alpha-out-%CE%B1_out[/blue].", + ), + ( + "[bold tan]RATE (τ_in/α_in)[/bold tan]", + "Exchange rate between TAO and subnet dTAO token. Calculated as the reserve ratio: (TAO Pool (τ_in) / Alpha Pool (α_in)). Note that the terms relative price, alpha token price, alpha price are the same as exchange rate. This rate can change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#rate-%CF%84_in%CE%B1_in[/blue].", + ), + ( + "[bold tan]Tempo (k/n)[/bold tan]", + 'The tempo status of the subnet. Represented as (k/n) where "k" is the number of blocks elapsed since the last tempo and "n" is the total number of blocks in the tempo. The number "n" is a subnet hyperparameter and does not change every block. \nFor more, see [blue]https://docs.bittensor.com/dynamic-tao/dtao-guide#tempo-kn[/blue].', + ), + ] + + description_table.add_column("Field", no_wrap=True, style="bold tan") + description_table.add_column("Description", overflow="fold") + for field_name, description in fields: + description_table.add_row(field_name, description) + console.print(description_table) + + +async def show( + subtensor: "SubtensorInterface", + netuid: int, + sort: bool = False, + max_rows: Optional[int] = None, + delegate_selection: bool = False, + verbose: bool = False, + prompt: bool = True, +) -> Optional[str]: + async def show_root(): + block_hash = await subtensor.substrate.get_chain_head() + all_subnets = await subtensor.all_subnets(block_hash=block_hash) + root_info = next((s for s in all_subnets if s.netuid == 0), None) + if root_info is None: + print_error("The root subnet does not exist") + raise typer.Exit() + + root_state, identities, old_identities = await asyncio.gather( + subtensor.get_subnet_state(netuid=0, block_hash=block_hash), + subtensor.query_all_identities(block_hash=block_hash), + subtensor.get_delegate_identities(block_hash=block_hash), + ) + + if root_state is None: + err_console.print("The root subnet does not exist") + return + + if len(root_state.hotkeys) == 0: + err_console.print( + "The root-subnet is currently empty with 0 UIDs registered." + ) + return + + tao_sum = sum( + [root_state.tao_stake[idx].tao for idx in range(len(root_state.tao_stake))] + ) + + table = Table( + title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]Root Network\n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Network: {subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column("[bold white]Position", style="white", justify="center") + table.add_column( + "Tao (τ)", + style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + no_wrap=True, + justify="right", + footer=f"{tao_sum:.4f} τ" if verbose else f"{millify_tao(tao_sum)} τ", + ) + table.add_column( + f"[bold white]Emission ({Balance.get_unit(0)}/block)", + style=COLOR_PALETTE["POOLS"]["EMISSION"], + justify="center", + ) + table.add_column( + "[bold white]Hotkey", + style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + justify="center", + ) + table.add_column( + "[bold white]Coldkey", + style=COLOR_PALETTE["GENERAL"]["COLDKEY"], + justify="center", + ) + table.add_column( + "[bold white]Identity", + style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + justify="left", + ) + + sorted_hotkeys = sorted( + enumerate(root_state.hotkeys), + key=lambda x: root_state.tao_stake[x[0]], + reverse=True, + ) + sorted_rows = [] + sorted_hks_delegation = [] + for pos, (idx, hk) in enumerate(sorted_hotkeys): + total_emission_per_block = 0 + for netuid_ in range(len(all_subnets)): + subnet = all_subnets[netuid_] + emission_on_subnet = ( + root_state.emission_history[netuid_][idx] / subnet.tempo + ) + total_emission_per_block += subnet.alpha_to_tao( + Balance.from_rao(emission_on_subnet) + ) + + # Get identity for this validator + coldkey_identity = identities.get(root_state.coldkeys[idx], {}).get( + "name", "" + ) + hotkey_identity = old_identities.get(root_state.hotkeys[idx]) + validator_identity = ( + coldkey_identity + if coldkey_identity + else (hotkey_identity.display if hotkey_identity else "") + ) + + sorted_rows.append( + ( + str((pos + 1)), # Position + # f"τ {millify_tao(root_state.total_stake[idx].tao)}" + # if not verbose + # else f"{root_state.total_stake[idx]}", # Total Stake + # f"τ {root_state.alpha_stake[idx].tao:.4f}" + # if verbose + # else f"τ {millify_tao(root_state.alpha_stake[idx])}", # Alpha Stake + f"τ {root_state.tao_stake[idx].tao:.4f}" + if verbose + else f"τ {millify_tao(root_state.tao_stake[idx])}", # Tao Stake + f"{total_emission_per_block}", # Emission + f"{root_state.hotkeys[idx][:6]}" + if not verbose + else f"{root_state.hotkeys[idx]}", # Hotkey + f"{root_state.coldkeys[idx][:6]}" + if not verbose + else f"{root_state.coldkeys[idx]}", # Coldkey + validator_identity, # Identity + ) + ) + sorted_hks_delegation.append(root_state.hotkeys[idx]) + + for pos, row in enumerate(sorted_rows, 1): + table_row = [] + # if delegate_selection: + # table_row.append(str(pos)) + table_row.extend(row) + table.add_row(*table_row) + if delegate_selection and pos == max_rows: + break + # Print the table + console.print(table) + console.print("\n") + + if not delegate_selection: + tao_pool = ( + f"{millify_tao(root_info.tao_in.tao)}" + if not verbose + else f"{root_info.tao_in.tao:,.4f}" + ) + stake = ( + f"{millify_tao(root_info.alpha_out.tao)}" + if not verbose + else f"{root_info.alpha_out.tao:,.5f}" + ) + rate = ( + f"{millify_tao(root_info.price.tao)}" + if not verbose + else f"{root_info.price.tao:,.4f}" + ) + console.print( + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Root Network (Subnet 0)[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"\n Rate: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{rate} τ/τ[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" + f"\n Emission: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]τ 0[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" + f"\n TAO Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]τ {tao_pool}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" + f"\n Stake: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]τ {stake}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + f"\n Tempo: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]{root_info.blocks_since_last_step}/{root_info.tempo}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + ) + console.print( + """ + Description: + The table displays the root subnet participants and their metrics. + The columns are as follows: + - Position: The sorted position of the hotkey by total TAO. + - TAO: The sum of all TAO balances for this hotkey accross all subnets. + - Stake: The stake balance of this hotkey on root (measured in TAO). + - Emission: The emission accrued to this hotkey across all subnets every block measured in TAO. + - Hotkey: The hotkey ss58 address. + - Coldkey: The coldkey ss58 address. + """ + ) + if delegate_selection: + valid_uids = [str(row[0]) for row in sorted_rows[:max_rows]] + while True: + selection = Prompt.ask( + "\nEnter the Position of the delegate you want to stake to [dim](or press Enter to cancel)[/dim]", + default="", + choices=[""] + valid_uids, + show_choices=False, + show_default=False, + ) + + if selection == "": + return None + + position = int(selection) + idx = position - 1 + original_idx = sorted_hotkeys[idx][0] + selected_hotkey = root_state.hotkeys[original_idx] + + coldkey_identity = identities.get( + root_state.coldkeys[original_idx], {} + ).get("name", "") + hotkey_identity = old_identities.get(selected_hotkey) + validator_identity = ( + coldkey_identity + if coldkey_identity + else (hotkey_identity.display if hotkey_identity else "") + ) + identity_str = f" ({validator_identity})" if validator_identity else "" + + console.print( + f"\nSelected delegate: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{selected_hotkey}{identity_str}" + ) + return selected_hotkey + + async def show_subnet(netuid_: int): + if not await subtensor.subnet_exists(netuid=netuid): + err_console.print(f"[red]Subnet {netuid} does not exist[/red]") + raise typer.Exit() + block_hash = await subtensor.substrate.get_chain_head() + ( + subnet_info, + subnet_state, + identities, + old_identities, + current_burn_cost, + ) = await asyncio.gather( + subtensor.subnet(netuid=netuid_, block_hash=block_hash), + subtensor.get_subnet_state(netuid=netuid_, block_hash=block_hash), + subtensor.query_all_identities(block_hash=block_hash), + subtensor.get_delegate_identities(block_hash=block_hash), + subtensor.get_hyperparameter( + param_name="Burn", netuid=netuid_, block_hash=block_hash + ), + ) + if subnet_state is None: + print_error(f"Subnet {netuid_} does not exist") + raise typer.Exit() + + if subnet_info is None: + print_error(f"Subnet {netuid_} does not exist") + raise typer.Exit() + + if len(subnet_state.hotkeys) == 0: + print_error(f"Subnet {netuid_} is currently empty with 0 UIDs registered.") + raise typer.Exit() + + # Define table properties + table = Table( + title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnet [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid_}" + f"{': ' + get_subnet_name(subnet_info)}" + f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + # For table footers + alpha_sum = sum( + [ + subnet_state.alpha_stake[idx].tao + for idx in range(len(subnet_state.alpha_stake)) + ] + ) + stake_sum = sum( + [ + subnet_state.total_stake[idx].tao + for idx in range(len(subnet_state.total_stake)) + ] + ) + tao_sum = sum( + [ + subnet_state.tao_stake[idx].tao * TAO_WEIGHT + for idx in range(len(subnet_state.tao_stake)) + ] + ) + dividends_sum = sum( + subnet_state.dividends[idx] for idx in range(len(subnet_state.dividends)) + ) + emission_sum = sum( + [ + subnet_state.emission[idx].tao + for idx in range(len(subnet_state.emission)) + ] + ) + + owner_hotkeys = await subtensor.get_owned_hotkeys(subnet_info.owner_coldkey) + if subnet_info.owner_hotkey not in owner_hotkeys: + owner_hotkeys.append(subnet_info.owner_hotkey) + + owner_identity = identities.get(subnet_info.owner_coldkey, {}).get("name", "") + if not owner_identity: + # If no coldkey identity found, try each owner hotkey + for hotkey in owner_hotkeys: + if hotkey_identity := old_identities.get(hotkey): + owner_identity = hotkey_identity.display + break + + sorted_indices = sorted( + range(len(subnet_state.hotkeys)), + key=lambda i: ( + # If sort is True, sort only by UIDs + i + if sort + else ( + # Otherwise + # Sort by owner status first + not ( + subnet_state.coldkeys[i] == subnet_info.owner_coldkey + or subnet_state.hotkeys[i] in owner_hotkeys + ), + # Then sort by stake amount (higher stakes first) + -subnet_state.total_stake[i].tao, + ) + ), + ) + + rows = [] + for idx in sorted_indices: + # Get identity for this uid + coldkey_identity = identities.get(subnet_state.coldkeys[idx], {}).get( + "name", "" + ) + hotkey_identity = old_identities.get(subnet_state.hotkeys[idx]) + uid_identity = ( + coldkey_identity + if coldkey_identity + else (hotkey_identity.display if hotkey_identity else "~") + ) + + if ( + subnet_state.coldkeys[idx] == subnet_info.owner_coldkey + or subnet_state.hotkeys[idx] in owner_hotkeys + ): + if uid_identity == "~": + uid_identity = ( + "[dark_sea_green3](*Owner controlled)[/dark_sea_green3]" + ) + else: + uid_identity = ( + f"[dark_sea_green3]{uid_identity} (*Owner)[/dark_sea_green3]" + ) + + # Modify tao stake with TAO_WEIGHT + tao_stake = subnet_state.tao_stake[idx] * TAO_WEIGHT + rows.append( + ( + str(idx), # UID + f"{subnet_state.total_stake[idx].tao:.4f} {subnet_info.symbol}" + if verbose + else f"{millify_tao(subnet_state.total_stake[idx])} {subnet_info.symbol}", # Stake + f"{subnet_state.alpha_stake[idx].tao:.4f} {subnet_info.symbol}" + if verbose + else f"{millify_tao(subnet_state.alpha_stake[idx])} {subnet_info.symbol}", # Alpha Stake + f"τ {tao_stake.tao:.4f}" + if verbose + else f"τ {millify_tao(tao_stake)}", # Tao Stake + f"{subnet_state.dividends[idx]:.6f}", # Dividends + f"{subnet_state.incentives[idx]:.6f}", # Incentive + f"{Balance.from_tao(subnet_state.emission[idx].tao).set_unit(netuid_).tao:.6f} {subnet_info.symbol}", # Emissions + f"{subnet_state.hotkeys[idx][:6]}" + if not verbose + else f"{subnet_state.hotkeys[idx]}", # Hotkey + f"{subnet_state.coldkeys[idx][:6]}" + if not verbose + else f"{subnet_state.coldkeys[idx]}", # Coldkey + uid_identity, # Identity + ) + ) + + # Add columns to the table + table.add_column("UID", style="grey89", no_wrap=True, justify="center") + table.add_column( + f"Stake ({Balance.get_unit(netuid_)})", + style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], + no_wrap=True, + justify="right", + footer=f"{stake_sum:.4f} {subnet_info.symbol}" + if verbose + else f"{millify_tao(stake_sum)} {subnet_info.symbol}", + ) + table.add_column( + f"Alpha ({Balance.get_unit(netuid_)})", + style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + no_wrap=True, + justify="right", + footer=f"{alpha_sum:.4f} {subnet_info.symbol}" + if verbose + else f"{millify_tao(alpha_sum)} {subnet_info.symbol}", + ) + table.add_column( + "Tao (τ)", + style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + no_wrap=True, + justify="right", + footer=f"{tao_sum:.4f} {subnet_info.symbol}" + if verbose + else f"{millify_tao(tao_sum)} {subnet_info.symbol}", + ) + table.add_column( + "Dividends", + style=COLOR_PALETTE["POOLS"]["EMISSION"], + no_wrap=True, + justify="center", + footer=f"{dividends_sum:.3f}", + ) + table.add_column("Incentive", style="#5fd7ff", no_wrap=True, justify="center") + table.add_column( + f"Emissions ({Balance.get_unit(netuid_)})", + style=COLOR_PALETTE["POOLS"]["EMISSION"], + no_wrap=True, + justify="center", + footer=str(Balance.from_tao(emission_sum).set_unit(subnet_info.netuid)), + ) + table.add_column( + "Hotkey", + style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + no_wrap=True, + justify="center", + ) + table.add_column( + "Coldkey", + style=COLOR_PALETTE["GENERAL"]["COLDKEY"], + no_wrap=True, + justify="center", + ) + table.add_column( + "Identity", + style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + no_wrap=True, + justify="left", + ) + for pos, row in enumerate(rows, 1): + table_row = [] + table_row.extend(row) + table.add_row(*table_row) + if delegate_selection and pos == max_rows: + break + + # Print the table + console.print("\n\n") + console.print(table) + console.print("\n") + + if not delegate_selection: + subnet_name_display = f": {get_subnet_name(subnet_info)}" + tao_pool = ( + f"{millify_tao(subnet_info.tao_in.tao)}" + if not verbose + else f"{subnet_info.tao_in.tao:,.4f}" + ) + alpha_pool = ( + f"{millify_tao(subnet_info.alpha_in.tao)}" + if not verbose + else f"{subnet_info.alpha_in.tao:,.4f}" + ) + current_registration_burn = ( + Balance.from_rao(int(current_burn_cost)) + if current_burn_cost + else Balance(0) + ) + + console.print( + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Subnet {netuid_}{subnet_name_display}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"\n Owner: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{subnet_info.owner_coldkey}{' (' + owner_identity + ')' if owner_identity else ''}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]" + f"\n Rate: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{subnet_info.price.tao:.4f} τ/{subnet_info.symbol}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" + f"\n Emission: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]τ {subnet_info.emission.tao:,.4f}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" + f"\n TAO Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]τ {tao_pool}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" + f"\n Alpha Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]{alpha_pool} {subnet_info.symbol}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" + # f"\n Stake: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]{subnet_info.alpha_out.tao:,.5f} {subnet_info.symbol}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + f"\n Tempo: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]{subnet_info.blocks_since_last_step}/{subnet_info.tempo}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + f"\n Registration cost (recycled): [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]τ {current_registration_burn.tao:.4f}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + ) + # console.print( + # """ + # Description: + # The table displays the subnet participants and their metrics. + # The columns are as follows: + # - UID: The hotkey index in the subnet. + # - TAO: The sum of all TAO balances for this hotkey accross all subnets. + # - Stake: The stake balance of this hotkey on this subnet. + # - Weight: The stake-weight of this hotkey on this subnet. Computed as an average of the normalized TAO and Stake columns of this subnet. + # - Dividends: Validating dividends earned by the hotkey. + # - Incentives: Mining incentives earned by the hotkey (always zero in the RAO demo.) + # - Emission: The emission accrued to this hokey on this subnet every block (in staking units). + # - Hotkey: The hotkey ss58 address. + # - Coldkey: The coldkey ss58 address. + # """ + # ) + + if delegate_selection: + while True: + valid_uids = [str(row[0]) for row in rows[:max_rows]] + selection = Prompt.ask( + "\nEnter the UID of the delegate you want to stake to [dim](or press Enter to cancel)[/dim]", + default="", + choices=[""] + valid_uids, + show_choices=False, + show_default=False, + ) + + if selection == "": + return None + + try: + uid = int(selection) + # Check if the UID exists in the subnet + if uid in [int(row[0]) for row in rows]: + row_data = next(row for row in rows if int(row[0]) == uid) + hotkey = subnet_state.hotkeys[uid] + identity = "" if row_data[9] == "~" else row_data[9] + identity_str = f" ({identity})" if identity else "" + console.print( + f"\nSelected delegate: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{hotkey}{identity_str}" + ) + return hotkey + else: + console.print( + "[red]Invalid UID. Please enter a valid UID from the table above[/red]" + ) + except ValueError: + console.print("[red]Please enter a valid number[/red]") + + return None + + if netuid == 0: + result = await show_root() + return result + else: + result = await show_subnet(netuid) + return result + + +async def burn_cost(subtensor: "SubtensorInterface") -> Optional[Balance]: + """View locking cost of creating a new subnetwork""" + with console.status( + f":satellite:Retrieving lock cost from {subtensor.network}...", + spinner="aesthetic", + ): + burn_cost = await subtensor.burn_cost() + if burn_cost: + console.print( + f"Subnet burn cost: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{burn_cost}" + ) + return burn_cost + else: + err_console.print( + "Subnet burn cost: [red]Failed to get subnet burn cost[/red]" + ) + return None + + +async def create( + wallet: Wallet, subtensor: "SubtensorInterface", subnet_identity: dict, prompt: bool +): + """Register a subnetwork""" + + # Call register command. + success = await register_subnetwork_extrinsic( + subtensor, wallet, subnet_identity, prompt=prompt + ) + if success and prompt: + # Prompt for user to set identity. + do_set_identity = Confirm.ask( + "Would you like to set your own [blue]identity?[/blue]" + ) + + if do_set_identity: + current_identity = await get_id( + subtensor, wallet.coldkeypub.ss58_address, "Current on-chain identity" + ) + if prompt: + if not Confirm.ask( + "\nCost to register an [blue]Identity[/blue] is [blue]0.1 TAO[/blue]," + " are you sure you wish to continue?" + ): + console.print(":cross_mark: Aborted!") + raise typer.Exit() + + identity = prompt_for_identity( + current_identity=current_identity, + name=None, + web_url=None, + image_url=None, + discord=None, + description=None, + additional=None, + github_repo=None, + ) + + await set_id( + wallet, + subtensor, + identity["name"], + identity["url"], + identity["image"], + identity["discord"], + identity["description"], + identity["additional"], + identity["github_repo"], + prompt, + ) + + +async def pow_register( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid, + processors, + update_interval, + output_in_place, + verbose, + use_cuda, + dev_id, + threads_per_block, +): + """Register neuron.""" + + await register_extrinsic( + subtensor, + wallet=wallet, + netuid=netuid, + prompt=True, + tpb=threads_per_block, + update_interval=update_interval, + num_processes=processors, + cuda=use_cuda, + dev_id=dev_id, + output_in_place=output_in_place, + log_verbose=verbose, + ) + + +async def register( + wallet: Wallet, subtensor: "SubtensorInterface", netuid: int, prompt: bool +): + """Register neuron by recycling some TAO.""" + + # Verify subnet exists + print_verbose("Checking subnet status") + block_hash = await subtensor.substrate.get_chain_head() + if not await subtensor.subnet_exists(netuid=netuid, block_hash=block_hash): + err_console.print(f"[red]Subnet {netuid} does not exist[/red]") + return + + # Check current recycle amount + print_verbose("Fetching recycle amount") + current_recycle_, balance = await asyncio.gather( + subtensor.get_hyperparameter( + param_name="Burn", netuid=netuid, block_hash=block_hash + ), + subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash=block_hash), + ) + current_recycle = ( + Balance.from_rao(int(current_recycle_)) if current_recycle_ else Balance(0) + ) + + # Check balance is sufficient + if balance < current_recycle: + err_console.print( + f"[red]Insufficient balance {balance} to register neuron. Current recycle is {current_recycle} TAO[/red]" + ) + return + + if prompt: + # TODO make this a reusable function, also used in subnets list + # Show creation table. + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Register to [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]netuid: {netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + table.add_column( + "Netuid", style="rgb(253,246,227)", no_wrap=True, justify="center" + ) + table.add_column( + "Symbol", + style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + no_wrap=True, + justify="center", + ) + table.add_column( + f"Cost ({Balance.get_unit(0)})", + style=COLOR_PALETTE["POOLS"]["TAO"], + no_wrap=True, + justify="center", + ) + table.add_column( + "Hotkey", + style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + no_wrap=True, + justify="center", + ) + table.add_column( + "Coldkey", + style=COLOR_PALETTE["GENERAL"]["COLDKEY"], + no_wrap=True, + justify="center", + ) + table.add_row( + str(netuid), + f"{Balance.get_unit(netuid)}", + f"τ {current_recycle.tao:.4f}", + f"{wallet.hotkey.ss58_address}", + f"{wallet.coldkeypub.ss58_address}", + ) + console.print(table) + if not ( + Confirm.ask( + f"Your balance is: [{COLOR_PALETTE['GENERAL']['BALANCE']}]{balance}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]\nThe cost to register by recycle is " + f"[{COLOR_PALETTE['GENERAL']['COST']}]{current_recycle}[/{COLOR_PALETTE['GENERAL']['COST']}]\nDo you want to continue?", + default=False, + ) + ): + return + + if netuid == 0: + await root_register_extrinsic(subtensor, wallet=wallet) + else: + await burned_register_extrinsic( + subtensor, + wallet=wallet, + netuid=netuid, + prompt=False, + old_balance=balance, + ) + + +# TODO: Confirm emissions, incentive, Dividends are to be fetched from subnet_state or keep NeuronInfo +async def metagraph_cmd( + subtensor: Optional["SubtensorInterface"], + netuid: Optional[int], + reuse_last: bool, + html_output: bool, + no_cache: bool, + display_cols: dict, +): + """Prints an entire metagraph.""" + # TODO allow config to set certain columns + if not reuse_last: + cast("SubtensorInterface", subtensor) + cast(int, netuid) + with console.status( + f":satellite: Syncing with chain: [white]{subtensor.network}[/white] ...", + spinner="aesthetic", + ) as status: + block_hash = await subtensor.substrate.get_chain_head() + + if not await subtensor.subnet_exists(netuid, block_hash): + print_error(f"Subnet with netuid: {netuid} does not exist", status) + return False + + ( + neurons, + difficulty_, + total_issuance_, + block, + subnet_state, + ) = await asyncio.gather( + subtensor.neurons(netuid, block_hash=block_hash), + subtensor.get_hyperparameter( + param_name="Difficulty", netuid=netuid, block_hash=block_hash + ), + subtensor.query( + module="SubtensorModule", + storage_function="TotalIssuance", + params=[], + block_hash=block_hash, + ), + subtensor.substrate.get_block_number(block_hash=block_hash), + subtensor.get_subnet_state(netuid=netuid), + ) + + difficulty = int(difficulty_) + total_issuance = Balance.from_rao(total_issuance_) + metagraph = MiniGraph( + netuid=netuid, + neurons=neurons, + subtensor=subtensor, + subnet_state=subnet_state, + block=block, + ) + table_data = [] + db_table = [] + total_global_stake = 0.0 + total_local_stake = 0.0 + total_rank = 0.0 + total_validator_trust = 0.0 + total_trust = 0.0 + total_consensus = 0.0 + total_incentive = 0.0 + total_dividends = 0.0 + total_emission = 0 + for uid in metagraph.uids: + neuron = metagraph.neurons[uid] + ep = metagraph.axons[uid] + row = [ + str(neuron.uid), + "{:.4f}".format(metagraph.global_stake[uid]), + "{:.4f}".format(metagraph.local_stake[uid]), + "{:.4f}".format(metagraph.stake_weights[uid]), + "{:.5f}".format(metagraph.ranks[uid]), + "{:.5f}".format(metagraph.trust[uid]), + "{:.5f}".format(metagraph.consensus[uid]), + "{:.5f}".format(metagraph.incentive[uid]), + "{:.5f}".format(metagraph.dividends[uid]), + "{}".format(int(metagraph.emission[uid] * 1000000000)), + "{:.5f}".format(metagraph.validator_trust[uid]), + "*" if metagraph.validator_permit[uid] else "", + str(metagraph.block.item() - metagraph.last_update[uid].item()), + str(metagraph.active[uid].item()), + ( + ep.ip + ":" + str(ep.port) + if ep.is_serving + else "[light_goldenrod2]none[/light_goldenrod2]" + ), + ep.hotkey[:10], + ep.coldkey[:10], + ] + db_row = [ + neuron.uid, + float(metagraph.global_stake[uid]), + float(metagraph.local_stake[uid]), + float(metagraph.stake_weights[uid]), + float(metagraph.ranks[uid]), + float(metagraph.trust[uid]), + float(metagraph.consensus[uid]), + float(metagraph.incentive[uid]), + float(metagraph.dividends[uid]), + int(metagraph.emission[uid] * 1000000000), + float(metagraph.validator_trust[uid]), + bool(metagraph.validator_permit[uid]), + metagraph.block.item() - metagraph.last_update[uid].item(), + metagraph.active[uid].item(), + (ep.ip + ":" + str(ep.port) if ep.is_serving else "ERROR"), + ep.hotkey[:10], + ep.coldkey[:10], + ] + db_table.append(db_row) + total_global_stake += metagraph.global_stake[uid] + total_local_stake += metagraph.local_stake[uid] + total_rank += metagraph.ranks[uid] + total_validator_trust += metagraph.validator_trust[uid] + total_trust += metagraph.trust[uid] + total_consensus += metagraph.consensus[uid] + total_incentive += metagraph.incentive[uid] + total_dividends += metagraph.dividends[uid] + total_emission += int(metagraph.emission[uid] * 1000000000) + table_data.append(row) + metadata_info = { + "total_global_stake": "\u03c4 {:.5f}".format(total_global_stake), + "total_local_stake": f"{Balance.get_unit(netuid)} " + + "{:.5f}".format(total_local_stake), + "rank": "{:.5f}".format(total_rank), + "validator_trust": "{:.5f}".format(total_validator_trust), + "trust": "{:.5f}".format(total_trust), + "consensus": "{:.5f}".format(total_consensus), + "incentive": "{:.5f}".format(total_incentive), + "dividends": "{:.5f}".format(total_dividends), + "emission": "\u03c1{}".format(int(total_emission)), + "net": f"{subtensor.network}:{metagraph.netuid}", + "block": str(metagraph.block.item()), + "N": f"{sum(metagraph.active.tolist())}/{metagraph.n.item()}", + "N0": str(sum(metagraph.active.tolist())), + "N1": str(metagraph.n.item()), + "issuance": str(total_issuance), + "difficulty": str(difficulty), + "total_neurons": str(len(metagraph.uids)), + "table_data": json.dumps(table_data), + } + if not no_cache: + update_metadata_table("metagraph", metadata_info) + create_table( + "metagraph", + columns=[ + ("UID", "INTEGER"), + ("GLOBAL_STAKE", "REAL"), + ("LOCAL_STAKE", "REAL"), + ("STAKE_WEIGHT", "REAL"), + ("RANK", "REAL"), + ("TRUST", "REAL"), + ("CONSENSUS", "REAL"), + ("INCENTIVE", "REAL"), + ("DIVIDENDS", "REAL"), + ("EMISSION", "INTEGER"), + ("VTRUST", "REAL"), + ("VAL", "INTEGER"), + ("UPDATED", "INTEGER"), + ("ACTIVE", "INTEGER"), + ("AXON", "TEXT"), + ("HOTKEY", "TEXT"), + ("COLDKEY", "TEXT"), + ], + rows=db_table, + ) + else: + try: + metadata_info = get_metadata_table("metagraph") + table_data = json.loads(metadata_info["table_data"]) + except sqlite3.OperationalError: + err_console.print( + "[red]Error[/red] Unable to retrieve table data. This is usually caused by attempting to use " + "`--reuse-last` before running the command a first time. In rare cases, this could also be due to " + "a corrupted database. Re-run the command (do not use `--reuse-last`) and see if that resolves your " + "issue." + ) + return + + if html_output: + try: + render_table( + table_name="metagraph", + table_info=f"Metagraph | " + f"net: {metadata_info['net']}, " + f"block: {metadata_info['block']}, " + f"N: {metadata_info['N']}, " + f"stake: {metadata_info['stake']}, " + f"issuance: {metadata_info['issuance']}, " + f"difficulty: {metadata_info['difficulty']}", + columns=[ + {"title": "UID", "field": "UID"}, + { + "title": "Global Stake", + "field": "GLOBAL_STAKE", + "formatter": "money", + "formatterParams": {"symbol": "τ", "precision": 5}, + }, + { + "title": "Local Stake", + "field": "LOCAL_STAKE", + "formatter": "money", + "formatterParams": { + "symbol": f"{Balance.get_unit(netuid)}", + "precision": 5, + }, + }, + { + "title": "Stake Weight", + "field": "STAKE_WEIGHT", + "formatter": "money", + "formatterParams": {"precision": 5}, + }, + { + "title": "Rank", + "field": "RANK", + "formatter": "money", + "formatterParams": {"precision": 5}, + }, + { + "title": "Trust", + "field": "TRUST", + "formatter": "money", + "formatterParams": {"precision": 5}, + }, + { + "title": "Consensus", + "field": "CONSENSUS", + "formatter": "money", + "formatterParams": {"precision": 5}, + }, + { + "title": "Incentive", + "field": "INCENTIVE", + "formatter": "money", + "formatterParams": {"precision": 5}, + }, + { + "title": "Dividends", + "field": "DIVIDENDS", + "formatter": "money", + "formatterParams": {"precision": 5}, + }, + {"title": "Emission", "field": "EMISSION"}, + { + "title": "VTrust", + "field": "VTRUST", + "formatter": "money", + "formatterParams": {"precision": 5}, + }, + {"title": "Validated", "field": "VAL"}, + {"title": "Updated", "field": "UPDATED"}, + {"title": "Active", "field": "ACTIVE"}, + {"title": "Axon", "field": "AXON"}, + {"title": "Hotkey", "field": "HOTKEY"}, + {"title": "Coldkey", "field": "COLDKEY"}, + ], + ) + except sqlite3.OperationalError: + err_console.print( + "[red]Error[/red] Unable to retrieve table data. This may indicate that your database is corrupted, " + "or was not able to load with the most recent data." + ) + return + else: + cols: dict[str, tuple[int, Column]] = { + "UID": ( + 0, + Column( + "[bold white]UID", + footer=f"[white]{metadata_info['total_neurons']}[/white]", + style="white", + justify="right", + ratio=0.75, + ), + ), + "GLOBAL_STAKE": ( + 1, + Column( + "[bold white]GLOBAL STAKE(\u03c4)", + footer=metadata_info["total_global_stake"], + style="bright_cyan", + justify="right", + no_wrap=True, + ratio=1.6, + ), + ), + "LOCAL_STAKE": ( + 2, + Column( + f"[bold white]LOCAL STAKE({Balance.get_unit(netuid)})", + footer=metadata_info["total_local_stake"], + style="bright_green", + justify="right", + no_wrap=True, + ratio=1.5, + ), + ), + "STAKE_WEIGHT": ( + 3, + Column( + f"[bold white]WEIGHT (\u03c4x{Balance.get_unit(netuid)})", + style="purple", + justify="right", + no_wrap=True, + ratio=1.3, + ), + ), + "RANK": ( + 4, + Column( + "[bold white]RANK", + footer=metadata_info["rank"], + style="medium_purple", + justify="right", + no_wrap=True, + ratio=1, + ), + ), + "TRUST": ( + 5, + Column( + "[bold white]TRUST", + footer=metadata_info["trust"], + style="dark_sea_green", + justify="right", + no_wrap=True, + ratio=1, + ), + ), + "CONSENSUS": ( + 6, + Column( + "[bold white]CONSENSUS", + footer=metadata_info["consensus"], + style="rgb(42,161,152)", + justify="right", + no_wrap=True, + ratio=1, + ), + ), + "INCENTIVE": ( + 7, + Column( + "[bold white]INCENTIVE", + footer=metadata_info["incentive"], + style="#5fd7ff", + justify="right", + no_wrap=True, + ratio=1, + ), + ), + "DIVIDENDS": ( + 8, + Column( + "[bold white]DIVIDENDS", + footer=metadata_info["dividends"], + style="#8787d7", + justify="right", + no_wrap=True, + ratio=1, + ), + ), + "EMISSION": ( + 9, + Column( + "[bold white]EMISSION(\u03c1)", + footer=metadata_info["emission"], + style="#d7d7ff", + justify="right", + no_wrap=True, + ratio=1.5, + ), + ), + "VTRUST": ( + 10, + Column( + "[bold white]VTRUST", + footer=metadata_info["validator_trust"], + style="magenta", + justify="right", + no_wrap=True, + ratio=1, + ), + ), + "VAL": ( + 11, + Column( + "[bold white]VAL", + justify="center", + style="bright_white", + no_wrap=True, + ratio=0.7, + ), + ), + "UPDATED": ( + 12, + Column("[bold white]UPDATED", justify="right", no_wrap=True, ratio=1), + ), + "ACTIVE": ( + 13, + Column( + "[bold white]ACTIVE", + justify="center", + style="#8787ff", + no_wrap=True, + ratio=1, + ), + ), + "AXON": ( + 14, + Column( + "[bold white]AXON", + justify="left", + style="dark_orange", + overflow="fold", + ratio=2, + ), + ), + "HOTKEY": ( + 15, + Column( + "[bold white]HOTKEY", + justify="center", + style="bright_magenta", + overflow="fold", + ratio=1.5, + ), + ), + "COLDKEY": ( + 16, + Column( + "[bold white]COLDKEY", + justify="center", + style="bright_magenta", + overflow="fold", + ratio=1.5, + ), + ), + } + table_cols: list[Column] = [] + table_cols_indices: list[int] = [] + for k, (idx, v) in cols.items(): + if display_cols[k] is True: + table_cols_indices.append(idx) + table_cols.append(v) + + table = Table( + *table_cols, + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_style="bold white", + title_justify="center", + show_lines=False, + expand=True, + title=( + f"[underline dark_orange]Metagraph[/underline dark_orange]\n\n" + f"Net: [bright_cyan]{metadata_info['net']}[/bright_cyan], " + f"Block: [bright_cyan]{metadata_info['block']}[/bright_cyan], " + f"N: [bright_green]{metadata_info['N0']}[/bright_green]/[bright_red]{metadata_info['N1']}[/bright_red], " + f"Total Local Stake: [dark_orange]{metadata_info['total_local_stake']}[/dark_orange], " + f"Issuance: [bright_blue]{metadata_info['issuance']}[/bright_blue], " + f"Difficulty: [bright_cyan]{metadata_info['difficulty']}[/bright_cyan]\n" + ), + pad_edge=True, + ) + + if all(x is False for x in display_cols.values()): + console.print("You have selected no columns to display in your config.") + table.add_row(" " * 256) # allows title to be printed + elif any(x is False for x in display_cols.values()): + console.print( + "Limiting column display output based on your config settings. Hiding columns " + f"{', '.join([k for (k, v) in display_cols.items() if v is False])}" + ) + for row in table_data: + new_row = [row[idx] for idx in table_cols_indices] + table.add_row(*new_row) + else: + for row in table_data: + table.add_row(*row) + + console.print(table) diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 2be3c0f6..dbbdf537 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -1,11 +1,14 @@ import asyncio -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Union, Optional +import typer from bittensor_wallet import Wallet from rich import box from rich.table import Column, Table +from rich.prompt import Confirm +from scalecodec import GenericCall -from bittensor_cli.src import HYPERPARAMS +from bittensor_cli.src import HYPERPARAMS, DelegatesDetails, COLOR_PALETTE from bittensor_cli.src.bittensor.chain_data import decode_account_id from bittensor_cli.src.bittensor.utils import ( console, @@ -14,10 +17,14 @@ print_verbose, normalize_hyperparameters, unlock_key, + blocks_to_duration, ) if TYPE_CHECKING: - from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + from bittensor_cli.src.bittensor.subtensor_interface import ( + SubtensorInterface, + ProposalVoteData, + ) # helpers and extrinsics @@ -89,12 +96,11 @@ async def set_hyperparameter_extrinsic( finalization/inclusion, the response is `True`. """ print_verbose("Confirming subnet owner") - subnet_owner_ = await subtensor.substrate.query( + subnet_owner = await subtensor.query( module="SubtensorModule", storage_function="SubnetOwner", params=[netuid], ) - subnet_owner = decode_account_id(subnet_owner_[0]) if subnet_owner != wallet.coldkeypub.ss58_address: err_console.print( ":cross_mark: [red]This wallet doesn't own the specified subnet.[/red]" @@ -110,7 +116,7 @@ async def set_hyperparameter_extrinsic( return False with console.status( - f":satellite: Setting hyperparameter {parameter} to {value} on subnet: {netuid} ...", + f":satellite: Setting hyperparameter [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{parameter}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] to [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{value}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] on subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] ...", spinner="earth", ): substrate = subtensor.substrate @@ -165,11 +171,297 @@ async def set_hyperparameter_extrinsic( # Successful registration, final check for membership else: console.print( - f":white_heavy_check_mark: [green]Hyperparameter {parameter} changed to {value}[/green]" + f":white_heavy_check_mark: [dark_sea_green3]Hyperparameter {parameter} changed to {value}[/dark_sea_green3]" ) return True +async def _get_senate_members( + subtensor: "SubtensorInterface", block_hash: Optional[str] = None +) -> list[str]: + """ + Gets all members of the senate on the given subtensor's network + + :param subtensor: SubtensorInterface object to use for the query + + :return: list of the senate members' ss58 addresses + """ + senate_members = await subtensor.query( + module="SenateMembers", + storage_function="Members", + params=None, + block_hash=block_hash, + ) + try: + return [ + decode_account_id(i[x][0]) for i in senate_members for x in range(len(i)) + ] + except (IndexError, TypeError): + err_console.print("Unable to retrieve senate members.") + return [] + + +async def _get_proposals( + subtensor: "SubtensorInterface", block_hash: str +) -> dict[str, tuple[dict, "ProposalVoteData"]]: + async def get_proposal_call_data(p_hash: str) -> Optional[GenericCall]: + proposal_data = await subtensor.query( + module="Triumvirate", + storage_function="ProposalOf", + block_hash=block_hash, + params=[p_hash], + ) + return proposal_data + + ph = await subtensor.query( + module="Triumvirate", + storage_function="Proposals", + params=None, + block_hash=block_hash, + ) + + try: + proposal_hashes: list[str] = [ + f"0x{bytes(ph[0][x][0]).hex()}" for x in range(len(ph[0])) + ] + except (IndexError, TypeError): + err_console.print("Unable to retrieve proposal vote data") + return {} + + call_data_, vote_data_ = await asyncio.gather( + asyncio.gather(*[get_proposal_call_data(h) for h in proposal_hashes]), + asyncio.gather(*[subtensor.get_vote_data(h) for h in proposal_hashes]), + ) + return { + proposal_hash: (cd, vd) + for cd, vd, proposal_hash in zip(call_data_, vote_data_, proposal_hashes) + } + + +def display_votes( + vote_data: "ProposalVoteData", delegate_info: dict[str, DelegatesDetails] +) -> str: + vote_list = list() + + for address in vote_data.ayes: + vote_list.append( + "{}: {}".format( + delegate_info[address].display if address in delegate_info else address, + "[bold green]Aye[/bold green]", + ) + ) + + for address in vote_data.nays: + vote_list.append( + "{}: {}".format( + delegate_info[address].display if address in delegate_info else address, + "[bold red]Nay[/bold red]", + ) + ) + + return "\n".join(vote_list) + + +def format_call_data(call_data: dict) -> str: + # Extract the module and call details + module, call_details = next(iter(call_data.items())) + + # Extract the call function name and arguments + call_info = call_details[0] + call_function, call_args = next(iter(call_info.items())) + + # Format arguments, handle nested/large payloads + formatted_args = [] + for arg_name, arg_value in call_args.items(): + if isinstance(arg_value, (tuple, list, dict)): + # For large nested, show abbreviated version + content_str = str(arg_value) + if len(content_str) > 20: + formatted_args.append(f"{arg_name}: ... [{len(content_str)}] ...") + else: + formatted_args.append(f"{arg_name}: {arg_value}") + else: + formatted_args.append(f"{arg_name}: {arg_value}") + + # Format the final output string + args_str = ", ".join(formatted_args) + return f"{module}.{call_function}({args_str})" + + +def _validate_proposal_hash(proposal_hash: str) -> bool: + if proposal_hash[0:2] != "0x" or len(proposal_hash) != 66: + return False + else: + return True + + +async def _is_senate_member(subtensor: "SubtensorInterface", hotkey_ss58: str) -> bool: + """ + Checks if a given neuron (identified by its hotkey SS58 address) is a member of the Bittensor senate. + The senate is a key governance body within the Bittensor network, responsible for overseeing and + approving various network operations and proposals. + + :param subtensor: SubtensorInterface object to use for the query + :param hotkey_ss58: The `SS58` address of the neuron's hotkey. + + :return: `True` if the neuron is a senate member at the given block, `False` otherwise. + + This function is crucial for understanding the governance dynamics of the Bittensor network and for + identifying the neurons that hold decision-making power within the network. + """ + + senate_members = await _get_senate_members(subtensor) + + if not hasattr(senate_members, "count"): + return False + + return senate_members.count(hotkey_ss58) > 0 + + +async def vote_senate_extrinsic( + subtensor: "SubtensorInterface", + wallet: Wallet, + proposal_hash: str, + proposal_idx: int, + vote: bool, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = True, + prompt: bool = False, +) -> bool: + """Votes ayes or nays on proposals. + + :param subtensor: The SubtensorInterface object to use for the query + :param wallet: Bittensor wallet object, with coldkey and hotkey unlocked. + :param proposal_hash: The hash of the proposal for which voting data is requested. + :param proposal_idx: The index of the proposal to vote. + :param vote: Whether to vote aye or nay. + :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns + `False` if the extrinsic fails to enter the block within the timeout. + :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, + or returns `False` if the extrinsic fails to be finalized within the timeout. + :param prompt: If `True`, the call waits for confirmation from the user before proceeding. + + :return: Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for + finalization/inclusion, the response is `True`. + """ + + if prompt: + # Prompt user for confirmation. + if not Confirm.ask(f"Cast a vote of {vote}?"): + return False + + with console.status(":satellite: Casting vote..", spinner="aesthetic"): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="vote", + call_params={ + "hotkey": wallet.hotkey.ss58_address, + "proposal": proposal_hash, + "index": proposal_idx, + "approve": vote, + }, + ) + success, err_msg = await subtensor.sign_and_send_extrinsic( + call, wallet, wait_for_inclusion, wait_for_finalization + ) + if not success: + err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") + await asyncio.sleep(0.5) + return False + # Successful vote, final check for data + else: + if vote_data := await subtensor.get_vote_data(proposal_hash): + if ( + vote_data.ayes.count(wallet.hotkey.ss58_address) > 0 + or vote_data.nays.count(wallet.hotkey.ss58_address) > 0 + ): + console.print(":white_heavy_check_mark: [green]Vote cast.[/green]") + return True + else: + # hotkey not found in ayes/nays + err_console.print( + ":cross_mark: [red]Unknown error. Couldn't find vote.[/red]" + ) + return False + else: + return False + + +async def set_take_extrinsic( + subtensor: "SubtensorInterface", + wallet: Wallet, + delegate_ss58: str, + take: float = 0.0, +) -> bool: + """ + Set delegate hotkey take + + :param subtensor: SubtensorInterface (initialized) + :param wallet: The wallet containing the hotkey to be nominated. + :param delegate_ss58: Hotkey + :param take: Delegate take on subnet ID + + :return: `True` if the process is successful, `False` otherwise. + + This function is a key part of the decentralized governance mechanism of Bittensor, allowing for the + dynamic selection and participation of validators in the network's consensus process. + """ + + # Calculate u16 representation of the take + take_u16 = int(take * 0xFFFF) + + print_verbose("Checking current take") + # Check if the new take is greater or lower than existing take or if existing is set + current_take = await get_current_take(subtensor, wallet) + current_take_u16 = int(float(current_take) * 0xFFFF) + + if take_u16 == current_take_u16: + console.print("Nothing to do, take hasn't changed") + return True + + if current_take_u16 < take_u16: + console.print( + f"Current take is [{COLOR_PALETTE['POOLS']['RATE']}]{current_take * 100.:.2f}%[/{COLOR_PALETTE['POOLS']['RATE']}]. Increasing to [{COLOR_PALETTE['POOLS']['RATE']}]{take * 100:.2f}%." + ) + with console.status( + f":satellite: Sending decrease_take_extrinsic call on [white]{subtensor}[/white] ..." + ): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="increase_take", + call_params={ + "hotkey": delegate_ss58, + "take": take_u16, + }, + ) + success, err = await subtensor.sign_and_send_extrinsic(call, wallet) + + else: + console.print( + f"Current take is [{COLOR_PALETTE['POOLS']['RATE']}]{current_take * 100.:.2f}%[/{COLOR_PALETTE['POOLS']['RATE']}]. Decreasing to [{COLOR_PALETTE['POOLS']['RATE']}]{take * 100:.2f}%." + ) + with console.status( + f":satellite: Sending increase_take_extrinsic call on [white]{subtensor}[/white] ..." + ): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="decrease_take", + call_params={ + "hotkey": delegate_ss58, + "take": take_u16, + }, + ) + success, err = await subtensor.sign_and_send_extrinsic(call, wallet) + + if not success: + err_console.print(err) + else: + console.print( + ":white_heavy_check_mark: [dark_sea_green_3]Finalized[/dark_sea_green_3]" + ) + return success + + # commands @@ -217,13 +509,20 @@ async def get_hyperparameters(subtensor: "SubtensorInterface", netuid: int): print_error(f"Subnet with netuid {netuid} does not exist.") return False subnet = await subtensor.get_subnet_hyperparameters(netuid) + subnet_info = await subtensor.subnet(netuid) + if subnet_info is None: + print_error(f"Subnet with netuid {netuid} does not exist.") + raise typer.Exit() table = Table( - Column("[white]HYPERPARAMETER", style="bright_magenta"), - Column("[white]VALUE", style="light_goldenrod2"), - Column("[white]NORMALIZED", style="light_goldenrod3"), - title=f"[underline dark_orange]\nSubnet Hyperparameters[/underline dark_orange]\n NETUID: [dark_orange]" - f"{netuid}[/dark_orange] - Network: [dark_orange]{subtensor.network}[/dark_orange]\n", + Column("[white]HYPERPARAMETER", style=COLOR_PALETTE["SUDO"]["HYPERPARAMETER"]), + Column("[white]VALUE", style=COLOR_PALETTE["SUDO"]["VALUE"]), + Column("[white]NORMALIZED", style=COLOR_PALETTE["SUDO"]["NORMALIZED"]), + title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]\nSubnet Hyperparameters\n NETUID: " + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}" + f"{f' ({subnet_info.subnet_name})' if subnet_info.subnet_name is not None else ''}" + f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f" - Network: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", show_footer=True, width=None, pad_edge=False, @@ -238,3 +537,246 @@ async def get_hyperparameters(subtensor: "SubtensorInterface", netuid: int): console.print(table) return True + + +async def get_senate(subtensor: "SubtensorInterface"): + """View Bittensor's senate memebers""" + with console.status( + f":satellite: Syncing with chain: [white]{subtensor}[/white] ...", + spinner="aesthetic", + ) as status: + print_verbose("Fetching senate members", status) + senate_members = await _get_senate_members(subtensor) + + print_verbose("Fetching member details from Github and on-chain identities") + delegate_info: dict[ + str, DelegatesDetails + ] = await subtensor.get_delegate_identities() + + table = Table( + Column( + "[bold white]NAME", + style="bright_cyan", + no_wrap=True, + ), + Column( + "[bold white]ADDRESS", + style="bright_magenta", + no_wrap=True, + ), + title=f"[underline dark_orange]Senate[/underline dark_orange]\n[dark_orange]Network: {subtensor.network}\n", + show_footer=True, + show_edge=False, + expand=False, + border_style="bright_black", + leading=True, + ) + + for ss58_address in senate_members: + table.add_row( + ( + delegate_info[ss58_address].display + if ss58_address in delegate_info + else "~" + ), + ss58_address, + ) + + return console.print(table) + + +async def proposals(subtensor: "SubtensorInterface", verbose: bool): + console.print( + ":satellite: Syncing with chain: [white]{}[/white] ...".format( + subtensor.network + ) + ) + block_hash = await subtensor.substrate.get_chain_head() + senate_members, all_proposals, current_block = await asyncio.gather( + _get_senate_members(subtensor, block_hash), + _get_proposals(subtensor, block_hash), + subtensor.substrate.get_block_number(block_hash), + ) + + registered_delegate_info: dict[ + str, DelegatesDetails + ] = await subtensor.get_delegate_identities() + + title = ( + f"[bold #4196D6]Bittensor Governance Proposals[/bold #4196D6]\n" + f"[steel_blue3]Current Block:[/steel_blue3] {current_block}\t" + f"[steel_blue3]Network:[/steel_blue3] {subtensor.network}\n\n" + f"[steel_blue3]Active Proposals:[/steel_blue3] {len(all_proposals)}\t" + f"[steel_blue3]Senate Size:[/steel_blue3] {len(senate_members)}\n" + ) + table = Table( + Column( + "[white]HASH", + style="light_goldenrod2", + no_wrap=True, + ), + Column("[white]THRESHOLD", style="rgb(42,161,152)"), + Column("[white]AYES", style="green"), + Column("[white]NAYS", style="red"), + Column( + "[white]VOTES", + style="rgb(50,163,219)", + ), + Column("[white]END", style="bright_cyan"), + Column("[white]CALLDATA", style="dark_sea_green", width=30), + title=title, + show_footer=True, + box=box.SIMPLE_HEAVY, + pad_edge=False, + width=None, + border_style="bright_black", + ) + for hash_, (call_data, vote_data) in all_proposals.items(): + blocks_remaining = vote_data.end - current_block + if blocks_remaining > 0: + duration_str = blocks_to_duration(blocks_remaining) + vote_end_cell = f"{vote_data.end} [dim](in {duration_str})[/dim]" + else: + vote_end_cell = f"{vote_data.end} [red](expired)[/red]" + + ayes_threshold = ( + (len(vote_data.ayes) / vote_data.threshold * 100) + if vote_data.threshold > 0 + else 0 + ) + nays_threshold = ( + (len(vote_data.nays) / vote_data.threshold * 100) + if vote_data.threshold > 0 + else 0 + ) + table.add_row( + hash_ if verbose else f"{hash_[:4]}...{hash_[-4:]}", + str(vote_data.threshold), + f"{len(vote_data.ayes)} ({ayes_threshold:.2f}%)", + f"{len(vote_data.nays)} ({nays_threshold:.2f}%)", + display_votes(vote_data, registered_delegate_info), + vote_end_cell, + format_call_data(call_data), + ) + console.print(table) + console.print( + "\n[dim]* Both Ayes and Nays percentages are calculated relative to the proposal's threshold.[/dim]" + ) + + +async def senate_vote( + wallet: Wallet, + subtensor: "SubtensorInterface", + proposal_hash: str, + vote: bool, + prompt: bool, +) -> bool: + """Vote in Bittensor's governance protocol proposals""" + + if not proposal_hash: + err_console.print( + "Aborting: Proposal hash not specified. View all proposals with the `proposals` command." + ) + return False + elif not _validate_proposal_hash(proposal_hash): + err_console.print( + "Aborting. Proposal hash is invalid. Proposal hashes should start with '0x' and be 32 bytes long" + ) + return False + + print_verbose(f"Fetching senate status of {wallet.hotkey_str}") + if not await _is_senate_member(subtensor, hotkey_ss58=wallet.hotkey.ss58_address): + err_console.print( + f"Aborting: Hotkey {wallet.hotkey.ss58_address} isn't a senate member." + ) + return False + + # Unlock the wallet. + if ( + not unlock_key(wallet, "hot").success + and unlock_key(wallet, "cold").success + ): + return False + + console.print(f"Fetching proposals in [dark_orange]network: {subtensor.network}") + vote_data = await subtensor.get_vote_data(proposal_hash, reuse_block=True) + if not vote_data: + err_console.print(":cross_mark: [red]Failed[/red]: Proposal not found.") + return False + + success = await vote_senate_extrinsic( + subtensor=subtensor, + wallet=wallet, + proposal_hash=proposal_hash, + proposal_idx=vote_data.index, + vote=vote, + wait_for_inclusion=True, + wait_for_finalization=False, + prompt=prompt, + ) + + return success + + +async def get_current_take(subtensor: "SubtensorInterface", wallet: Wallet): + current_take = await subtensor.current_take(wallet.hotkey.ss58_address) + return current_take + + +async def display_current_take(subtensor: "SubtensorInterface", wallet: Wallet) -> None: + current_take = await get_current_take(subtensor, wallet) + console.print( + f"Current take is [{COLOR_PALETTE['POOLS']['RATE']}]{current_take * 100.:.2f}%" + ) + + +async def set_take( + wallet: Wallet, subtensor: "SubtensorInterface", take: float +) -> bool: + """Set delegate take.""" + + async def _do_set_take() -> bool: + if take > 0.18 or take < 0: + err_console.print("ERROR: Take value should not exceed 18% or be below 0%") + return False + + block_hash = await subtensor.substrate.get_chain_head() + netuids_registered = await subtensor.get_netuids_for_hotkey( + wallet.hotkey.ss58_address, block_hash=block_hash + ) + if not len(netuids_registered) > 0: + err_console.print( + f"Hotkey [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{wallet.hotkey.ss58_address}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}] is not registered to any subnet. Please register using [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]`btcli subnets register`[{COLOR_PALETTE['GENERAL']['SUBHEADING']}] and try again." + ) + return False + + result: bool = await set_take_extrinsic( + subtensor=subtensor, + wallet=wallet, + delegate_ss58=wallet.hotkey.ss58_address, + take=take, + ) + + if not result: + err_console.print("Could not set the take") + return False + else: + new_take = await get_current_take(subtensor, wallet) + console.print( + f"New take is [{COLOR_PALETTE['POOLS']['RATE']}]{new_take * 100.:.2f}%" + ) + return True + + console.print( + f"Setting take on [{COLOR_PALETTE['GENERAL']['LINKS']}]network: {subtensor.network}" + ) + + if ( + not unlock_key(wallet, "hot").success + and unlock_key(wallet, "cold").success + ): + return False + + result_ = await _do_set_take() + + return result_ diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index d49c03e2..e3a3e8d6 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1,39 +1,31 @@ import asyncio -import binascii import itertools import os -import sys from collections import defaultdict -from functools import partial -from sys import getsizeof -from typing import Collection, Generator, Optional +from typing import Generator, Optional import aiohttp -from bittensor_wallet import Wallet +from bittensor_wallet import Wallet, Keypair from bittensor_wallet.errors import KeyFileError from bittensor_wallet.keyfile import Keyfile from fuzzywuzzy import fuzz from rich import box from rich.align import Align -from rich.prompt import Confirm from rich.table import Column, Table from rich.tree import Tree from rich.padding import Padding -from rich.prompt import IntPrompt import typer +from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor import utils from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.chain_data import ( DelegateInfo, NeuronInfoLite, - StakeInfo, - decode_account_id, ) from bittensor_cli.src.bittensor.extrinsics.registration import ( run_faucet_extrinsic, swap_hotkey_extrinsic, - is_hotkey_registered, ) from bittensor_cli.src.bittensor.extrinsics.transfer import transfer_extrinsic from bittensor_cli.src.bittensor.networking import int_to_ip @@ -49,19 +41,13 @@ get_hotkey_wallets_for_wallet, is_valid_ss58_address, validate_coldkey_presence, - retry_prompt, + get_subnet_name, + millify_tao, unlock_key, - hex_to_bytes, + WalletLike, ) -class WalletLike: - def __init__(self, name=None, hotkey_ss58=None, hotkey_str=None): - self.name = name - self.hotkey_ss58 = hotkey_ss58 - self.hotkey_str = hotkey_str - - async def regen_coldkey( wallet: Wallet, mnemonic: Optional[str], @@ -161,15 +147,27 @@ async def new_hotkey( wallet: Wallet, n_words: int, use_password: bool, + uri: Optional[str] = None, overwrite: Optional[bool] = False, ): """Creates a new hotkey under this wallet.""" try: - wallet.create_new_hotkey( - n_words=n_words, - use_password=use_password, - overwrite=overwrite, - ) + if uri: + try: + keypair = Keypair.create_from_uri(uri) + except Exception as e: + print_error(f"Failed to create keypair from URI {uri}: {str(e)}") + wallet.set_hotkey(keypair=keypair, encrypt=use_password) + console.print( + f"[dark_sea_green]Hotkey created from URI: {uri}[/dark_sea_green]" + ) + else: + wallet.create_new_hotkey( + n_words=n_words, + use_password=use_password, + overwrite=overwrite, + ) + console.print("[dark_sea_green]Hotkey created[/dark_sea_green]") except KeyFileError: print_error("KeyFileError: File is not writable") @@ -178,15 +176,28 @@ async def new_coldkey( wallet: Wallet, n_words: int, use_password: bool, + uri: Optional[str] = None, overwrite: Optional[bool] = False, ): """Creates a new coldkey under this wallet.""" try: - wallet.create_new_coldkey( - n_words=n_words, - use_password=use_password, - overwrite=overwrite, - ) + if uri: + try: + keypair = Keypair.create_from_uri(uri) + except Exception as e: + print_error(f"Failed to create keypair from URI {uri}: {str(e)}") + wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=False) + wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=False) + console.print( + f"[dark_sea_green]Coldkey created from URI: {uri}[/dark_sea_green]" + ) + else: + wallet.create_new_coldkey( + n_words=n_words, + use_password=use_password, + overwrite=overwrite, + ) + console.print("[dark_sea_green]Coldkey created[/dark_sea_green]") except KeyFileError: print_error("KeyFileError: File is not writable") @@ -195,26 +206,41 @@ async def wallet_create( wallet: Wallet, n_words: int = 12, use_password: bool = True, + uri: Optional[str] = None, overwrite: Optional[bool] = False, ): """Creates a new wallet.""" - try: - wallet.create_new_coldkey( - n_words=n_words, - use_password=use_password, - overwrite=overwrite, + if uri: + try: + keypair = Keypair.create_from_uri(uri) + wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=False) + wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=False) + wallet.set_hotkey(keypair=keypair, encrypt=False, overwrite=False) + except Exception as e: + print_error(f"Failed to create keypair from URI: {str(e)}") + console.print( + f"[dark_sea_green]Wallet created from URI: {uri}[/dark_sea_green]" ) - except KeyFileError: - print_error("KeyFileError: File is not writable") + else: + try: + wallet.create_new_coldkey( + n_words=n_words, + use_password=use_password, + overwrite=overwrite, + ) + console.print("[dark_sea_green]Coldkey created[/dark_sea_green]") + except KeyFileError: + print_error("KeyFileError: File is not writable") - try: - wallet.create_new_hotkey( - n_words=n_words, - use_password=False, - overwrite=overwrite, - ) - except KeyFileError: - print_error("KeyFileError: File is not writable") + try: + wallet.create_new_hotkey( + n_words=n_words, + use_password=False, + overwrite=overwrite, + ) + console.print("[dark_sea_green]Hotkey created[/dark_sea_green]") + except KeyFileError: + print_error("KeyFileError: File is not writable") def get_coldkey_wallets_for_path(path: str) -> list[Wallet]: @@ -258,7 +284,11 @@ async def wallet_balance( """Retrieves the current balance of the specified wallet""" if ss58_addresses: coldkeys = ss58_addresses - wallet_names = [f"Provided Address {i + 1}" for i in range(len(ss58_addresses))] + identities = await subtensor.query_all_identities() + wallet_names = [ + f"{identities.get(coldkey, {'name': f'Provided address {i}'})['name']}" + for i, coldkey in enumerate(coldkeys) + ] elif not all_balances: if not wallet.coldkeypub_file.exists_on_device(): @@ -277,16 +307,12 @@ async def wallet_balance( wallet_names = [wallet.name] block_hash = await subtensor.substrate.get_chain_head() - free_balances, staked_balances = await asyncio.gather( - subtensor.get_balance(*coldkeys, block_hash=block_hash), - subtensor.get_total_stake_for_coldkey(*coldkeys, block_hash=block_hash), - ) + free_balances = await subtensor.get_balances(*coldkeys, block_hash=block_hash) total_free_balance = sum(free_balances.values()) - total_staked_balance = sum(staked_balances.values()) balances = { - name: (coldkey, free_balances[coldkey], staked_balances[coldkey]) + name: (coldkey, free_balances[coldkey]) for (name, coldkey) in zip(wallet_names, coldkeys) } @@ -298,28 +324,16 @@ async def wallet_balance( ), Column( "[white]Coldkey Address", - style="bright_magenta", + style=COLOR_PALETTE["GENERAL"]["COLDKEY"], no_wrap=True, ), Column( "[white]Free Balance", justify="right", - style="light_goldenrod2", + style=COLOR_PALETTE["GENERAL"]["BALANCE"], no_wrap=True, ), - Column( - "[white]Staked Balance", - justify="right", - style="orange1", - no_wrap=True, - ), - Column( - "[white]Total Balance", - justify="right", - style="green", - no_wrap=True, - ), - title=f"[underline dark_orange]Wallet Coldkey Balance[/underline dark_orange]\n[dark_orange]Network: {subtensor.network}", + title=f"\n [{COLOR_PALETTE['GENERAL']['HEADER']}]Wallet Coldkey Balance\nNetwork: {subtensor.network}", show_footer=True, show_edge=False, border_style="bright_black", @@ -329,24 +343,21 @@ async def wallet_balance( leading=True, ) - for name, (coldkey, free, staked) in balances.items(): + for name, (coldkey, free) in balances.items(): table.add_row( name, coldkey, str(free), - str(staked), - str(free + staked), ) table.add_row() table.add_row( "Total Balance", "", str(total_free_balance), - str(total_staked_balance), - str(total_free_balance + total_staked_balance), ) console.print(Padding(table, (0, 0, 0, 4))) await subtensor.substrate.close() + return total_free_balance async def get_wallet_transfers(wallet_address: str) -> list[dict]: @@ -497,7 +508,9 @@ async def wallet_list(wallet_path: str): wallet_tree = root.add( f"[bold blue]Coldkey[/bold blue] [green]{wallet.name}[/green] ss58_address [green]{coldkeypub_str}[/green]" ) - hotkeys = utils.get_hotkey_wallets_for_wallet(wallet, show_nulls=True) + hotkeys = utils.get_hotkey_wallets_for_wallet( + wallet, show_nulls=True, show_encrypted=True + ) for hkey in hotkeys: data = f"[bold red]Hotkey[/bold red][green] {hkey}[/green] (?)" if hkey: @@ -541,7 +554,7 @@ async def _get_total_balance( ] total_balance += sum( ( - await subtensor.get_balance( + await subtensor.get_balances( *(x.coldkeypub.ss58_address for x in _balance_cold_wallets), block_hash=block_hash, ) @@ -563,7 +576,7 @@ async def _get_total_balance( ): total_balance = sum( ( - await subtensor.get_balance( + await subtensor.get_balances( coldkey_wallet.coldkeypub.ss58_address, block_hash=block_hash ) ).values() @@ -587,17 +600,19 @@ async def overview( include_hotkeys: Optional[list[str]] = None, exclude_hotkeys: Optional[list[str]] = None, netuids_filter: Optional[list[int]] = None, + verbose: bool = False, ): """Prints an overview for the wallet's coldkey.""" total_balance = Balance(0) # We are printing for every coldkey. - print_verbose("Fetching total balance for coldkey/s") block_hash = await subtensor.substrate.get_chain_head() all_hotkeys, total_balance = await _get_total_balance( total_balance, subtensor, wallet, all_wallets, block_hash=block_hash ) + _dynamic_info = await subtensor.all_subnets() + dynamic_info = {info.netuid: info for info in _dynamic_info} with console.status( f":satellite: Synchronizing with chain [white]{subtensor.network}[/white]", @@ -605,9 +620,6 @@ async def overview( ) as status: # We are printing for a select number of hotkeys from all_hotkeys. if include_hotkeys or exclude_hotkeys: - print_verbose( - "Fetching for select hotkeys passed in 'include_hotkeys'", status - ) all_hotkeys = _get_hotkeys(include_hotkeys, exclude_hotkeys, all_hotkeys) # Check we have keys to display. @@ -617,17 +629,14 @@ async def overview( # Pull neuron info for all keys. neurons: dict[str, list[NeuronInfoLite]] = {} - print_verbose("Fetching subnet netuids", status) block, all_netuids = await asyncio.gather( subtensor.substrate.get_block_number(None), subtensor.get_all_subnet_netuids(), ) - print_verbose("Filtering netuids by registered hotkeys", status) netuids = await subtensor.filter_netuids_by_registered_hotkeys( all_netuids, netuids_filter, all_hotkeys, reuse_block=True ) - # bittensor.logging.debug(f"Netuids to check: {netuids}") for netuid in netuids: neurons[str(netuid)] = [] @@ -648,122 +657,17 @@ async def overview( ) all_hotkeys, _ = validate_coldkey_presence(all_hotkeys) - print_verbose("Fetching key addresses", status) all_hotkey_addresses, hotkey_coldkey_to_hotkey_wallet = _get_key_address( all_hotkeys ) - print_verbose("Pulling and processing neuron information for all keys", status) results = await _get_neurons_for_netuids( subtensor, netuids, all_hotkey_addresses ) neurons = _process_neuron_results(results, neurons, netuids) - total_coldkey_stake_from_metagraph = await _calculate_total_coldkey_stake( - neurons - ) - - has_alerts = False - alerts_table = Table(show_header=True, header_style="bold magenta") - alerts_table.add_column("🥩 alert!") - alerts_table.add_row( - "[bold]Detected the following stake(s) associated with coldkey(s) that are not linked to any local hotkeys:[/bold]" - ) - alerts_table.add_row("") - - coldkeys_to_check = [] - ck_stakes = await subtensor.get_total_stake_for_coldkey( - *( - coldkey_wallet.coldkeypub.ss58_address - for coldkey_wallet in all_coldkey_wallets - if coldkey_wallet.coldkeypub - ), - block_hash=block_hash, - ) - for coldkey_wallet in all_coldkey_wallets: - if coldkey_wallet.coldkeypub: - # Check if we have any stake with hotkeys that are not registered. - difference = ( - ck_stakes[coldkey_wallet.coldkeypub.ss58_address] - - total_coldkey_stake_from_metagraph[ - coldkey_wallet.coldkeypub.ss58_address - ] - ) - if difference == 0: - continue # We have all our stake registered. - - has_alerts = True - coldkeys_to_check.append(coldkey_wallet) - alerts_table.add_row( - "[light_goldenrod2]{}[/light_goldenrod2] stake associated with coldkey [bright_magenta]{}[/bright_magenta] (ss58: [bright_magenta]{}[/bright_magenta])".format( - abs(difference), - coldkey_wallet.name, - coldkey_wallet.coldkeypub.ss58_address, - ) - ) - if has_alerts: - alerts_table.add_row("") - alerts_table.add_row( - "[bold yellow]Note:[/bold yellow] This stake might be delegated, staked to another user's hotkey, or associated with a hotkey not present in your wallet." - ) - alerts_table.add_row( - "You can find out more by executing `[bold]btcli wallet inspect[/bold]` command." - ) - - if coldkeys_to_check: - # We have some stake that is not with a registered hotkey. - if "-1" not in neurons: - neurons["-1"] = [] - - print_verbose("Checking coldkeys for de-registered stake", status) - results = await asyncio.gather( - *[ - _get_de_registered_stake_for_coldkey_wallet( - subtensor, all_hotkey_addresses, coldkey_wallet - ) - for coldkey_wallet in coldkeys_to_check - ] - ) - - for result in results: - coldkey_wallet, de_registered_stake, err_msg = result - if err_msg is not None: - err_console.print(err_msg) - - if len(de_registered_stake) == 0: - continue # We have no de-registered stake with this coldkey. - - de_registered_neurons = [] - for hotkey_addr, our_stake in de_registered_stake: - # Make a neuron info lite for this hotkey and coldkey. - de_registered_neuron = NeuronInfoLite.get_null_neuron() - de_registered_neuron.hotkey = hotkey_addr - de_registered_neuron.coldkey = coldkey_wallet.coldkeypub.ss58_address - de_registered_neuron.total_stake = Balance(our_stake) - de_registered_neurons.append(de_registered_neuron) - - # Add this hotkey to the wallets dict - wallet_ = WalletLike( - name=wallet.name, - hotkey_ss58=hotkey_addr, - hotkey_str=hotkey_addr[:5], - ) - # Indicates a hotkey not on local machine but exists in stake_info obj on-chain - if hotkey_coldkey_to_hotkey_wallet.get(hotkey_addr) is None: - hotkey_coldkey_to_hotkey_wallet[hotkey_addr] = {} - hotkey_coldkey_to_hotkey_wallet[hotkey_addr][ - coldkey_wallet.coldkeypub.ss58_address - ] = wallet_ - - # Add neurons to overview. - neurons["-1"].extend(de_registered_neurons) - # Setup outer table. grid = Table.grid(pad_edge=True) - # If there are any alerts, add them to the grid - if has_alerts: - grid.add_row(alerts_table) - # Add title if not all_wallets: title = "[underline dark_orange]Wallet[/underline dark_orange]\n" @@ -782,9 +686,6 @@ async def overview( ) ) # Generate rows per netuid - hotkeys_seen = set() - total_neurons = 0 - total_stake = 0.0 tempos = await asyncio.gather( *[ subtensor.get_hyperparameter("Tempo", netuid, block_hash) @@ -792,7 +693,6 @@ async def overview( ] ) for netuid, subnet_tempo in zip(netuids, tempos): - last_subnet = netuid == netuids[-1] table_data = [] total_rank = 0.0 total_trust = 0.0 @@ -801,6 +701,8 @@ async def overview( total_incentive = 0.0 total_dividends = 0.0 total_emission = 0 + total_stake = 0 + total_neurons = 0 for nn in neurons[str(netuid)]: hotwallet = hotkey_coldkey_to_hotkey_wallet.get(nn.hotkey, {}).get( @@ -821,7 +723,7 @@ async def overview( validator_trust = nn.validator_trust incentive = nn.incentive dividends = nn.dividends - emission = int(nn.emission / (subnet_tempo + 1) * 1e9) + emission = int(nn.emission / (subnet_tempo + 1) * 1e9) # Per block last_update = int(block - nn.last_update) validator_permit = nn.validator_permit row = [ @@ -829,14 +731,14 @@ async def overview( hotwallet.hotkey_str, str(uid), str(active), - "{:.5f}".format(stake), - "{:.5f}".format(rank), - "{:.5f}".format(trust), - "{:.5f}".format(consensus), - "{:.5f}".format(incentive), - "{:.5f}".format(dividends), - "{:_}".format(emission), - "{:.5f}".format(validator_trust), + f"{stake:.4f}" if verbose else millify_tao(stake), + f"{rank:.4f}" if verbose else millify_tao(rank), + f"{trust:.4f}" if verbose else millify_tao(trust), + f"{consensus:.4f}" if verbose else millify_tao(consensus), + f"{incentive:.4f}" if verbose else millify_tao(incentive), + f"{dividends:.4f}" if verbose else millify_tao(dividends), + f"{emission:.4f}", + f"{validator_trust:.4f}" if verbose else millify_tao(validator_trust), "*" if validator_permit else "", str(last_update), ( @@ -854,23 +756,15 @@ async def overview( total_dividends += dividends total_emission += emission total_validator_trust += validator_trust - - if (nn.hotkey, nn.coldkey) not in hotkeys_seen: - # Don't double count stake on hotkey-coldkey pairs. - hotkeys_seen.add((nn.hotkey, nn.coldkey)) - total_stake += stake - - # netuid -1 are neurons that are de-registered. - if netuid != "-1": - total_neurons += 1 + total_stake += stake + total_neurons += 1 table_data.append(row) # Add subnet header - if netuid == "-1": - grid.add_row("Deregistered Neurons") - else: - grid.add_row(f"Subnet: [dark_orange]{netuid}[/dark_orange]") + grid.add_row( + f"Subnet: [dark_orange]{netuid}: {get_subnet_name(dynamic_info[netuid])} {dynamic_info[netuid].symbol}[/dark_orange]" + ) width = console.width table = Table( show_footer=False, @@ -879,45 +773,34 @@ async def overview( expand=True, width=width - 5, ) - if last_subnet: - table.add_column( - "[white]COLDKEY", str(total_neurons), style="bold bright_cyan", ratio=2 - ) - table.add_column( - "[white]HOTKEY", str(total_neurons), style="bright_cyan", ratio=2 - ) - else: - # No footer for non-last subnet. - table.add_column("[white]COLDKEY", style="bold bright_cyan", ratio=2) - table.add_column("[white]HOTKEY", style="bright_cyan", ratio=2) + + table.add_column("[white]COLDKEY", style="bold bright_cyan", ratio=2) + table.add_column("[white]HOTKEY", style="bright_cyan", ratio=2) table.add_column( "[white]UID", str(total_neurons), style="rgb(42,161,152)", ratio=1 ) table.add_column( "[white]ACTIVE", justify="right", style="#8787ff", no_wrap=True, ratio=1 ) - if last_subnet: - table.add_column( - "[white]STAKE(\u03c4)", - "\u03c4{:.5f}".format(total_stake), - footer_style="bold white", - justify="right", - style="dark_orange", - no_wrap=True, - ratio=1, - ) - else: - # No footer for non-last subnet. - table.add_column( - "[white]STAKE(\u03c4)", - justify="right", - style="dark_orange", - no_wrap=True, - ratio=1.5, - ) + + _total_stake_formatted = ( + f"{total_stake:.4f}" if verbose else millify_tao(total_stake) + ) + table.add_column( + "[white]STAKE(\u03c4)" + if netuid == 0 + else f"[white]STAKE({Balance.get_unit(netuid)})", + f"{_total_stake_formatted} {Balance.get_unit(netuid)}" + if netuid != 0 + else f"{Balance.get_unit(netuid)} {_total_stake_formatted}", + justify="right", + style="dark_orange", + no_wrap=True, + ratio=1.5, + ) table.add_column( "[white]RANK", - "{:.5f}".format(total_rank), + f"{total_rank:.4f}", justify="right", style="medium_purple", no_wrap=True, @@ -925,7 +808,7 @@ async def overview( ) table.add_column( "[white]TRUST", - "{:.5f}".format(total_trust), + f"{total_trust:.4f}", justify="right", style="green", no_wrap=True, @@ -933,7 +816,7 @@ async def overview( ) table.add_column( "[white]CONSENSUS", - "{:.5f}".format(total_consensus), + f"{total_consensus:.4f}", justify="right", style="rgb(42,161,152)", no_wrap=True, @@ -941,7 +824,7 @@ async def overview( ) table.add_column( "[white]INCENTIVE", - "{:.5f}".format(total_incentive), + f"{total_incentive:.4f}", justify="right", style="#5fd7ff", no_wrap=True, @@ -949,7 +832,7 @@ async def overview( ) table.add_column( "[white]DIVIDENDS", - "{:.5f}".format(total_dividends), + f"{total_dividends:.4f}", justify="right", style="#8787d7", no_wrap=True, @@ -957,7 +840,7 @@ async def overview( ) table.add_column( "[white]EMISSION(\u03c1)", - "\u03c1{:_}".format(total_emission), + f"\u03c1{total_emission}", justify="right", style="#d7d7ff", no_wrap=True, @@ -965,7 +848,7 @@ async def overview( ) table.add_column( "[white]VTRUST", - "{:.5f}".format(total_validator_trust), + f"{total_validator_trust:.4f}", justify="right", style="magenta", no_wrap=True, @@ -1148,7 +1031,7 @@ def _map_hotkey_to_neurons( async def _fetch_neuron_for_netuid( netuid: int, subtensor: SubtensorInterface -) -> tuple[int, Optional[str]]: +) -> tuple[int, list[NeuronInfoLite]]: """ Retrieves all neurons for a specified netuid @@ -1157,25 +1040,13 @@ async def _fetch_neuron_for_netuid( :return: the original netuid, and a mapping of the neurons to their NeuronInfoLite objects """ - - async def neurons_lite_for_uid(uid: int) -> Optional[str]: - block_hash = subtensor.substrate.last_block_hash - hex_bytes_result = await subtensor.query_runtime_api( - runtime_api="NeuronInfoRuntimeApi", - method="get_neurons_lite", - params=[uid], - block_hash=block_hash, - ) - - return hex_bytes_result - - neurons = await neurons_lite_for_uid(uid=netuid) + neurons = await subtensor.neurons_lite(netuid=netuid) return netuid, neurons async def _fetch_all_neurons( netuids: list[int], subtensor -) -> list[tuple[int, Optional[str]]]: +) -> list[tuple[int, list[NeuronInfoLite]]]: """Retrieves all neurons for each of the specified netuids""" return list( await asyncio.gather( @@ -1184,85 +1055,16 @@ async def _fetch_all_neurons( ) -def _process_neurons_for_netuids( - netuids_with_all_neurons_hex_bytes: list[tuple[int, Optional[str]]], -) -> list[tuple[int, list[NeuronInfoLite]]]: - """ - Decode a list of hex-bytes neurons with their respective netuid - - :param netuids_with_all_neurons_hex_bytes: netuids with hex-bytes neurons - :return: netuids mapped to decoded neurons - """ - all_results = [ - (netuid, NeuronInfoLite.list_from_vec_u8(hex_to_bytes(result))) - if result - else (netuid, []) - for netuid, result in netuids_with_all_neurons_hex_bytes - ] - return all_results - - async def _get_neurons_for_netuids( subtensor: SubtensorInterface, netuids: list[int], hot_wallets: list[str] ) -> list[tuple[int, list["NeuronInfoLite"], Optional[str]]]: - all_neurons_hex_bytes = await _fetch_all_neurons(netuids, subtensor) - - all_processed_neurons = _process_neurons_for_netuids(all_neurons_hex_bytes) + all_neurons = await _fetch_all_neurons(netuids, subtensor) return [ _map_hotkey_to_neurons(neurons, hot_wallets, netuid) - for netuid, neurons in all_processed_neurons + for netuid, neurons in all_neurons ] -async def _get_de_registered_stake_for_coldkey_wallet( - subtensor: SubtensorInterface, - all_hotkey_addresses: Collection[str], - coldkey_wallet: Wallet, -) -> tuple[Wallet, list[tuple[str, float]], Optional[str]]: - """ - Looks at the total stake of a coldkey, then filters this based on the supplied hotkey addresses - depending on whether the hotkey is a delegate - - :param subtensor: SubtensorInterface to make queries with - :param all_hotkey_addresses: collection of hotkey SS58 addresses - :param coldkey_wallet: Wallet containing coldkey - - :return: (original wallet, [(hotkey SS58, stake in TAO), ...], error message) - """ - # Pull all stake for our coldkey - all_stake_info_for_coldkey = await subtensor.get_stake_info_for_coldkey( - coldkey_ss58=coldkey_wallet.coldkeypub.ss58_address, reuse_block=True - ) - - # Filter out hotkeys that are in our wallets - # Filter out hotkeys that are delegates. - async def _filter_stake_info(stake_info: StakeInfo) -> bool: - if stake_info.stake == 0: - return False # Skip hotkeys that we have no stake with. - if stake_info.hotkey_ss58 in all_hotkey_addresses: - return False # Skip hotkeys that are in our wallets. - return not await subtensor.is_hotkey_delegate( - hotkey_ss58=stake_info.hotkey_ss58, reuse_block=True - ) - - all_staked = await asyncio.gather( - *[_filter_stake_info(stake_info) for stake_info in all_stake_info_for_coldkey] - ) - - # Collecting all filtered stake info using async for loop - all_staked_hotkeys = [] - for stake_info, staked in zip(all_stake_info_for_coldkey, all_staked): - if staked: - all_staked_hotkeys.append( - ( - stake_info.hotkey_ss58, - stake_info.stake.tao, - ) - ) - - return coldkey_wallet, all_staked_hotkeys, None - - async def transfer( wallet: Wallet, subtensor: SubtensorInterface, @@ -1273,11 +1075,11 @@ async def transfer( ): """Transfer token of amount to destination.""" await transfer_extrinsic( - subtensor, - wallet, - destination, - Balance.from_tao(amount), - transfer_all, + subtensor=subtensor, + wallet=wallet, + destination=destination, + amount=Balance.from_tao(amount), + transfer_all=transfer_all, prompt=prompt, ) @@ -1292,6 +1094,8 @@ def delegate_row_maker( delegates_: list[tuple[DelegateInfo, Balance]], ) -> Generator[list[str], None, None]: for d_, staked in delegates_: + if not staked.tao > 0: + continue if d_.hotkey_ss58 in registered_delegate_info: delegate_name = registered_delegate_info[d_.hotkey_ss58].display else: @@ -1301,7 +1105,11 @@ def delegate_row_maker( + [ str(delegate_name), str(staked), - str(d_.total_daily_return.tao * (staked.tao / d_.total_stake.tao)), + str( + d_.total_daily_return.tao * (staked.tao / d_.total_stake.tao) + if d_.total_stake.tao != 0 + else 0 + ), ] + [""] * 4 ) @@ -1381,7 +1189,7 @@ def neuron_row_maker( all_delegates: list[list[tuple[DelegateInfo, Balance]]] with console.status("Pulling balance data...", spinner="aesthetic"): balances, all_neurons, all_delegates = await asyncio.gather( - subtensor.get_balance( + subtensor.get_balances( *[w.coldkeypub.ss58_address for w in wallets_with_ckp_file], block_hash=block_hash, ), @@ -1467,210 +1275,94 @@ async def swap_hotkey( ) -def set_id_prompts( - validator: bool, -) -> tuple[str, str, str, str, str, str, str, str, str, bool, int]: - """ - Used to prompt the user to input their info for setting the ID - :return: (display_name, legal_name, web_url, riot_handle, email,pgp_fingerprint, image_url, info_, twitter_url, - validator_id) - """ - text_rejection = partial( - retry_prompt, - rejection=lambda x: sys.getsizeof(x) > 113, - rejection_text="[red]Error:[/red] Identity field must be <= 64 raw bytes.", - ) +def create_identity_table(title: str = None): + if not title: + title = "On-Chain Identity" - def pgp_check(s: str): - try: - if s.startswith("0x"): - s = s[2:] # Strip '0x' - pgp_fingerprint_encoded = binascii.unhexlify(s.replace(" ", "")) - except Exception: - return True - return True if len(pgp_fingerprint_encoded) != 20 else False - - display_name = text_rejection("Display name") - legal_name = text_rejection("Legal name") - web_url = text_rejection("Web URL") - riot_handle = text_rejection("Riot handle") - email = text_rejection("Email address") - pgp_fingerprint = retry_prompt( - "PGP fingerprint (Eg: A1B2 C3D4 E5F6 7890 1234 5678 9ABC DEF0 1234 5678)", - lambda s: False if not s else pgp_check(s), - "[red]Error:[/red] PGP Fingerprint must be exactly 20 bytes.", - ) - image_url = text_rejection("Image URL") - info_ = text_rejection("Enter info") - twitter_url = text_rejection("𝕏 (Twitter) URL") - - subnet_netuid = None - if validator is False: - subnet_netuid = IntPrompt.ask("Enter the netuid of the subnet you own") - - return ( - display_name, - legal_name, - web_url, - pgp_fingerprint, - riot_handle, - email, - image_url, - twitter_url, - info_, - validator, - subnet_netuid, + table = Table( + Column( + "Item", + justify="right", + style=COLOR_PALETTE["GENERAL"]["SUBHEADING_MAIN"], + no_wrap=True, + ), + Column("Value", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]), + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{title}", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, ) + return table async def set_id( wallet: Wallet, subtensor: SubtensorInterface, - display_name: str, - legal_name: str, + name: str, web_url: str, - pgp_fingerprint: str, - riot_handle: str, - email: str, - image: str, - twitter: str, - info_: str, - validator_id: bool, - subnet_netuid: int, + image_url: str, + discord: str, + description: str, + additional: str, + github_repo: str, prompt: bool, ): """Create a new or update existing identity on-chain.""" - id_dict = { - "additional": [[]], - "display": display_name, - "legal": legal_name, - "web": web_url, - "pgp_fingerprint": pgp_fingerprint, - "riot": riot_handle, - "email": email, - "image": image, - "twitter": twitter, - "info": info_, + identity_data = { + "name": name.encode(), + "url": web_url.encode(), + "image": image_url.encode(), + "discord": discord.encode(), + "description": description.encode(), + "additional": additional.encode(), + "github_repo": github_repo.encode(), } - try: - pgp_fingerprint_encoded = binascii.unhexlify(pgp_fingerprint.replace(" ", "")) - except Exception as e: - print_error(f"The PGP is not in the correct format: {e}") - raise typer.Exit() - - for field, string in id_dict.items(): - if ( - field == "pgp_fingerprint" - and pgp_fingerprint - and len(pgp_fingerprint_encoded) != 20 - ): + for field, value in identity_data.items(): + max_size = 64 # bytes + if len(value) > max_size: err_console.print( - "[red]Error:[/red] PGP Fingerprint must be exactly 20 bytes." - ) - return False - elif (size := getsizeof(string)) > 113: # 64 + 49 overhead bytes for string - err_console.print( - f"[red]Error:[/red] Identity field [white]{field}[/white] must be <= 64 raw bytes.\n" - f"Value: '{string}' currently [white]{size} bytes[/white]." + f"[red]Error:[/red] Identity field [white]{field}[/white] must be <= {max_size} bytes.\n" + f"Value '{value.decode()}' is {len(value)} bytes." ) return False - identified = ( - wallet.hotkey.ss58_address if validator_id else wallet.coldkey.ss58_address - ) - encoded_id_dict = { - "info": { - "additional": [[]], - "display": {f"Raw{len(display_name.encode())}": display_name.encode()}, - "legal": {f"Raw{len(legal_name.encode())}": legal_name.encode()}, - "web": {f"Raw{len(web_url.encode())}": web_url.encode()}, - "riot": {f"Raw{len(riot_handle.encode())}": riot_handle.encode()}, - "email": {f"Raw{len(email.encode())}": email.encode()}, - "pgp_fingerprint": pgp_fingerprint_encoded if pgp_fingerprint else None, - "image": {f"Raw{len(image.encode())}": image.encode()}, - "info": {f"Raw{len(info_.encode())}": info_.encode()}, - "twitter": {f"Raw{len(twitter.encode())}": twitter.encode()}, - }, - "identified": identified, - } - - if prompt: - if not Confirm.ask( - "Cost to register an Identity is [bold white italic]0.1 Tao[/bold white italic]," - " are you sure you wish to continue?" - ): - console.print(":cross_mark: Aborted!") - raise typer.Exit() - - if validator_id: - block_hash = await subtensor.substrate.get_chain_head() - - is_registered_on_root, hotkey_owner = await asyncio.gather( - is_hotkey_registered( - subtensor, netuid=0, hotkey_ss58=wallet.hotkey.ss58_address - ), - subtensor.get_hotkey_owner( - hotkey_ss58=wallet.hotkey.ss58_address, block_hash=block_hash - ), - ) - - if not is_registered_on_root: - print_error("The hotkey is not registered on root. Aborting.") - return False - - own_hotkey = wallet.coldkeypub.ss58_address == hotkey_owner - if not own_hotkey: - print_error("The hotkey doesn't belong to the coldkey wallet. Aborting.") - return False - else: - subnet_owner_ = await subtensor.substrate.query( - module="SubtensorModule", - storage_function="SubnetOwner", - params=[subnet_netuid], - ) - subnet_owner = decode_account_id(subnet_owner_[0]) - if subnet_owner != wallet.coldkeypub.ss58_address: - print_error(f":cross_mark: This wallet doesn't own subnet {subnet_netuid}.") - return False - if not unlock_key(wallet).success: return False + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="set_identity", + call_params=identity_data, + ) + with console.status( - ":satellite: [bold green]Updating identity on-chain...", spinner="earth" + " :satellite: [dark_sea_green3]Updating identity on-chain...", spinner="earth" ): - call = await subtensor.substrate.compose_call( - call_module="Registry", - call_function="set_identity", - call_params=encoded_id_dict, - ) success, err_msg = await subtensor.sign_and_send_extrinsic(call, wallet) if not success: err_console.print(f"[red]:cross_mark: Failed![/red] {err_msg}") return - console.print(":white_heavy_check_mark: Success!") - identity = await subtensor.query_identity( - identified or wallet.coldkey.ss58_address - ) - - table = Table( - Column("Key", justify="right", style="cyan", no_wrap=True), - Column("Value", style="magenta"), - title="[bold white italic]Updated On-Chain Identity", - ) + console.print(":white_heavy_check_mark: [dark_sea_green3]Success!") + identity = await subtensor.query_identity(wallet.coldkeypub.ss58_address) - table.add_row("Address", identified or wallet.coldkey.ss58_address) + table = create_identity_table(title="New on-chain Identity") + table.add_row("Address", wallet.coldkeypub.ss58_address) for key, value in identity.items(): - table.add_row(key, str(value) if value is not None else "~") + table.add_row(key, str(value) if value else "~") return console.print(table) -async def get_id(subtensor: SubtensorInterface, ss58_address: str): +async def get_id(subtensor: SubtensorInterface, ss58_address: str, title: str = None): with console.status( ":satellite: [bold green]Querying chain identity...", spinner="earth" ): @@ -1678,40 +1370,37 @@ async def get_id(subtensor: SubtensorInterface, ss58_address: str): if not identity: err_console.print( - f"[red]Identity not found[/red]" - f" for [light_goldenrod3]{ss58_address}[/light_goldenrod3]" - f" on [white]{subtensor}[/white]" + f"[blue]Existing identity not found[/blue]" + f" for [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]" + f" on {subtensor}" ) - return - table = Table( - Column("Item", justify="right", style="cyan", no_wrap=True), - Column("Value", style="magenta"), - title="[bold white italic]On-Chain Identity", - ) + return {} + table = create_identity_table(title) table.add_row("Address", ss58_address) for key, value in identity.items(): - table.add_row(key, str(value) if value is not None else "~") + table.add_row(key, str(value) if value else "~") - return console.print(table) + console.print(table) + return identity async def check_coldkey_swap(wallet: Wallet, subtensor: SubtensorInterface): - arbitration_check = len( + arbitration_check = len( # TODO verify this works ( - await subtensor.substrate.query( + await subtensor.query( module="SubtensorModule", storage_function="ColdkeySwapDestinations", params=[wallet.coldkeypub.ss58_address], ) - ).decode() + ) ) if arbitration_check == 0: console.print( "[green]There has been no previous key swap initiated for your coldkey.[/green]" ) elif arbitration_check == 1: - arbitration_block = await subtensor.substrate.query( + arbitration_block = await subtensor.query( module="SubtensorModule", storage_function="ColdkeyArbitrationBlock", params=[wallet.coldkeypub.ss58_address], @@ -1735,17 +1424,22 @@ async def check_coldkey_swap(wallet: Wallet, subtensor: SubtensorInterface): async def sign(wallet: Wallet, message: str, use_hotkey: str): """Sign a message using the provided wallet or hotkey.""" + if not use_hotkey: - if not unlock_key(wallet).success: + if not unlock_key(wallet, "cold").success: return False keypair = wallet.coldkey - print_verbose(f"Signing using coldkey: {wallet.name}") + print_verbose( + f"Signing using [{COLOR_PALETTE['GENERAL']['COLDKEY']}]coldkey: {wallet.name}" + ) else: if not unlock_key(wallet, "hot").success: return False keypair = wallet.hotkey - print_verbose(f"Signing using hotkey: {wallet.hotkey_str}") + print_verbose( + f"Signing using [{COLOR_PALETTE['GENERAL']['HOTKEY']}]hotkey: {wallet.hotkey_str}" + ) signed_message = keypair.sign(message.encode("utf-8")).hex() - console.print("[bold green]Message signed successfully:") + console.print("[dark_sea_green3]Message signed successfully:") console.print(signed_message) diff --git a/bittensor_cli/src/commands/weights.py b/bittensor_cli/src/commands/weights.py index 90d26d43..29337be4 100644 --- a/bittensor_cli/src/commands/weights.py +++ b/bittensor_cli/src/commands/weights.py @@ -7,7 +7,7 @@ import numpy as np from numpy.typing import NDArray from rich.prompt import Confirm -from substrateinterface.exceptions import SubstrateRequestException +from async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src.bittensor.utils import err_console, console, format_error_message from bittensor_cli.src.bittensor.extrinsics.root import ( @@ -146,7 +146,7 @@ async def _commit_reveal( ) -> tuple[bool, str]: interval = int( await self.subtensor.get_hyperparameter( - param_name="get_commit_reveal_weights_interval", + param_name="get_commit_reveal_period", netuid=self.netuid, reuse_block=False, ) diff --git a/requirements.txt b/requirements.txt index 0a6f921c..521e9494 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ wheel async-property==0.2.2 +async-substrate-interface>=1.0.0 aiohttp~=3.10.2 backoff~=2.2.1 GitPython>=3.0.0 @@ -13,8 +14,9 @@ pytest python-Levenshtein rich~=13.7 scalecodec==1.2.11 -substrate-interface~=1.7.9 typer~=0.12 websockets>=14.1 -bittensor-wallet>=3.0.0 -bt-decode==0.4.0 \ No newline at end of file +bittensor-wallet>=3.0.3 +plotille +pywry +plotly \ No newline at end of file diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py index 5dcd9afb..4d9f9c7b 100644 --- a/tests/e2e_tests/conftest.py +++ b/tests/e2e_tests/conftest.py @@ -8,9 +8,7 @@ import time import pytest -from bittensor_cli.src.bittensor.async_substrate_interface import ( - AsyncSubstrateInterface, -) +from async_substrate_interface.async_substrate import AsyncSubstrateInterface from .utils import setup_wallet @@ -58,7 +56,7 @@ def wait_for_node_start(process, pattern): wait_for_node_start(process, pattern) # Run the test, passing in substrate interface - yield AsyncSubstrateInterface(chain_endpoint="ws://127.0.0.1:9945") + yield AsyncSubstrateInterface(url="ws://127.0.0.1:9945") # Terminate the process group (includes all child processes) os.killpg(os.getpgid(process.pid), signal.SIGTERM) diff --git a/tests/e2e_tests/test_root.py b/tests/e2e_tests/test_root.py index 1e87dbfa..5a7674bb 100644 --- a/tests/e2e_tests/test_root.py +++ b/tests/e2e_tests/test_root.py @@ -1,6 +1,7 @@ import time from bittensor_cli.src.bittensor.balances import Balance +import pytest from .utils import extract_coldkey_balance """ @@ -15,7 +16,7 @@ * btcli root undelegate-stake """ - +@pytest.mark.skip(reason="Root no longer applicable. We will update this.") def test_root_commands(local_chain, wallet_setup): """ Test the root commands and inspects their output diff --git a/tests/e2e_tests/test_senate.py b/tests/e2e_tests/test_senate.py index 5dd9de57..7d8a5241 100644 --- a/tests/e2e_tests/test_senate.py +++ b/tests/e2e_tests/test_senate.py @@ -44,7 +44,7 @@ def test_senate(local_chain, wallet_setup): # Fetch existing senate list root_senate = exec_command_bob( - command="root", + command="sudo", sub_command="senate", extra_args=[ "--network", @@ -58,9 +58,11 @@ def test_senate(local_chain, wallet_setup): # Register Bob to the root network (0) # Registering to root automatically makes you a senator if eligible root_register = exec_command_bob( - command="root", + command="subnets", sub_command="register", extra_args=[ + "--netuid", + "0", "--wallet-path", wallet_path_bob, "--network", @@ -76,7 +78,7 @@ def test_senate(local_chain, wallet_setup): # Fetch the senate members after registering to root root_senate_after_reg = exec_command_bob( - command="root", + command="sudo", sub_command="senate", extra_args=[ "--chain", @@ -93,7 +95,7 @@ def test_senate(local_chain, wallet_setup): # Fetch proposals after adding one proposals = exec_command_bob( - command="root", + command="sudo", sub_command="proposals", extra_args=[ "--chain", @@ -118,7 +120,7 @@ def test_senate(local_chain, wallet_setup): # Vote on the proposal by Bob (vote aye) vote_aye = exec_command_bob( - command="root", + command="sudo", sub_command="senate-vote", extra_args=[ "--wallet-path", @@ -139,7 +141,7 @@ def test_senate(local_chain, wallet_setup): # Fetch proposals after voting aye proposals_after_aye = exec_command_bob( - command="root", + command="sudo", sub_command="proposals", extra_args=[ "--chain", @@ -162,9 +164,11 @@ def test_senate(local_chain, wallet_setup): # Register Alice to the root network (0) # Registering to root automatically makes you a senator if eligible root_register = exec_command_alice( - command="root", + command="subnets", sub_command="register", extra_args=[ + "--netuid", + "0", "--wallet-path", wallet_path_alice, "--chain", @@ -180,7 +184,7 @@ def test_senate(local_chain, wallet_setup): # Vote on the proposal by Alice (vote nay) vote_nay = exec_command_alice( - command="root", + command="sudo", sub_command="senate-vote", extra_args=[ "--wallet-path", @@ -201,7 +205,7 @@ def test_senate(local_chain, wallet_setup): # Fetch proposals after voting proposals_after_nay = exec_command_bob( - command="root", + command="sudo", sub_command="proposals", extra_args=[ "--chain", diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 16276486..6684b1a1 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -32,7 +32,7 @@ def test_staking(local_chain, wallet_setup): AssertionError: If any of the checks or verifications fail """ print("Testing staking and sudo commands🧪") - netuid = 1 + netuid = 2 wallet_path_alice = "//Alice" # Create wallet for Alice @@ -51,6 +51,22 @@ def test_staking(local_chain, wallet_setup): "ws://127.0.0.1:9945", "--wallet-name", wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", "--no-prompt", ], ) @@ -74,13 +90,15 @@ def test_staking(local_chain, wallet_setup): "--no-prompt", ], ) - assert "✅ Registered" in register_subnet.stdout + assert "✅ Already Registered" in register_subnet.stdout # Add stake to Alice's hotkey add_stake = exec_command_alice( command="stake", sub_command="add", extra_args=[ + "--netuid", + netuid, "--wallet-path", wallet_path_alice, "--wallet-name", @@ -91,6 +109,9 @@ def test_staking(local_chain, wallet_setup): "ws://127.0.0.1:9945", "--amount", "100", + "--tolerance", + "0.1", + "--partial", "--no-prompt", ], ) @@ -99,7 +120,7 @@ def test_staking(local_chain, wallet_setup): # Execute stake show for Alice's wallet show_stake = exec_command_alice( command="stake", - sub_command="show", + sub_command="list", extra_args=[ "--wallet-path", wallet_path_alice, @@ -113,19 +134,16 @@ def test_staking(local_chain, wallet_setup): cleaned_stake = [ re.sub(r"\s+", " ", line) for line in show_stake.stdout.splitlines() ] - stake_added = cleaned_stake[6].split()[6].strip("τ") - assert Balance.from_tao(100) == Balance.from_tao(float(stake_added)) - - # TODO: Ask nucleus the rate limit and wait epoch - # Sleep 120 seconds for rate limiting when unstaking - print("Waiting for interval for 2 minutes") - time.sleep(120) + stake_added = cleaned_stake[8].split("│")[3].strip().split()[0] + assert Balance.from_tao(float(stake_added)) >= Balance.from_tao(100) # Execute remove_stake command and remove all 100 TAO from Alice remove_stake = exec_command_alice( command="stake", sub_command="remove", extra_args=[ + "--netuid", + netuid, "--wallet-path", wallet_path_alice, "--wallet-name", @@ -136,6 +154,9 @@ def test_staking(local_chain, wallet_setup): "ws://127.0.0.1:9945", "--amount", "100", + "--tolerance", + "0.1", + "--partial", "--no-prompt", ], ) @@ -155,10 +176,10 @@ def test_staking(local_chain, wallet_setup): # Parse all hyperparameters and single out max_burn in TAO all_hyperparams = hyperparams.stdout.splitlines() - max_burn_tao = all_hyperparams[22].split()[2] + max_burn_tao = all_hyperparams[22].split()[3] # Assert max_burn is 100 TAO from default - assert Balance.from_tao(float(max_burn_tao.strip("τ"))) == Balance.from_tao(100) + assert Balance.from_tao(float(max_burn_tao)) == Balance.from_tao(100) # Change max_burn hyperparameter to 10 TAO change_hyperparams = exec_command_alice( @@ -199,10 +220,8 @@ def test_staking(local_chain, wallet_setup): # Parse updated hyperparameters all_updated_hyperparams = updated_hyperparams.stdout.splitlines() - updated_max_burn_tao = all_updated_hyperparams[22].split()[2] + updated_max_burn_tao = all_updated_hyperparams[22].split()[3] # Assert max_burn is now 10 TAO - assert Balance.from_tao(float(updated_max_burn_tao.strip("τ"))) == Balance.from_tao( - 10 - ) + assert Balance.from_tao(float(updated_max_burn_tao)) == Balance.from_tao(10) print("✅ Passed staking and sudo commands") diff --git a/tests/e2e_tests/test_wallet_creations.py b/tests/e2e_tests/test_wallet_creations.py index 7490a480..52aa3139 100644 --- a/tests/e2e_tests/test_wallet_creations.py +++ b/tests/e2e_tests/test_wallet_creations.py @@ -373,7 +373,7 @@ def test_wallet_regen(wallet_setup, capfd): "--mnemonic", mnemonics["coldkey"], "--no-use-password", - "--overwrite" + "--overwrite", ], ) @@ -413,7 +413,7 @@ def test_wallet_regen(wallet_setup, capfd): wallet_path, "--ss58-address", ss58_address, - "--overwrite" + "--overwrite", ], ) diff --git a/tests/e2e_tests/test_wallet_interactions.py b/tests/e2e_tests/test_wallet_interactions.py index c43efb90..ad44c5a1 100644 --- a/tests/e2e_tests/test_wallet_interactions.py +++ b/tests/e2e_tests/test_wallet_interactions.py @@ -38,7 +38,7 @@ def test_wallet_overview_inspect(local_chain, wallet_setup): AssertionError: If any of the checks or verifications fail """ print("Testing wallet overview, inspect command 🧪") - netuid = 1 + netuid = 2 wallet_path_name = "//Alice" # Create wallet for Alice @@ -55,6 +55,22 @@ def test_wallet_overview_inspect(local_chain, wallet_setup): "ws://127.0.0.1:9945", "--wallet-name", wallet.name, + "--wallet-hotkey", + wallet.hotkey_str, + "--name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "test@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "test#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Test subnet", "--no-prompt", ], ) @@ -77,26 +93,6 @@ def test_wallet_overview_inspect(local_chain, wallet_setup): # Assert using regex that the subnet is visible in subnets list assert verify_subnet_entry(subnets_list.stdout, netuid, keypair.ss58_address) - # Register Alice in netuid = 1 using her hotkey - register_subnet = exec_command( - command="subnets", - sub_command="register", - extra_args=[ - "--wallet-path", - wallet_path, - "--wallet-name", - wallet.name, - "--hotkey", - wallet.hotkey_str, - "--netuid", - "1", - "--chain", - "ws://127.0.0.1:9945", - "--no-prompt", - ], - ) - assert "✅ Registered" in register_subnet.stdout - # Check balance of Alice after registering to the subnet wallet_balance = exec_command( command="wallet", @@ -145,31 +141,33 @@ def test_wallet_overview_inspect(local_chain, wallet_setup): axon_active=False, # Axon is not active until we run validator/miner ) - # Execute wallet inspect command - inspect = exec_command( - command="wallet", - sub_command="inspect", - extra_args=[ - "--wallet-path", - wallet_path, - "--wallet-name", - wallet.name, - "--chain", - "ws://127.0.0.1:9945", - ], - ) - - # Assert correct entry is present in wallet inspect - assert validate_wallet_inspect( - inspect.stdout, - coldkey=wallet.name, - balance=Balance.from_tao(balance["free_balance"]), - delegates=None, # We have not delegated anywhere yet - hotkeys_netuid=[ - (1, f"default-{wallet.hotkey.ss58_address}", 0, False) - ], # (netuid, hotkey-display, stake, check_emissions) - ) - print("Passed wallet overview, inspect command ✅") + # TODO: Re-enable this once inspect is ported over + if False: + # Execute wallet inspect command + inspect = exec_command( + command="wallet", + sub_command="inspect", + extra_args=[ + "--wallet-path", + wallet_path, + "--wallet-name", + wallet.name, + "--chain", + "ws://127.0.0.1:9945", + ], + ) + + # Assert correct entry is present in wallet inspect + assert validate_wallet_inspect( + inspect.stdout, + coldkey=wallet.name, + balance=Balance.from_tao(balance["free_balance"]), + delegates=None, # We have not delegated anywhere yet + hotkeys_netuid=[ + (1, f"default-{wallet.hotkey.ss58_address}", 0, False) + ], # (netuid, hotkey-display, stake, check_emissions) + ) + print("Passed wallet overview command ✅") def test_wallet_transfer(local_chain, wallet_setup): @@ -353,7 +351,7 @@ def test_wallet_identities(local_chain, wallet_setup): """ print("Testing wallet set-id, get-id, sign command 🧪") - netuid = 1 + netuid = 2 wallet_path_alice = "//Alice" # Create wallet for Alice @@ -361,25 +359,6 @@ def test_wallet_identities(local_chain, wallet_setup): wallet_path_alice ) - # Register Alice to the root network (0) - # Either root list neurons + subnet registered can set-id or subnet owners - root_register = exec_command_alice( - command="root", - sub_command="register", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--network", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--hotkey", - wallet_alice.hotkey_str, - "--no-prompt", - ], - ) - assert "✅ Registered" in root_register.stdout - # Register a subnet with sudo as Alice result = exec_command_alice( command="subnets", @@ -391,42 +370,36 @@ def test_wallet_identities(local_chain, wallet_setup): "ws://127.0.0.1:9945", "--wallet-name", wallet_alice.name, - "--no-prompt", - ], - ) - assert f"✅ Registered subnetwork with netuid: {netuid}" in result.stdout - - # Register Alice in netuid = 1 using her hotkey - register_subnet = exec_command_alice( - command="subnets", - sub_command="register", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--wallet-name", - wallet_alice.name, - "--hotkey", + "--wallet-hotkey", wallet_alice.hotkey_str, - "--netuid", - netuid, - "--chain", - "ws://127.0.0.1:9945", + "--name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", "--no-prompt", ], ) - assert "✅ Registered" in register_subnet.stdout + assert f"✅ Registered subnetwork with netuid: {netuid}" in result.stdout # Define values for Alice's identity alice_identity = { - "display_name": "Alice", - "legal_name": "Alice OTF", - "web_url": "https://bittensor.com/", - "riot": "MyRiotID", - "email": "alice@opentensor.dev", - "pgp": "D2A1 F4A3 B1D3 5A74 63F0 678E 35E7 041A 22C1 A4FE", - "image_url": "https://bittensor.com/img/dark-Bittensor.svg", - "info": "I am a tester for OTF", - "twitter": "https://x.com/opentensor", + "name": "Alice OTF", + "url": "https://bittensor.com/", + "image": "https://bittensor.com/img/dark-Bittensor.svg", + "discord": "alice#1234", + "description": "I am a tester for OTF", + "additional": "Lead Developer", + "github_repo": "https://github.com/opentensor/bittensor", } # Execute btcli set-identity command @@ -442,25 +415,20 @@ def test_wallet_identities(local_chain, wallet_setup): wallet_alice.name, "--wallet-hotkey", wallet_alice.hotkey_str, - "--display-name", - alice_identity["display_name"], - "--legal-name", - alice_identity["legal_name"], + "--name", + alice_identity["name"], "--web-url", - alice_identity["web_url"], - "--riot", - alice_identity["riot"], - "--email", - alice_identity["email"], - "--pgp", - alice_identity["pgp"], - "--image-url", - alice_identity["image_url"], - "--info", - alice_identity["info"], - "-x", - alice_identity["twitter"], - "--validator", + alice_identity["url"], + "--image-url", + alice_identity["image"], + "--discord", + alice_identity["discord"], + "--description", + alice_identity["description"], + "--additional", + alice_identity["additional"], + "--github", + alice_identity["github_repo"], "--no-prompt", ], ) @@ -469,18 +437,18 @@ def test_wallet_identities(local_chain, wallet_setup): assert "✅ Success!" in set_id.stdout set_id_output = set_id.stdout.splitlines() - assert alice_identity["display_name"] in set_id_output[7] - assert alice_identity["legal_name"] in set_id_output[8] - assert alice_identity["web_url"] in set_id_output[9] - assert alice_identity["riot"] in set_id_output[10] - assert alice_identity["email"] in set_id_output[11] - assert alice_identity["pgp"] in set_id_output[12] - assert alice_identity["image_url"] in set_id_output[13] - assert alice_identity["twitter"] in set_id_output[14] + assert alice_identity["name"] in set_id_output[6] + assert alice_identity["url"] in set_id_output[7] + assert alice_identity["github_repo"] in set_id_output[8] + assert alice_identity["image"] in set_id_output[9] + assert alice_identity["discord"] in set_id_output[10] + assert alice_identity["description"] in set_id_output[11] + assert alice_identity["additional"] in set_id_output[12] + # TODO: Currently coldkey + hotkey are the same for test wallets. # Maybe we can add a new key to help in distinguishing - assert wallet_alice.hotkey.ss58_address in set_id_output[5] + assert wallet_alice.coldkeypub.ss58_address in set_id_output[5] # Execute btcli get-identity using hotkey get_identity = exec_command_alice( @@ -490,20 +458,20 @@ def test_wallet_identities(local_chain, wallet_setup): "--chain", "ws://127.0.0.1:9945", "--key", - wallet_alice.hotkey.ss58_address, + wallet_alice.coldkeypub.ss58_address, ], ) # Assert all correct values are being fetched for the ID we just set get_identity_output = get_identity.stdout.splitlines() - assert alice_identity["display_name"] in get_identity_output[6] - assert alice_identity["legal_name"] in get_identity_output[7] - assert alice_identity["web_url"] in get_identity_output[8] - assert alice_identity["riot"] in get_identity_output[9] - assert alice_identity["email"] in get_identity_output[10] - assert alice_identity["pgp"] in get_identity_output[11] - assert alice_identity["image_url"] in get_identity_output[12] - assert alice_identity["twitter"] in get_identity_output[13] + assert alice_identity["name"] in get_identity_output[5] + assert alice_identity["url"] in get_identity_output[6] + assert alice_identity["github_repo"] in get_identity_output[7] + assert alice_identity["image"] in get_identity_output[8] + assert alice_identity["discord"] in get_identity_output[9] + assert alice_identity["description"] in get_identity_output[10] + assert alice_identity["additional"] in get_identity_output[11] + # Sign a message using hotkey sign_using_hotkey = exec_command_alice( diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index 9a339987..a568af51 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -10,9 +10,7 @@ from typer.testing import CliRunner if TYPE_CHECKING: - from bittensor_cli.src.bittensor.async_substrate_interface import ( - AsyncSubstrateInterface, - ) + from async_substrate_interface.async_substrate import AsyncSubstrateInterface template_path = os.getcwd() + "/neurons/" templates_repo = "templates repository" @@ -76,9 +74,7 @@ def extract_coldkey_balance(text: str, wallet_name: str, coldkey_address: str) - """ pattern = ( rf"{wallet_name}\s+{coldkey_address}\s+" - r"τ([\d,]+\.\d+)\s+" # Free Balance - r"τ([\d,]+\.\d+)\s+" # Staked Balance - r"τ([\d,]+\.\d+)" # Total Balance + r"τ\s*([\d,]+\.\d+)" # Free Balance ) match = re.search(pattern, text) @@ -86,15 +82,11 @@ def extract_coldkey_balance(text: str, wallet_name: str, coldkey_address: str) - if not match: return { "free_balance": 0.0, - "staked_balance": 0.0, - "total_balance": 0.0, } # Return the balances as a dictionary return { "free_balance": float(match.group(1).replace(",", "")), - "staked_balance": float(match.group(2).replace(",", "")), - "total_balance": float(match.group(3).replace(",", "")), } @@ -111,24 +103,11 @@ def verify_subnet_entry(output_text: str, netuid: str, ss58_address: str) -> boo bool: True if the entry is found, False otherwise. """ - pattern = ( - rf"\b{re.escape(str(netuid))}\s*\│\s*" # NETUID - r"\d+\s*\│\s*" # N (any number) - r"\d+(?:\.\d+)?\s*[KMB]*\s*\│\s*" # MAX_N (number with optional decimal and K/M/B suffix) - r"\d+\.\d+%\s*\│\s*" # EMISSION (percentage) - r"\d+\s*\│\s*" # TEMPO (any number) - r"τ\d+\.\d+\s*\│\s*" # RECYCLE (τ followed by a number) - r"\d+(?:\.\d+)?\s*[KMB]*\s*\│\s*" # POW (number with optional decimal and K/M/B suffix) - rf"{re.escape(ss58_address)}\b" # SUDO (exact SS58 address) - ) - - # Normalize spaces in the output text - normalized_output = re.sub(r"\s+", " ", output_text) - - # Search for the pattern - match = re.search(pattern, normalized_output) - - return bool(match) + pattern = rf"^\s*{re.escape(str(netuid))}\s*[│┃]" + for line in output_text.splitlines(): + if re.search(pattern, line): + return True + return False def validate_wallet_overview( @@ -150,20 +129,20 @@ def validate_wallet_overview( pattern = rf"{coldkey}\s+" # COLDKEY pattern += rf"{hotkey}\s+" # HOTKEY pattern += rf"{uid}\s+" # UID - pattern += r"True\s+" # ACTIVE - Always True immediately after we register - pattern += r"[\d.]+\s+" # STAKE(τ) + pattern += r"True\s+" # ACTIVE + pattern += r"[\d.]+\s+" # STAKE pattern += r"[\d.]+\s+" # RANK pattern += r"[\d.]+\s+" # TRUST pattern += r"[\d.]+\s+" # CONSENSUS pattern += r"[\d.]+\s+" # INCENTIVE pattern += r"[\d.]+\s+" # DIVIDENDS - pattern += r"\d+\s+" # EMISSION(ρ) + pattern += r"[\d.]+\s+" # EMISSION pattern += r"[\d.]+\s+" # VTRUST - pattern += r"(?:True|False)?\s*" # VPERMIT (optional) - pattern += r"[\d]+\s+" # UPDATED (any number) + pattern += r"\*?\s*" # VPERMIT (optional *) + pattern += r"[\d]+\s+" # UPDATED pattern += ( r"(?!none)\w+\s+" if axon_active else r"none\s+" - ) # AXON - True if axon is active + ) # AXON pattern += rf"{hotkey_ss58[:10]}\s*" # HOTKEY_SS58 # Search for the pattern in the wallet information