From a0e02ecc9ba159698352bc85669a6173226e60d9 Mon Sep 17 00:00:00 2001 From: Benjamin Himes <37844818+thewhaleking@users.noreply.github.com> Date: Tue, 13 Aug 2024 23:05:49 +0200 Subject: [PATCH] stake commands (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `root list` working * Fixed help output * Fixed help output * Fixed the way config for network/chain is handled. * [WIP] Check-in. Pausing on setting weights until I determine whether we're doing the metagraph approach still. * Root get-weights * Circular import temp fix. * Added root boost command. Not finished as I need to confirm the use of metagraph on it. * Ruff * Root boost and root set-weights. * Code cleanup. * Code cleanup. * Feedback from PR #1 * Feedback from PR #1. Docstrings mostly. * Final PR #1 suggestions I will be implementing. * In the arena, trying things. * Boost and Slash created with new logic to parse old weights. Confirming this logic is correct in Discord. * Senate vote added * Added `root senate` command. * Root register command. * Root proposals * Root `set-take` * Root `set-delegate` * Root `undelegate-stake` * Docstring. * [WIP] Check-in * [WIP] Check-in * Root `my-delegates` * Root `list-delegates` * Root `nominate`, made extrinsics more general for signing and such. * Ruff and Mypy * Ruff and Mypy * [WIP] Initial Commit for stake commands. * `stake show` command working. * Root commands (#3) * `root list` working * Fixed help output * Fixed help output * Fixed the way config for network/chain is handled. * [WIP] Check-in. Pausing on setting weights until I determine whether we're doing the metagraph approach still. * Root get-weights * Circular import temp fix. * Added root boost command. Not finished as I need to confirm the use of metagraph on it. * Ruff * Root boost and root set-weights. * Code cleanup. * Code cleanup. * Feedback from PR #1 * Feedback from PR #1. Docstrings mostly. * Final PR #1 suggestions I will be implementing. * In the arena, trying things. * Boost and Slash created with new logic to parse old weights. Confirming this logic is correct in Discord. * Senate vote added * Added `root senate` command. * Root register command. * Root proposals * Root `set-take` * Root `set-delegate` * Root `undelegate-stake` * Docstring. * [WIP] Check-in * [WIP] Check-in * Root `my-delegates` * Root `list-delegates` * Root `nominate`, made extrinsics more general for signing and such. * Ruff and Mypy * Ruff and Mypy * Corrected type registry change. * `stake show` command working. * `stake get-children` command * `stake set-children` command * Added commands to stake_app * Exception handling and printing. * Close subtensor. * Added TODOs * Fixed the list options in CLI. Began adding stake command. * Update requirements.txt to use SSH instead of HTTPS for easier cloning * Fixed interactive prompting and mypy issues. * Fixed interactive prompting and mypy issues. * Removed _take_extrinsic logic to the subtensor.sign_and_send_extrinsic method. Added the actual logic for doing the `root set-weights` command to its respective CLI command. * Mypy * `stake add` command * Fixed vecu8 * `stake show` command * PR Feedback — added docstrings. --- cli.py | 515 ++++++- requirements.txt | 2 +- src/bittensor/async_substrate_interface.py | 6 +- src/bittensor/balances.py | 3 + src/commands/root.py | 99 +- src/commands/stake.py | 1599 ++++++++++++++++++++ src/subtensor_interface.py | 42 +- src/utils.py | 37 +- 8 files changed, 2189 insertions(+), 114 deletions(-) create mode 100644 src/commands/stake.py diff --git a/cli.py b/cli.py index 8c01e9b9..23f5d899 100755 --- a/cli.py +++ b/cli.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 import asyncio import os.path -from typing import Optional, Coroutine +import re +from typing import Optional, Coroutine, Collection from bittensor_wallet import Wallet from git import Repo import rich -from rich.prompt import Confirm, Prompt +from rich.prompt import Confirm, Prompt, FloatPrompt from rich.table import Table, Column import typer from typing_extensions import Annotated @@ -14,7 +15,7 @@ from yaml import safe_load, safe_dump from src import defaults, utils -from src.commands import wallets, root +from src.commands import wallets, root, stake from src.subtensor_interface import SubtensorInterface from src.bittensor.async_substrate_interface import SubstrateRequestException from src.utils import console, err_console @@ -80,7 +81,9 @@ class Options: chain = typer.Option( None, help="The subtensor chain endpoint to connect to.", show_default=False ) - netuids = typer.Option([], help="Set the netuid(s) to filter by (e.g. `0 1 2`)") + netuids = typer.Option( + [], "--netuids", "-n", help="Set the netuid(s) to filter by (e.g. `0 1 2`)" + ) netuid = typer.Option( None, help="The netuid (network unique identifier) of the subnet within the root network, (e.g. 1)", @@ -88,6 +91,23 @@ class Options: ) +def list_prompt(init_var: list, list_type: type, help_text: str) -> list: + """ + Serves a similar purpose to rich.FloatPrompt or rich.Prompt, but for creating a list of those variables for + a given type + :param init_var: starting variable, this will generally be `None` if you intend to get something out of this + prompt, if it is not empty, it will return the same + :param list_type: the type for each item in the list you're creating + :param help_text: the helper text to display to the user in the prompt + + :return: list of the specified type of the user inputs + """ + while not init_var: + prompt = Prompt.ask(help_text) + init_var = [list_type(x) for x in re.split(r"[ ,]+", prompt) if x] + return init_var + + 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 @@ -141,6 +161,7 @@ class CLIManager: :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 not_subtensor: the `SubtensorInterface` object passed to the various commands that require it """ @@ -164,6 +185,7 @@ def __init__(self): self.config_app = typer.Typer() self.wallet_app = typer.Typer() self.root_app = typer.Typer() + self.stake_app = typer.Typer() # config alias self.app.add_typer( @@ -191,6 +213,14 @@ def __init__(self): ) self.app.add_typer(self.root_app, name="d", hidden=True) + # stake aliases + self.app.add_typer( + self.stake_app, + name="stake", + short_help="Stake commands, alias: `st`", + ) + self.app.add_typer(self.stake_app, name="st", hidden=True) + # config commands self.config_app.command("set")(self.set_config) self.config_app.command("get")(self.get_config) @@ -229,6 +259,13 @@ def __init__(self): self.root_app.command("list-delegates")(self.root_list_delegates) self.root_app.command("nominate")(self.root_nominate) + # stake commands + self.stake_app.command("show")(self.stake_show) + self.stake_app.command("add")(self.stake_add) + self.stake_app.command("remove")(self.stake_remove) + self.stake_app.command("get-children")(self.stake_get_children) + self.stake_app.command("set-children")(self.stake_set_children) + def initialize_chain( self, network: Optional[str] = typer.Option("default_network", help="Network name"), @@ -438,19 +475,23 @@ def wallet_overview( None, help="Sort the hotkeys in the specified ordering. (ascending/asc or descending/desc/reverse)", ), - include_hotkeys: Optional[list[str]] = typer.Option( + include_hotkeys: list[str] = typer.Option( [], + "--include-hotkeys", + "-in", help="Specify the hotkeys to include by name or ss58 address. (e.g. `hk1 hk2 hk3`). " "If left empty, all hotkeys not excluded will be included.", ), - exclude_hotkeys: Optional[list[str]] = typer.Option( + exclude_hotkeys: list[str] = typer.Option( [], + "--exclude-hotkeys", + "-ex", help="Specify the hotkeys to exclude by name or ss58 address. (e.g. `hk1 hk2 hk3`). " "If left empty, and no hotkeys included in --include-hotkeys, all hotkeys will be included.", ), - netuids: Optional[list[int]] = Options.netuids, - network: Optional[str] = Options.network, - chain: Optional[str] = Options.chain, + netuids: list[int] = Options.netuids, + network: str = Options.network, + chain: str = Options.chain, ): """ # wallet overview @@ -510,7 +551,7 @@ def wallet_overview( ``` - ``` - btcli wallet overview --include-hotkeys hk1 hk2 --sort-by stake + btcli wallet overview -in hk1 -in hk2 --sort-by stake ``` #### Note: @@ -560,11 +601,11 @@ def wallet_transfer( prompt=True, help="Amount (in TAO) to transfer.", ), - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - network: Optional[str] = Options.network, - chain: Optional[str] = Options.chain, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + network: str = Options.network, + chain: str = Options.chain, ): """ # wallet transfer @@ -632,12 +673,12 @@ def wallet_inspect( "-a", help="Inspect all wallets within specified path.", ), - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - network: Optional[str] = Options.network, - chain: Optional[str] = Options.chain, - netuids: Optional[list[int]] = Options.netuids, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + network: str = Options.network, + chain: str = Options.chain, + netuids: list[int] = Options.netuids, ): """ # wallet inspect @@ -679,11 +720,11 @@ def wallet_inspect( ``` ``` - btcli wallet inspect --all + btcli wallet inspect --all -n 1 -n 2 -n 3 ``` #### Note: - The ``inspect`` command is for displaying information only and does not perform any + The `inspect` command is for displaying information only and does not perform any transactions or state changes on the Bittensor network. It is intended to be used as part of the Bittensor CLI and not as a standalone function within user code. """ @@ -782,11 +823,10 @@ def wallet_faucet( CUDA-based GPU calculations. It is currently disabled on testnet and finney. You must use this on a local chain. """ wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) - self.initialize_chain(network, chain) return self._run_command( wallets.faucet( wallet, - self.not_subtensor, + self.initialize_chain(network, chain), threads_per_block, update_interval, processors, @@ -1389,12 +1429,14 @@ def root_list( def root_set_weights( self, - network: Optional[str] = Options.network, - chain: Optional[str] = Options.chain, - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hk_req, - netuids: list[int] = typer.Option(None, help="Netuids, e.g. `0 1 2` ..."), + network: str = Options.network, + chain: str = Options.chain, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hk_req, + netuids: list[int] = typer.Option( + None, help="Netuids, e.g. `-n 0 -n 1 -n 2` ..." + ), weights: list[float] = typer.Argument( None, help="Weights: e.g. `0.02 0.03 0.01` ...", @@ -1412,7 +1454,7 @@ def root_set_weights( ### Example usage:: ``` - btcli root set-weights 0.3 0.3 0.4 --netuids 1 2 3 --chain ws://127.0.0.1:9945 + btcli root set-weights 0.3 0.3 0.4 -n 1 -n 2 -n 3 --chain ws://127.0.0.1:9945 ``` #### Note: @@ -1420,6 +1462,13 @@ def root_set_weights( network's dynamics. It is a powerful tool that directly impacts the network's operational mechanics and reward distribution. """ + netuids = list_prompt(netuids, int, "Enter netuids") + wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + self._run_command( + root.set_weights( + wallet, self.initialize_chain(network, chain), netuids, weights + ) + ) def root_get_weights( self, @@ -1473,8 +1522,9 @@ def root_get_weights( network. It offers transparency into how network rewards and responsibilities are allocated across different subnets. """ - self.initialize_chain(network, chain) - return self._run_command(root.get_weights(self.not_subtensor)) + return self._run_command( + root.get_weights(self.initialize_chain(network, chain)) + ) def root_boost( self, @@ -1549,9 +1599,10 @@ def root_boost( ``` """ wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) - self.initialize_chain(network, chain) return self._run_command( - root.set_boost(wallet, self.not_subtensor, netuid, amount) + root.set_boost( + wallet, self.initialize_chain(network, chain), netuid, amount + ) ) def root_slash( @@ -1664,8 +1715,9 @@ def root_senate_vote( role in the governance and evolution of the Bittensor network. """ wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) - self.initialize_chain(network, chain) - return self._run_command(root.senate_vote(wallet, self.not_subtensor, proposal)) + return self._run_command( + root.senate_vote(wallet, self.initialize_chain(network, chain), proposal) + ) def root_senate( self, @@ -1848,7 +1900,7 @@ def root_delegate_stake( ## Usage: The user must specify the delegate's SS58 address and the amount of Tao to stake. The function sends a transaction to the subtensor network to delegate the specified amount to the chosen delegate. These values are - prompted if not provided. + prompted if not provided. You can list all delegates with `btcli root list-delegates`. ### Example usage: @@ -1864,7 +1916,6 @@ def root_delegate_stake( interaction, and is designed to be used within the Bittensor CLI environment. The user should ensure the delegate's address and the amount to be staked are correct before executing the command. """ - # TODO instruct users how to show all delegates (I think list-delegates, but have to be sure) if amount and stake_all: err_console.print( "`--amount` and `--all` specified. Choose one or the other." @@ -2080,8 +2131,8 @@ def root_list_delegates(self): This function is part of the Bittensor CLI tools and is intended for use within a console application. It prints directly to the console and does not return any value. """ - self.initialize_chain("archive", "wss://archive.chain.opentensor.ai:443") - return self._run_command(root.list_delegates(self.not_subtensor)) + sub = self.initialize_chain("archive", "wss://archive.chain.opentensor.ai:443") + return self._run_command(root.list_delegates(sub)) def root_nominate( self, @@ -2129,6 +2180,386 @@ def root_nominate( root.nominate(wallet, self.initialize_chain(network, chain)) ) + def stake_show( + self, + all_wallets: bool = typer.Option( + False, + "--all", + "--all-wallets", + "-a", + help="When set, the command checks all coldkey wallets instead of just the specified wallet.", + ), + network: Optional[str] = Options.network, + chain: Optional[str] = Options.chain, + wallet_name: Optional[str] = Options.wallet_name, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + wallet_path: Optional[str] = Options.wallet_path, + ): + """ + # stake show + Executes the `show` command to list all stake accounts associated with a user's wallet on the Bittensor network. + + This command provides a comprehensive view of the stakes associated with both hotkeys and delegates linked to + the user's coldkey. + + ## Usage: + The command lists all stake accounts for a specified wallet or all wallets in the user's configuration + directory. It displays the coldkey, balance, account details (hotkey/delegate name), stake amount, and the rate + of return. + + The command compiles a table showing: + + - Coldkey: The coldkey associated with the wallet. + + - Balance: The balance of the coldkey. + + - Account: The name of the hotkey or delegate. + + - Stake: The amount of TAO staked to the hotkey or delegate. + + - Rate: The rate of return on the stake, typically shown in TAO per day. + + + ### Example usage: + + ``` + btcli stake show --all + ``` + + #### Note: + This command is essential for users who wish to monitor their stake distribution and returns across various + accounts on the Bittensor network. It provides a clear and detailed overview of the user's staking activities. + """ + wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + return self._run_command( + stake.show(wallet, self.initialize_chain(network, chain), all_wallets) + ) + + def stake_add( + self, + stake_all: bool = typer.Option( + False, + "--all-tokens", + "--all", + "-a", + help="When set, stakes all available tokens from the coldkey.", + ), + uid: int = typer.Option( + None, + "--uid", + "-u", + help="The unique identifier of the neuron to which the stake is to be added.", + ), + amount: float = typer.Option( + 0.0, "--amount", help="The amount of TAO tokens to stake" + ), + max_stake: float = typer.Option( + 0.0, + "--max-stake", + "-m", + help="Sets the maximum amount of TAO to have staked in each hotkey.", + ), + include_hotkeys: list[str] = typer.Option( + [], + "--include-hotkeys", + "-in", + help="Specifies hotkeys by name or SS58 address to stake to. i.e `-in hk1 -in hk2`", + ), + exclude_hotkeys: list[str] = typer.Option( + [], + "--exclude-hotkeys", + "-ex", + help="Specifies hotkeys by name/SS58 address not to stake to (only use with `--all-hotkeys`.)" + " i.e. `-ex hk3 -ex hk4`", + ), + all_hotkeys: bool = typer.Option( + False, + help="When set, 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: str = Options.network, + chain: str = Options.chain, + ): + """ + # stake add + Executes the `stake_add` command to stake tokens to one or more hotkeys from a user's coldkey on the Bittensor + network. + + This command is used to allocate tokens to different hotkeys, securing their position and influence on the + network. + + ## Usage: + Users can specify the amount to stake, the hotkeys to stake to (either by name or ``SS58`` address), and whether + to stake to all hotkeys. The command checks for sufficient balance and hotkey registration before proceeding + with the staking process. + + + The command prompts for confirmation before executing the staking operation. + + ### Example usage: + + ``` + btcli stake add --amount 100 --wallet-name --wallet-hotkey + ``` + + #### Note: + This command is critical for users who wish to distribute their stakes among different neurons (hotkeys) on the + network. It allows for a strategic allocation of tokens to enhance network participation and influence. + """ + if stake_all and amount: + err_console.print( + "Cannot specify an amount and 'stake-all'. Choose one or the other." + ) + raise typer.Exit() + if not stake_all and not amount: + amount = FloatPrompt.ask("Please enter an amount to stake.") + if stake_all and not amount: + if not Confirm.ask("Stake all available TAO tokens?", default=False): + raise typer.Exit() + if all_hotkeys and include_hotkeys: + err_console.print( + "You have specified hotkeys to include and the `--all-hotkeys` flag. The flag" + "should only be used standalone (to use all hotkeys) or with `--exclude-hotkeys`." + ) + raise typer.Exit() + if include_hotkeys and exclude_hotkeys: + err_console.print( + "You have specified including and excluding hotkeys. Select one or the other." + ) + raise typer.Exit() + wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + return self._run_command( + stake.stake_add( + wallet, + self.initialize_chain(network, chain), + uid, + amount, + stake_all, + max_stake, + include_hotkeys, + exclude_hotkeys, + all_hotkeys, + ) + ) + + def stake_remove( + self, + network: Optional[str] = Options.network, + chain: Optional[str] = Options.chain, + 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, unstakes all staked tokens from the specified hotkeys.", + ), + amount: float = typer.Option( + 0.0, "--amount", "-a", help="The amount of TAO tokens to unstake." + ), + hotkey_ss58_address: str = typer.Option( + "", + help="The SS58 address of the hotkey to unstake from.", + ), + max_stake: float = typer.Option( + 0.0, + "--max-stake", + "--max", + help="Sets the maximum amount of TAO to remain staked in each hotkey.", + ), + include_hotkeys: list[str] = typer.Option( + [], + "--include-hotkeys", + "-in", + help="Specifies hotkeys by name or SS58 address to unstake from. i.e `-in hk1 -in hk2`", + ), + exclude_hotkeys: list[str] = typer.Option( + [], + "--exclude-hotkeys", + "-ex", + help="Specifies hotkeys by name/SS58 address not to unstake from (only use with `--all-hotkeys`.)" + " i.e. `-ex hk3 -ex hk4`", + ), + all_hotkeys: bool = typer.Option( + False, + help="When set, unstakes from all hotkeys associated with the wallet. Do not use if specifying " + "hotkeys in `--include-hotkeys`.", + ), + ): + """ + # stake remove + Executes the `remove` command to unstake TAO tokens from one or more hotkeys and transfer them back to the + user's coldkey on the Bittensor network. + + This command is used to withdraw tokens previously staked to different hotkeys. + + ## Usage: + Users can specify the amount to unstake, the hotkeys to unstake from (either by name or `SS58` address), and + whether to unstake from all hotkeys. The command checks for sufficient stake and prompts for confirmation before + proceeding with the unstaking process. + + The command prompts for confirmation before executing the unstaking operation. + + ### Example usage: + + ``` + btcli stake remove --amount 100 -in hk1 -in hk2 + ``` + + #### Note: + This command is important for users who wish to reallocate their stakes or withdraw them from the network. + It allows for flexible management of token stakes across different neurons (hotkeys) on the network. + """ + if unstake_all and amount: + err_console.print( + "Cannot specify an amount and 'unstake-all'. Choose one or the other." + ) + raise typer.Exit() + if not unstake_all and not amount: + amount = FloatPrompt.ask("Please enter an amount to unstake.") + if unstake_all and not amount: + if not Confirm.ask("Unstake all staked TAO tokens?", default=False): + raise typer.Exit() + if all_hotkeys and include_hotkeys: + err_console.print( + "You have specified hotkeys to include and the `--all-hotkeys` flag. The flag" + "should only be used standalone (to use all hotkeys) or with `--exclude-hotkeys`." + ) + raise typer.Exit() + if include_hotkeys and exclude_hotkeys: + err_console.print( + "You have specified including and excluding hotkeys. Select one or the other." + ) + raise typer.Exit() + wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + return self._run_command( + stake.unstake( + wallet, + self.initialize_chain(network, chain), + hotkey_ss58_address, + all_hotkeys, + include_hotkeys, + exclude_hotkeys, + amount, + max_stake, + unstake_all, + ) + ) + + def stake_get_children( + self, + wallet_name: Optional[str] = Options.wallet_name, + wallet_hotkey: Optional[str] = Options.wallet_hk_req, + wallet_path: Optional[str] = Options.wallet_path, + network: Optional[str] = Options.network, + chain: Optional[str] = Options.chain, + netuid: int = Options.netuid, + ): + """ + # stake get-children + Executes the `get_children_info` command to get all child hotkeys on a specified subnet on the Bittensor network. + + This command is used to view delegated authority to different hotkeys on the subnet. + + ## Usage: + Users can specify the subnet and see the children and the proportion that is given to them. + + The command compiles a table showing: + + - ChildHotkey: The hotkey associated with the child. + + - ParentHotKey: The hotkey associated with the parent. + + - Proportion: The proportion that is assigned to them. + + - Expiration: The expiration of the hotkey. + + + ### Example usage: + + ``` + btcli stake get_children --netuid 1 + ``` + + #### Note: + This command is for users who wish to see child hotkeys among different neurons (hotkeys) on the network. + """ + wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + return self._run_command( + stake.get_children(wallet, self.initialize_chain(network, chain), netuid) + ) + + def stake_set_children( + self, + children: list[str] = typer.Option( + [], "--children", "-c", help="Enter children hotkeys (ss58)", prompt=False + ), + wallet_name: str = Options.wallet_name, + wallet_hotkey: str = Options.wallet_hk_req, + wallet_path: str = Options.wallet_path, + network: str = Options.network, + chain: str = Options.chain, + netuid: int = Options.netuid, + proportions: list[float] = typer.Option( + [], + "--proportions", + "-p", + help="Enter proportions for children as (sum less than 1)", + prompt=False, + ), + ): + """ + # stake set-children + Executes the `set_children` command to add children hotkeys on a specified subnet on the Bittensor network. + + This command is used to delegate authority to different hotkeys, securing their position and influence on the + subnet. + + ## Usage: + Users can specify the amount or 'proportion' to delegate to child hotkeys (``SS58`` address), + the user needs to have sufficient authority to make this call, and the sum of proportions cannot be greater + than 1. + + The command prompts for confirmation before executing the set_children operation. + + ### Example usage: + + ``` + btcli stake set_children - -c --hotkey --netuid 1 + -p 0.3 -p 0.3 + ``` + + #### Note: + This command is critical for users who wish to delegate children hotkeys among different neurons (hotkeys) on + the network. It allows for a strategic allocation of authority to enhance network participation and influence. + """ + children = list_prompt(children, str, "Enter the child hotkeys (ss58)") + proportions = list_prompt( + proportions, + float, + "Enter proportions equal to the number of children (sum not exceeding a total of 1.0)", + ) + if len(proportions) != len(children): + err_console.print("You must have as many proportions as you have children.") + raise typer.Exit() + if sum(proportions) > 1.0: + err_console.print("Your proportion total must sum not exceed 1.0.") + raise typer.Exit() + wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + return self._run_command( + stake.set_children( + wallet, + self.initialize_chain(network, chain), + netuid, + children, + proportions, + ) + ) + def run(self): self.app() diff --git a/requirements.txt b/requirements.txt index 149cac3d..05d0853c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ aiohttp~=3.9.5 backoff~=2.2.1 -git+https://github.com/opentensor/btwallet # bittensor_wallet +git+ssh://git@github.com/opentensor/btwallet.git # bittensor_wallet GitPython>=3.0.0 fuzzywuzzy~=0.18.0 netaddr~=1.3.0 diff --git a/src/bittensor/async_substrate_interface.py b/src/bittensor/async_substrate_interface.py index cd5e4fc8..2749f77f 100644 --- a/src/bittensor/async_substrate_interface.py +++ b/src/bittensor/async_substrate_interface.py @@ -533,12 +533,14 @@ async def _preprocess( metadata_pallet = self.substrate.metadata.get_metadata_pallet(module) if not metadata_pallet: - raise Exception(f'Pallet "{module}" not found') + 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 Exception(f'Storage function "{module}.{storage_function}" not found') + raise SubstrateRequestException( + f'Storage function "{module}.{storage_function}" not found' + ) # SCALE type string of value param_types = storage_item.get_params_type_string() diff --git a/src/bittensor/balances.py b/src/bittensor/balances.py index d0c1945c..1e678c9d 100644 --- a/src/bittensor/balances.py +++ b/src/bittensor/balances.py @@ -90,6 +90,9 @@ def __rich_rao__(self): def __repr__(self): return self.__str__() + def __bool__(self): + return self.rao != 0 + def __eq__(self, other: Union[int, float, "Balance"]): if other is None: return False diff --git a/src/commands/root.py b/src/commands/root.py index 70438a6f..fb6c69a9 100644 --- a/src/commands/root.py +++ b/src/commands/root.py @@ -22,7 +22,6 @@ err_console, get_delegates_details_from_github, convert_weight_uids_and_vals_to_tensor, - format_error_message, ss58_to_vec_u8, ) from src import Constants @@ -110,7 +109,7 @@ async def _get_senate_members( async def _get_proposals( subtensor: SubtensorInterface, block_hash: str -) -> dict[ProposalVoteData, tuple[GenericCall, ProposalVoteData]]: +) -> dict[str, tuple[GenericCall, ProposalVoteData]]: async def get_proposal_call_data(p_hash: str) -> Optional[GenericCall]: proposal_data = await subtensor.substrate.query( module="Triumvirate", @@ -132,12 +131,13 @@ async def get_proposal_vote_data(p_hash: str) -> Optional[ProposalVoteData]: params=None, block_hash=block_hash, ) - proposal_hashes: Optional[ProposalVoteData] = getattr( - ph, "serialize", lambda: None - )() - if proposal_hashes is None: - return None + try: + proposal_hashes: list[str] = ph.serialize() + except AttributeError: + err_console.print("Unable to retrieve proposal vote data") + raise typer.Exit() + call_data_, vote_data_ = await asyncio.gather( asyncio.gather(*[get_proposal_call_data(h) for h in proposal_hashes]), asyncio.gather(*[get_proposal_vote_data(h) for h in proposal_hashes]), @@ -250,28 +250,26 @@ async def vote_senate_extrinsic( call, wallet, wait_for_inclusion, wait_for_finalization ) if not success: - err_console.print( - f":cross_mark: [red]Failed[/red]: {format_error_message(err_msg)}" - ) + 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: - vote_data = await _get_vote_data(subtensor, proposal_hash) - has_voted = ( - vote_data["ayes"].count(wallet.hotkey.ss58_address) > 0 - or vote_data["nays"].count(wallet.hotkey.ss58_address) > 0 - ) - - if has_voted: - console.print(":white_heavy_check_mark: [green]Vote cast.[/green]") - return True + if vote_data := await _get_vote_data(subtensor, 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: - # hotkey not found in ayes/nays - err_console.print( - ":cross_mark: [red]Unknown error. Couldn't find vote.[/red]" - ) return False @@ -347,7 +345,7 @@ async def burned_register_extrinsic( "hotkey": wallet.hotkey.ss58_address, }, ) - success, err_msg = subtensor.sign_and_send_extrinsic( + success, err_msg = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion, wait_for_finalization ) @@ -423,25 +421,6 @@ async def _get_delegate_by_hotkey(ss58: str) -> Optional[DelegateInfo]: else: return DelegateInfo.from_vec_u8(result) - async def _take_extrinsic(call_) -> tuple[bool, str]: - """Submits the previously-created extrinsic call to the chain""" - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call_, keypair=wallet.coldkey - ) # sign with coldkey - response = await subtensor.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, "" - response.process_events() - if response.is_success: - return True, "" - else: - return False, format_error_message(response.error_message) - # Calculate u16 representation of the take take_u16 = int(take * 0xFFFF) @@ -471,7 +450,7 @@ async def _take_extrinsic(call_) -> tuple[bool, str]: "take": take, }, ) - success, err = await _take_extrinsic(call) + success, err = await subtensor.sign_and_send_extrinsic(call, wallet) else: console.print("Current take is higher than the new one. Will use decrease_take") @@ -486,7 +465,7 @@ async def _take_extrinsic(call_) -> tuple[bool, str]: "take": take, }, ) - success, err = await _take_extrinsic(call) + success, err = await subtensor.sign_and_send_extrinsic(call, wallet) if not success: err_console.print(err) @@ -498,8 +477,8 @@ async def _take_extrinsic(call_) -> tuple[bool, str]: async def delegate_extrinsic( subtensor: SubtensorInterface, wallet: Wallet, - delegate_ss58: Optional[str] = None, - amount: Balance = None, + delegate_ss58: str, + amount: Balance, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, prompt: bool = False, @@ -602,7 +581,7 @@ async def get_stake_for_coldkey_and_hotkey( # Stake it all. staking_balance = Balance.from_tao(my_prev_coldkey_balance.tao) else: - staking_balance = Balance.from_tao(amount) + staking_balance = amount if delegate: # Remove existential balance to keep key alive. @@ -811,12 +790,12 @@ async def _get_list() -> tuple: async def set_weights( wallet: Wallet, subtensor: SubtensorInterface, - netuids_: list[int], - weights_: list[float], + netuids: list[int], + weights: list[float], ): """Set weights for root network.""" - netuids_ = np.array(netuids_, dtype=np.int64) - weights_ = np.array(weights_, dtype=np.float32) + netuids_ = np.array(netuids, dtype=np.int64) + weights_ = np.array(weights, dtype=np.float32) # Run the set weights operation. with console.status("Setting root weights..."): @@ -842,7 +821,7 @@ async def get_weights(subtensor: SubtensorInterface): await subtensor.substrate.close() - uid_to_weights = {} + uid_to_weights: dict[int, dict] = {} netuids = set() for matrix in weights: [uid, weights_data] = matrix @@ -1038,8 +1017,8 @@ async def get_senate(subtensor: SubtensorInterface): async with subtensor: senate_members = await _get_senate_members(subtensor) - delegate_info: Optional[ - dict[str, DelegatesDetails] + delegate_info: dict[ + str, DelegatesDetails ] = await get_delegates_details_from_github(Constants.delegates_detail_url) await subtensor.substrate.close() @@ -1214,7 +1193,7 @@ async def _do_set_take() -> bool: err_console.print("ERROR: Take value should not exceed 18%") return False - result: bool = set_take_extrinsic( + result: bool = await set_take_extrinsic( subtensor=subtensor, wallet=wallet, delegate_ss58=wallet.hotkey.ss58_address, @@ -1256,7 +1235,7 @@ async def _do_set_take() -> bool: async def delegate_stake( wallet: Wallet, subtensor: SubtensorInterface, - amount: Optional[float], + amount: float, delegate_ss58key: str, ): """Delegates stake to a chain delegate.""" @@ -1377,7 +1356,7 @@ async def wallet_to_delegates( await subtensor.substrate.close() for wall, delegates in wallets_with_delegates: - if not wall: + if not wall or not delegates: continue my_delegates_ = {} # hotkey, amount @@ -1619,7 +1598,9 @@ async def nominate(wallet: Wallet, subtensor: SubtensorInterface): return else: # Check if we are a delegate. - is_delegate: bool = subtensor.is_hotkey_delegate(wallet.hotkey.ss58_address) + 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]" diff --git a/src/commands/stake.py b/src/commands/stake.py new file mode 100644 index 00000000..52aae262 --- /dev/null +++ b/src/commands/stake.py @@ -0,0 +1,1599 @@ +import asyncio +import copy +from typing import TYPE_CHECKING, Union, Optional + +from bittensor_wallet import Wallet +from rich.prompt import Confirm +from rich.table import Table, Column +from substrateinterface.exceptions import SubstrateRequestException + +from src import Constants +from src.bittensor.balances import Balance +from src.utils import ( + get_delegates_details_from_github, + get_hotkey_wallets_for_wallet, + get_coldkey_wallets_for_path, + console, + err_console, + is_valid_ss58_address, + float_to_u64, + u16_normalized_float, +) + +if TYPE_CHECKING: + from src.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.decode()) + 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 _get_hotkey_owner( + subtensor: "SubtensorInterface", hotkey_ss58: str, block_hash: str +) -> Optional[str]: + hk_owner_query = await subtensor.substrate.query( + module="SubtensorModule", + storage_function="Owner", + params=[hotkey_ss58], + block_hash=block_hash, + ) + hotkey_owner = ( + val + if ( + (val := getattr(hk_owner_query, "value", None)) + and await subtensor.does_hotkey_exist(val, block_hash=block_hash) + ) + else None + ) + return hotkey_owner + + +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, + wallet.unlock_coldkey() + + # 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] ..." + ): + block_hash = await subtensor.substrate.get_chain_head() + # Get hotkey owner + hotkey_owner = await _get_hotkey_owner( + subtensor, 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(getattr(hk_result, "value", 0)) + + # Get current stake + old_stake = await subtensor.get_stake_for_coldkey_and_hotkey( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + block_hash=block_hash, + ) + + # 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 = 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 + 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: {wallet.hotkey_str}\n" + f"\ttake: {hotkey_take}\n" + f"\towner: {hotkey_owner}[/bold white]" + ): + 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}[/bold white]" + ): + return False + + with console.status( + f":satellite: Staking to: [bold white]{subtensor}[/bold white] ..." + ): + 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: list[Optional[Balance]] + if amounts is None: + new_amounts = [None] * len(hotkey_ss58s) + else: + new_amounts = amounts + if sum(amount.tao for amount in amounts) == 0: + # Staking 0 tao + return True + + # Decrypt coldkey. + wallet.unlock_coldkey() + + old_stakes = [] + 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 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 = getattr(tx_query, "value", 0) + 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( + "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 keys, + wallet.unlock_coldkey() + + 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] ..." + ): + 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, + ), + _get_hotkey_owner(subtensor, 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 = 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 + + # 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] ..." + ): + 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, 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: list[Optional[Balance]] + if amounts is None: + new_amounts = [None] * len(hotkey_ss58s) + else: + new_amounts = amounts + if sum(amount.tao for amount in new_amounts) == 0: + # Staking 0 tao + return True + + # Unlock coldkey. + wallet.unlock_coldkey() + + 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( + *[_get_hotkey_owner(subtensor, 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"\thotkey: {wallet.hotkey_str}[/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 = getattr(tx_query, "value", 0) + if tx_rate_limit_blocks > 0: + console.print( + ":hourglass: [yellow]Waiting for tx rate limit:" + " [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: + 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 + + +async def set_children_extrinsic( + subtensor: "SubtensorInterface", + wallet: Wallet, + hotkey: str, + netuid: int, + children_with_proportions: list[tuple[float, str]], + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt: bool = False, +) -> tuple[bool, str]: + """ + Sets children hotkeys with proportions assigned from the parent. + + :param: subtensor: Subtensor endpoint to use. + :param: wallet: Bittensor wallet object. + :param: hotkey: Parent hotkey. + :param: children_with_proportions: Children hotkeys. + :param: netuid: Unique identifier of for the subnet. + :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: A tuple containing a success flag and an optional error message. + """ + # Check if all children are being revoked + all_revoked = all(prop == 0.0 for prop, _ in children_with_proportions) + + operation = "Revoke all children hotkeys" if all_revoked else "Set children hotkeys" + + # Ask before moving on. + if prompt: + if all_revoked: + if not Confirm.ask( + f"Do you want to revoke all children hotkeys for hotkey {hotkey}?" + ): + return False, "Operation Cancelled" + else: + if not Confirm.ask( + "Do you want to set children hotkeys:\n[bold white]{}[/bold white]?".format( + "\n".join( + f" {child[1]}: {child[0]}" + for child in children_with_proportions + ) + ) + ): + return False, "Operation Cancelled" + + with console.status( + f":satellite: {operation} on [white]{subtensor.network}[/white] ..." + ): + normalized_children = ( + prepare_child_proportions(children_with_proportions) + if not all_revoked + else children_with_proportions + ) + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="set_children", + call_params={ + "hotkey": hotkey, + "children": normalized_children, + "netuid": netuid, + }, + ) + success, error_message = await subtensor.sign_and_send_extrinsic( + call, wallet, wait_for_inclusion, wait_for_finalization + ) + + if not wait_for_finalization and not wait_for_inclusion: + return ( + True, + f"Not waiting for finalization or inclusion. {operation} initiated.", + ) + + if success: + console.print(":white_heavy_check_mark: [green]Finalized[/green]") + # bittensor.logging.success( + # prefix=operation, + # suffix="Finalized: " + str(success), + # ) + return True, f"Successfully {operation.lower()} and Finalized." + else: + err_console.print(f":cross_mark: [red]Failed[/red]: {error_message}") + # bittensor.logging.warning( + # prefix=operation, + # suffix="Failed: " + str(error_message), + # ) + return False, error_message + + +def prepare_child_proportions(children_with_proportions): + """ + Convert proportions to u64 and normalize + """ + children_u64 = [ + (float_to_u64(prop), child) for prop, child in children_with_proportions + ] + normalized_children = normalize_children_and_proportions(children_u64) + return normalized_children + + +def normalize_children_and_proportions( + children: list[tuple[int, str]], +) -> list[tuple[int, str]]: + """ + Normalizes the proportions of children so that they sum to u64::MAX. + """ + total = sum(prop for prop, _ in children) + u64_max = 2**64 - 1 + return [(int(prop * u64_max / total), child) for prop, child in children] + + +# Commands + + +async def show(wallet: Wallet, subtensor: "SubtensorInterface", all_wallets: bool): + """Show all stake accounts.""" + if all_wallets: + wallets = get_coldkey_wallets_for_path(wallet.path) + else: + wallets = [wallet] + + registered_delegate_info = await get_delegates_details_from_github( + Constants.delegates_detail_url + ) + + 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_, block_hash=block_hash), + ) + + 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 = [getattr(_result, "value", None) 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.value) if getattr( + stake, "value", None + ) else Balance(0) + + hotkeys = 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_, block_hash: str + ) -> 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].name + if dele.hotkey_ss58 in registered_delegate_info + else dele.hotkey_ss58 + ) + stakes[dele.hotkey_ss58] = { + "name": delegate_name, + "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_ + + with console.status(":satellite:Retrieving account data..."): + async with subtensor: + block_hash_ = await subtensor.substrate.get_chain_head() + accounts = await get_all_wallet_accounts(block_hash=block_hash_) + + await subtensor.substrate.close() + + total_stake = 0 + total_balance = 0 + total_rate = 0 + for acc in accounts: + total_balance += acc["balance"].tao + for key, value in acc["accounts"].items(): + total_stake += value["stake"].tao + total_rate += float(value["rate"]) + table = Table( + Column( + "[overline white]Coldkey", footer_style="overline white", style="bold white" + ), + Column( + "[overline white]Balance", + "\u03c4{:.5f}".format(total_balance), + footer_style="overline white", + style="green", + ), + Column("[overline white]Account", footer_style="overline white", style="blue"), + Column( + "[overline white]Stake", + "\u03c4{:.5f}".format(total_stake), + footer_style="overline white", + style="green", + ), + Column( + "[overline white]Rate", + "\u03c4{:.5f}/d".format(total_rate), + footer_style="overline white", + style="green", + ), + show_footer=True, + pad_edge=False, + box=None, + expand=False, + ) + for acc in accounts: + table.add_row(acc["name"], acc["balance"], "", "") + for key, value in acc["accounts"].items(): + table.add_row( + "", "", value["name"], value["stake"], str(value["rate"]) + "/d" + ) + console.print(table) + + +async def stake_add( + wallet: Wallet, + subtensor: SubtensorInterface, + uid: int, + amount: float, + stake_all: bool, + max_stake: float, + include_hotkeys: list[str], + exclude_hotkeys: list[str], + all_hotkeys: bool, +) -> 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 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 + ] # definitely wallets + + elif include_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. + assert wallet.hotkey is not None + hotkey_ss58_or_name = wallet.hotkey.ss58_address + hotkeys_to_stake_to = [(None, hotkey_ss58_or_name)] + + try: + async with subtensor: + # Get 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) + 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 + + # Ask to stake + if not False: # TODO no-prompt + if not Confirm.ask( + f"Do you want to stake to the following keys from {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) + ] + ) + ): + 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=True, + ) + 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=False, + ) + except ValueError: + pass + await subtensor.substrate.close() + + +async def unstake( + wallet: Wallet, + subtensor: "SubtensorInterface", + hotkey_ss58_address: str, + all_hotkeys: bool, + include_hotkeys: list[str], + exclude_hotkeys: list[str], + amount: float, + max_stake: float, + unstake_all: 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: + # Stake to specific hotkey. + hotkeys_to_unstake_from = [(None, hotkey_ss58_address)] + elif all_hotkeys: + # Stake 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 + ] # definitely wallets + + elif include_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_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. + 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) + try: + async with subtensor: + with console.status(f":satellite:Syncing with chain {subtensor}"): + 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 max_stake: + # Get the current stake of the hotkey from this coldkey. + unstake_amount_tao = hotkey_stake.tao - max_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 not False: # TODO no 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 + return subtensor.unstake( + wallet=wallet, + hotkey_ss58=final_hotkeys[0][1], + amount=None if unstake_all else final_amounts[0], + wait_for_inclusion=True, + prompt=True, + ) + + subtensor.unstake_multiple( + 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=False, + ) + except ValueError: + pass + await subtensor.substrate.close() + + +async def get_children(wallet: Wallet, subtensor: "SubtensorInterface", netuid: int): + async def _get_children(hotkey): + """ + Get the children of a hotkey on a specific network. + + :param hotkey: The hotkey to query. + + :return: List of (proportion, child_address) tuples, or None if an error occurred. + """ + try: + children = await subtensor.substrate.query( + module="SubtensorModule", + storage_function="ChildKeys", + params=[hotkey, netuid], + ) + if children: + formatted_children = [] + for proportion, child in children: + # Convert U64 to int + int_proportion = ( + proportion.value + if hasattr(proportion, "value") + else int(proportion) + ) + formatted_children.append((int_proportion, child.value)) + return formatted_children + else: + console.print("[yellow]No children found.[/yellow]") + return [] + except SubstrateRequestException as e: + err_console.print(f"Error querying ChildKeys: {e}") + return None + + async def get_total_stake_for_child_hk(child: tuple): + child_hotkey = child[1] + _result = await subtensor.substrate.query( + module="SubtensorModule", + storage_function="TotalHotkeyStake", + params=[child_hotkey], + reuse_block_hash=True, + ) + return ( + Balance.from_rao(_result.value) + if getattr(_result, "value", None) + else Balance(0) + ) + + async def render_table( + hk: str, + children: list[tuple[int, str]], + nuid: int, + ): + # Initialize Rich table for pretty printing + table = Table( + Column("Index", style="cyan", no_wrap=True, justify="right"), + Column("ChildHotkey", style="cyan", no_wrap=True), + Column("Proportion", style="cyan", no_wrap=True, justify="right"), + Column("Total Stake", style="cyan", no_wrap=True, justify="right"), + show_header=True, + header_style="bold magenta", + border_style="green", + style="green", + ) + + if not children: + console.print(table) + + command = ( + "btcli stake set-children --children --hotkey " + f"--netuid {nuid} --proportion " + ) + console.print(f"There are currently no child hotkeys on subnet {nuid}.") + console.print( + f"To add a child hotkey you can run the command: [white]{command}[/white]" + ) + return + + console.print("ParentHotKey:", style="cyan", no_wrap=True) + console.print(hk) + + # calculate totals + total_proportion = 0 + total_stake = 0 + + children_info = [] + child_stakes = await asyncio.gather( + *[get_total_stake_for_child_hk(c) for c in children] + ) + for child, child_stake in zip(children, child_stakes): + proportion = child[0] + child_hotkey = child[1] + + # add to totals + total_proportion += proportion + total_stake += child_stake + + children_info.append((proportion, child_hotkey, child_stake)) + + children_info.sort( + key=lambda x: x[0], reverse=True + ) # sorting by proportion (highest first) + + # add the children info to the table + for i, (proportion, hk, stake) in enumerate(children_info, 1): + table.add_row( + str(i), + hk, + str(proportion), + str(stake), + ) + + # add totals row + table.add_row("", "Total", str(total_proportion), str(total_stake), "") + console.print(table) + + async with subtensor: + children_ = await _get_children(wallet.hotkey) + + await render_table(wallet.hotkey, children_, netuid) + + await subtensor.substrate.close() + + return children_ + + +async def set_children( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + children: list[str], + proportions: list[float], +): + """Set children hotkeys.""" + # Validate children SS58 addresses + for child in children: + if not is_valid_ss58_address(child): + err_console.print(f":cross_mark:[red] Invalid SS58 address: {child}[/red]") + return + + total_proposed = sum(proportions) + if total_proposed > 1: + raise ValueError( + 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)) + + async with subtensor: + success, message = await set_children_extrinsic( + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + hotkey=wallet.hotkey.ss58_address, + children_with_proportions=children_with_proportions, + prompt=True, + ) + await subtensor.substrate.close() + # Result + if success: + console.print(":white_heavy_check_mark: [green]Set children hotkeys.[/green]") + else: + console.print( + f":cross_mark:[red] Unable to set children hotkeys.[/red] {message}" + ) diff --git a/src/subtensor_interface.py b/src/subtensor_interface.py index c765d6ec..059e8763 100644 --- a/src/subtensor_interface.py +++ b/src/subtensor_interface.py @@ -130,7 +130,15 @@ async def is_hotkey_delegate( 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. + """ json_body = await self.substrate.rpc_request( method="delegateInfo_getDelegates", # custom rpc method params=[block_hash] if block_hash else [], @@ -181,6 +189,24 @@ async def get_stake_info_for_coldkey( # TODO: review if this is the correct type / works return StakeInfo.list_from_vec_u8(bytes_result) # type: ignore + async def get_stake_for_coldkey_and_hotkey( + self, hotkey_ss58: str, coldkey_ss58: str, block_hash: Optional[str] + ) -> 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 + """ + _result = await self.substrate.query( + module="SubtensorModule", + storage_function="Stake", + params=[hotkey_ss58, coldkey_ss58], + block_hash=block_hash, + ) + return Balance.from_rao(getattr(_result, "value", 0)) + async def query_runtime_api( self, runtime_api: str, @@ -241,7 +267,7 @@ async def query_runtime_api( async def get_balance( self, *addresses: str, - block_hash: Optional[int] = None, + block_hash: Optional[str] = None, reuse_block: bool = False, ) -> dict[str, Balance]: """ @@ -421,7 +447,7 @@ async def filter_netuids_by_registered_hotkeys( async def get_existential_deposit( self, block_hash: Optional[str] = None, reuse_block: bool = False - ) -> Optional[Balance]: + ) -> Balance: """ Retrieves the existential deposit amount for the Bittensor blockchain. The existential deposit is the minimum amount of TAO required for an account to exist on the blockchain. Accounts with @@ -727,6 +753,16 @@ async def sign_and_send_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, ) -> tuple[bool, str]: + """ + Helper method to sign and submit an extrinsic call to chain. + + :param call: a prepared Call object + :param wallet: the wallet whose coldkey will be used to sign the extrinsic + :param wait_for_inclusion: whether to wait until the extrinsic call is included on the chain + :param wait_for_finalization: whether to wait until the extrinsic call is finalized on the chain + + :return: (success, error message) + """ extrinsic = await self.substrate.create_signed_extrinsic( call=call, keypair=wallet.coldkey ) # sign with coldkey diff --git a/src/utils.py b/src/utils.py index efae64a7..b0837408 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,7 +1,7 @@ import os import math from pathlib import Path -from typing import Union, Any +from typing import Union, Any, Collection, Optional import aiohttp import scalecodec @@ -26,11 +26,22 @@ def u16_normalized_float(x: int) -> float: + """Converts a u16 int to a float""" return float(x) / float(U16_MAX) +def float_to_u64(value: float) -> int: + """Converts a float to a u64 int""" + # Ensure the input is within the expected range + if not (0 <= value < 1): + raise ValueError("Input value must be between 0 and 1") + + # Convert the float to a u64 value + return int(value * (2**64 - 1)) + + def convert_weight_uids_and_vals_to_tensor( - n: int, uids: list[int], weights: list[int] + n: int, uids: Collection[int], weights: Collection[int] ) -> NDArray[np.float32]: """ Converts weights and uids from chain representation into a `np.array` (inverse operation from @@ -107,7 +118,16 @@ def convert_root_weight_uids_and_vals_to_tensor( def get_hotkey_wallets_for_wallet( wallet: Wallet, show_nulls: bool = False -) -> list[Wallet]: +) -> 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 + + :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`) + """ hotkey_wallets = [] wallet_path = Path(wallet.path).expanduser() hotkeys_path = wallet_path / wallet.name / "hotkeys" @@ -137,6 +157,7 @@ def get_hotkey_wallets_for_wallet( def get_coldkey_wallets_for_path(path: str) -> list[Wallet]: + """Gets all wallets with coldkeys from a given path""" wallet_path = Path(path).expanduser() wallets = [ Wallet(name=directory.name, path=path) @@ -147,6 +168,7 @@ def get_coldkey_wallets_for_path(path: str) -> list[Wallet]: def get_all_wallets_for_path(path: str) -> list[Wallet]: + """Gets all wallets from a given path.""" all_wallets = [] cold_wallets = get_coldkey_wallets_for_path(path) for cold_wallet in cold_wallets: @@ -250,6 +272,7 @@ def is_valid_bittensor_address_or_public_key(address: Union[str, bytes]) -> bool 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) @@ -298,7 +321,7 @@ def get_explorer_root_url_by_network_from_map( def get_explorer_url_for_network( - network: str, block_hash: str, network_map: dict[str, str] + network: str, block_hash: str, network_map: dict[str, dict[str, str]] ) -> dict[str, str]: """ Returns the explorer url for the given block hash and network. @@ -415,13 +438,13 @@ def millify(n: int): :return: The formatted string representing the number with a suffix. """ mill_names = ["", " K", " M", " B", " T"] - n = float(n) + n_ = float(n) mill_idx = max( 0, min( len(mill_names) - 1, - int(math.floor(0 if n == 0 else math.log10(abs(n)) / 3)), + int(math.floor(0 if n_ == 0 else math.log10(abs(n_)) / 3)), ), ) - return "{:.2f}{}".format(n / 10 ** (3 * mill_idx), mill_names[mill_idx]) + return "{:.2f}{}".format(n_ / 10 ** (3 * mill_idx), mill_names[mill_idx])