From b6bcacbb55f1f20a749a289f44e65021ff558e09 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Tue, 4 Feb 2025 21:25:05 -0800 Subject: [PATCH 01/11] Improves stake add --- bittensor_cli/cli.py | 10 +- bittensor_cli/src/commands/stake/stake.py | 245 +++++++++++++--------- 2 files changed, 149 insertions(+), 106 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 074ad8743..9dc0188e1 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -2601,12 +2601,6 @@ def stake_add( 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.", - ), include_hotkeys: str = typer.Option( "", "--include-hotkeys", @@ -2757,7 +2751,7 @@ def stake_add( excluded_hotkeys = [] # TODO: Ask amount for each subnet explicitly if more than one - if not stake_all and not amount and not max_stake: + if not stake_all and not amount: free_balance, staked_balance = self._run_command( wallets.wallet_balance( wallet, self.initialize_chain(network), False, None @@ -2792,9 +2786,7 @@ def stake_add( netuid, stake_all, amount, - False, prompt, - max_stake, all_hotkeys, included_hotkeys, excluded_hotkeys, diff --git a/bittensor_cli/src/commands/stake/stake.py b/bittensor_cli/src/commands/stake/stake.py index 2ea5dd8e5..29684c1d2 100644 --- a/bittensor_cli/src/commands/stake/stake.py +++ b/bittensor_cli/src/commands/stake/stake.py @@ -35,15 +35,149 @@ from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +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_add_table(wallet: Wallet, subtensor: "SubtensorInterface") -> 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"] + ) + + return table + + +def _print_table_and_slippage(table: Table, max_slippage: float): + """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 + console.print( + """ +[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). +""" + ) + + async def stake_add( wallet: Wallet, subtensor: "SubtensorInterface", netuid: Optional[int], stake_all: bool, amount: float, - delegate: bool, prompt: bool, - max_stake: float, all_hotkeys: bool, include_hotkeys: list[str], exclude_hotkeys: list[str], @@ -71,17 +205,11 @@ async def stake_add( if netuid is not None else await subtensor.get_all_subnet_netuids() ) - # Init the table. - table = Table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Staking to: \nWallet: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.name}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}], Coldkey ss58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\nNetwork: {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, + hotkeys_to_stake_to = _get_hotkeys_to_stake_to( + wallet=wallet, + all_hotkeys=all_hotkeys, + include_hotkeys=include_hotkeys, + exclude_hotkeys=exclude_hotkeys, ) # Determine the amount we are staking. @@ -95,46 +223,6 @@ async def stake_add( remaining_wallet_balance = current_wallet_balance max_slippage = 0.0 - 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: - 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)] - starting_chain_head = await subtensor.substrate.get_chain_head() _all_dynamic_info, stake_info_dict = await asyncio.gather( subtensor.all_subnets(), @@ -145,7 +233,7 @@ async def stake_add( ) all_dynamic_info = {di.netuid: di for di in _all_dynamic_info} initial_stake_balances = {} - for hotkey_ss58 in [x[1] for x in hotkeys_to_stake_to]: + for _, hotkey_ss58 in hotkeys_to_stake_to: initial_stake_balances[hotkey_ss58] = {} for netuid in netuids: initial_stake_balances[hotkey_ss58][netuid] = Balance.from_rao(0) @@ -177,7 +265,7 @@ async def stake_add( amount_to_stake_as_balance = Balance.from_tao(amount) elif stake_all: amount_to_stake_as_balance = current_wallet_balance / len(netuids) - elif not amount and not max_stake: + elif not amount: if Confirm.ask(f"Stake all: [bold]{remaining_wallet_balance}[/bold]?"): amount_to_stake_as_balance = remaining_wallet_balance else: @@ -226,50 +314,13 @@ async def stake_add( str(slippage_pct), ) ) - 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"] - ) + + # Define and print stake table + slippage warning + table = _define_stake_add_table(wallet, subtensor) for row in rows: table.add_row(*row) - 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} %[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}], this may result in a loss of funds.\n" - message += "-------------------------------------------------------------------------------------------------------------------\n" - console.print(message) - console.print( - """ -[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). -""" - ) + _print_table_and_slippage(table, max_slippage) + if prompt: if not Confirm.ask("Would you like to continue?"): raise typer.Exit() From cf7e7b3098e2136b78f07bbb6ba2fa3e0b14eb4f Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Tue, 4 Feb 2025 23:09:32 -0800 Subject: [PATCH 02/11] Overhauled stake add --- bittensor_cli/cli.py | 9 +- bittensor_cli/src/commands/stake/add.py | 410 ++++++++++++++++++++++ bittensor_cli/src/commands/stake/move.py | 2 +- bittensor_cli/src/commands/stake/stake.py | 387 +------------------- 4 files changed, 419 insertions(+), 389 deletions(-) create mode 100644 bittensor_cli/src/commands/stake/add.py diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 9dc0188e1..1c0bc65c1 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -32,7 +32,12 @@ from bittensor_cli.src.commands import sudo, wallets from bittensor_cli.src.commands import weights as weights_cmds from bittensor_cli.src.commands.subnets import price, subnets -from bittensor_cli.src.commands.stake import children_hotkeys, stake, move +from bittensor_cli.src.commands.stake import ( + children_hotkeys, + stake, + move, + add as stake_add, +) 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 ( @@ -2780,7 +2785,7 @@ def stake_add( raise typer.Exit() return self._run_command( - stake.stake_add( + stake_add.stake_add( wallet, self.initialize_chain(network), netuid, diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py new file mode 100644 index 000000000..b22e4f8d4 --- /dev/null +++ b/bittensor_cli/src/commands/stake/add.py @@ -0,0 +1,410 @@ +import asyncio +from functools import partial + +import typer +from typing import TYPE_CHECKING, Optional +from rich.table import Table +from rich.prompt import Confirm + +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, + prompt_stake_amount, +) +from bittensor_wallet import Wallet +from bittensor_wallet.errors import KeyFileError + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +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_stable(wallet: Wallet, subtensor: "SubtensorInterface") -> 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"] + ) + + return table + + +def print_table_and_slippage(table: Table, max_slippage: float): + """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 + console.print( + """ +[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). +""" + ) + + +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 = str(1 / (float(subnet_info.price) or 1)) + else: + slippage_pct_float = 0 + slippage_str = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]N/A[/{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]" + rate = str(1) + + return received_amount, slippage_str, slippage_pct_float, rate + + +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], +): + """ + + 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`) + + Returns: + bool: True if stake operation is successful, False otherwise + """ + + async def send_stake_extrinsic( + netuid_i, amount_, current, staking_address_ss58, status=None + ): + err_out = partial(print_error, status=status) + 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, subtensor.substrate)}" + ) + 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, stake_info_dict = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_stake_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address, + ), + ) + new_stake = Balance.from_rao(0) + for stake_info in stake_info_dict: + if ( + stake_info.hotkey_ss58 == staking_address_ss58 + and stake_info.netuid == netuid_i + ): + new_stake = stake_info.stake.set_unit(netuid_i) + break + 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_wallet_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 = [] + 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. + dynamic_info = all_subnets.get(netuid) + if not dynamic_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(dynamic_info, amount_to_stake) + ) + max_slippage = max(slippage_pct_float, max_slippage) + + # Add rows for the table + rows.append( + ( + str(netuid), + f"{hotkey[1]}", + str(amount_to_stake), + rate + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", + str(received_amount.set_unit(netuid)), + str(slippage_pct), + ) + ) + + # Define and print stake table + slippage warning + table = define_stake_stable(wallet, subtensor) + for row in rows: + table.add_row(*row) + print_table_and_slippage(table, max_slippage) + + if prompt: + if not Confirm.ask("Would you like to continue?"): + raise typer.Exit() + + # Perform staking operation. + try: + wallet.unlock_coldkey() + except KeyFileError: + err_console.print("Error decrypting coldkey (possibly incorrect password)") + return False + + stake_coroutines = [ + send_stake_extrinsic(ni, am, curr, 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 + ] + await asyncio.gather(*stake_coroutines) diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 68211a5f5..64be393fa 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -178,7 +178,7 @@ def prompt_stake_amount( """ while True: amount_input = Prompt.ask( - f"\nEnter amount to {action_name} from " + 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 " diff --git a/bittensor_cli/src/commands/stake/stake.py b/bittensor_cli/src/commands/stake/stake.py index 29684c1d2..68aef7242 100644 --- a/bittensor_cli/src/commands/stake/stake.py +++ b/bittensor_cli/src/commands/stake/stake.py @@ -1,18 +1,16 @@ 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, FloatPrompt, Prompt +from rich.prompt import Confirm, 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 async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.balances import Balance @@ -35,389 +33,6 @@ from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -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_add_table(wallet: Wallet, subtensor: "SubtensorInterface") -> 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"] - ) - - return table - - -def _print_table_and_slippage(table: Table, max_slippage: float): - """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 - console.print( - """ -[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). -""" - ) - - -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], -): - """ - - 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`) - - Returns: - - """ - 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, - ) - - # Determine the amount we are staking. - rows = [] - stake_amount_balance = [] - current_stake_balances = [] - current_wallet_balance_ = await subtensor.get_balance( - wallet.coldkeypub.ss58_address - ) - current_wallet_balance = current_wallet_balance_.set_unit(0) - remaining_wallet_balance = current_wallet_balance - max_slippage = 0.0 - - starting_chain_head = await subtensor.substrate.get_chain_head() - _all_dynamic_info, stake_info_dict = await asyncio.gather( - subtensor.all_subnets(), - subtensor.get_stake_for_coldkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - block_hash=starting_chain_head, - ), - ) - all_dynamic_info = {di.netuid: di for di in _all_dynamic_info} - initial_stake_balances = {} - for _, hotkey_ss58 in hotkeys_to_stake_to: - initial_stake_balances[hotkey_ss58] = {} - for netuid in netuids: - initial_stake_balances[hotkey_ss58][netuid] = Balance.from_rao(0) - - for stake_info in stake_info_dict: - if stake_info.hotkey_ss58 in initial_stake_balances: - initial_stake_balances[stake_info.hotkey_ss58][stake_info.netuid] = ( - stake_info.stake - ) - - for hk_name, hk_ss58 in hotkeys_to_stake_to: - if not is_valid_ss58_address(hk_ss58): - print_error( - f"The entered hotkey ss58 address is incorrect: {hk_name} | {hk_ss58}" - ) - return False - for hotkey in hotkeys_to_stake_to: - for netuid in netuids: - # Check that the subnet exists. - dynamic_info = all_dynamic_info.get(netuid) - if not dynamic_info: - err_console.print(f"Subnet with netuid: {netuid} does not exist.") - continue - current_stake_balances.append(initial_stake_balances[hotkey[1]][netuid]) - - # Get the amount. - amount_to_stake_as_balance = Balance(0) - if amount: - amount_to_stake_as_balance = Balance.from_tao(amount) - elif stake_all: - amount_to_stake_as_balance = current_wallet_balance / len(netuids) - elif not amount: - if Confirm.ask(f"Stake all: [bold]{remaining_wallet_balance}[/bold]?"): - amount_to_stake_as_balance = remaining_wallet_balance - else: - try: - amount = FloatPrompt.ask( - f"Enter amount to stake in {Balance.get_unit(0)} to subnet: {netuid}" - ) - amount_to_stake_as_balance = Balance.from_tao(amount) - except ValueError: - err_console.print( - f":cross_mark:[red]Invalid amount: {amount}[/red]" - ) - return False - stake_amount_balance.append(amount_to_stake_as_balance) - - # Check enough to stake. - amount_to_stake_as_balance.set_unit(0) - if amount_to_stake_as_balance > 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_as_balance}[/bold white]" - ) - return False - remaining_wallet_balance -= amount_to_stake_as_balance - - # Slippage warning - received_amount, _, slippage_pct_float = ( - dynamic_info.tao_to_alpha_with_slippage(amount_to_stake_as_balance) - ) - if dynamic_info.is_dynamic: - slippage_pct = f"{slippage_pct_float:.4f} %" - rate = str(1 / (float(dynamic_info.price) or 1)) - else: - slippage_pct_float = 0 - slippage_pct = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]N/A[/{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]" - rate = str(1) - max_slippage = max(slippage_pct_float, max_slippage) - rows.append( - ( - str(netuid), - # f"{staking_address_ss58[:3]}...{staking_address_ss58[-3:]}", - f"{hotkey[1]}", - str(amount_to_stake_as_balance), - rate + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", - str(received_amount.set_unit(netuid)), - str(slippage_pct), - ) - ) - - # Define and print stake table + slippage warning - table = _define_stake_add_table(wallet, subtensor) - for row in rows: - table.add_row(*row) - _print_table_and_slippage(table, max_slippage) - - if prompt: - if not Confirm.ask("Would you like to continue?"): - raise typer.Exit() - - async def send_extrinsic( - netuid_i, amount_, current, staking_address_ss58, status=None - ): - err_out = partial(print_error, status=status) - failure_prelude = ( - f":cross_mark: [red]Failed[/red] to stake {amount} on Netuid {netuid_i}" - ) - 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 - ) - 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, subtensor.substrate)}" - ) - 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, stake_info_dict = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address), - subtensor.get_stake_for_coldkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - ), - ) - new_stake = Balance.from_rao(0) - for stake_info in stake_info_dict: - if ( - stake_info.hotkey_ss58 == staking_address_ss58 - and stake_info.netuid == netuid_i - ): - new_stake = stake_info.stake.set_unit(netuid_i) - break - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - console.print( - f"Balance:\n [blue]{current_wallet_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']}] Stake:\n [blue]{current}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" - ) - - # Perform staking operation. - try: - wallet.unlock_coldkey() - except KeyFileError: - err_console.print("Error decrypting coldkey (possibly incorrect password)") - return False - extrinsics_coroutines = [ - send_extrinsic(ni, am, curr, staking_address) - for i, (ni, am, curr) in enumerate( - zip(netuids, stake_amount_balance, current_stake_balances) - ) - for _, staking_address in hotkeys_to_stake_to - ] - if len(extrinsics_coroutines) == 1: - with console.status(f"\n:satellite: Staking on netuid(s): {netuids} ..."): - await extrinsics_coroutines[0] - else: - with console.status(":satellite: Checking transaction rate limit ..."): - tx_rate_limit_blocks = await subtensor.query( - module="SubtensorModule", storage_function="TxRateLimit" - ) - netuid_hk_pairs = [(ni, hk) for ni in netuids for hk in hotkeys_to_stake_to] - for item, kp in zip(extrinsics_coroutines, netuid_hk_pairs): - ni, hk = kp - with console.status( - f"\n:satellite: Staking on netuid {ni} with hotkey {hk}... ..." - ): - await item - 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 sec per block - - async def unstake_selection( subtensor: "SubtensorInterface", wallet: Wallet, From 368e06309ee5e270e799fcc5be40b237d5e7716c Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Wed, 5 Feb 2025 09:50:04 -0800 Subject: [PATCH 03/11] naming update + row hints --- bittensor_cli/src/commands/stake/add.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index b22e4f8d4..bc3dae98b 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -222,7 +222,7 @@ async def stake_add( bool: True if stake operation is successful, False otherwise """ - async def send_stake_extrinsic( + async def add_stake_extrinsic( netuid_i, amount_, current, staking_address_ss58, status=None ): err_out = partial(print_error, status=status) @@ -374,12 +374,12 @@ async def send_stake_extrinsic( # Add rows for the table rows.append( ( - str(netuid), - f"{hotkey[1]}", - str(amount_to_stake), - rate + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", - str(received_amount.set_unit(netuid)), - str(slippage_pct), + str(netuid), # netuid + f"{hotkey[1]}", # hotkey + str(amount_to_stake), # amount + rate + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate + str(received_amount.set_unit(netuid)), # received + str(slippage_pct), # slippage ) ) @@ -401,7 +401,7 @@ async def send_stake_extrinsic( return False stake_coroutines = [ - send_stake_extrinsic(ni, am, curr, staking_address) + add_stake_extrinsic(ni, am, curr, staking_address) for i, (ni, am, curr) in enumerate( zip(netuids, amounts_to_stake, current_stake_balances) ) From f9df8835361f59f4360235b96235d7e32b99f91a Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Thu, 6 Feb 2025 20:04:07 -0800 Subject: [PATCH 04/11] Adds safe staking configs, impl in stake add --- bittensor_cli/cli.py | 228 ++++++++++++++--- bittensor_cli/src/__init__.py | 1 + bittensor_cli/src/bittensor/utils.py | 14 ++ bittensor_cli/src/commands/stake/add.py | 293 ++++++++++++++++++---- bittensor_cli/src/commands/stake/stake.py | 7 +- 5 files changed, 464 insertions(+), 79 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 1c0bc65c1..67dec15c4 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -243,6 +243,26 @@ class Options: help="Create wallet from uri (e.g. 'Alice', 'Bob', 'Charlie', 'Dave', 'Eve')", callback=validate_uri, ) + slippage_tolerance = typer.Option( + None, + "--slippage", + "--slippage-tolerance", + "--tolerance", + help="Set the slippage tolerance percentage for transactions (default: 0.05%).", + ) + 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", + help="Enable or disable partial stake mode (default: disabled).", + ) def list_prompt(init_var: list, list_type: type, help_text: str) -> list: @@ -517,25 +537,29 @@ def __init__(self): "wallet_hotkey": None, "network": None, "use_cache": True, - "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, - }, + "slippage_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) @@ -634,7 +658,7 @@ def __init__(self): 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( @@ -1057,6 +1081,25 @@ 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`.", ), + slippage_tolerance: Optional[float] = typer.Option( + None, + "--slippage", + "--slippage-tolerance", + "--tolerance", + help="Set the slippage 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` @@ -1067,8 +1110,11 @@ def set_config( "wallet_hotkey": wallet_hotkey, "network": network, "use_cache": use_cache, + "slippage_tolerance": slippage_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() @@ -1092,6 +1138,17 @@ def set_config( default=True, ) self.config[arg] = nc + + elif arg == "slippage_tolerance": + 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, + ) + if val < 0 or val > 1: + print_error("Slippage tolerance must be between 0 and 1.") + raise typer.Exit() + self.config[arg] = val + else: val = Prompt.ask( f"What value would you like to assign to [red]{arg}[/red]?" @@ -1149,6 +1206,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"), + slippage_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"), ): """ @@ -1178,6 +1247,9 @@ def del_config( "wallet_hotkey": wallet_hotkey, "network": network, "use_cache": use_cache, + "slippage_tolerance": slippage_tolerance, + "safe_staking": safe_staking, + "allow_partial_stake": allow_partial_stake, } # If no specific argument is provided, iterate over all @@ -1239,6 +1311,8 @@ def get_config(self): else: if value in Constants.networks: value = value + f" ({Constants.network_map[value]})" + if key == "slippage_tolerance": + value = f"{value} ({value*100}%)" elif key in deprecated_configs: continue @@ -1251,13 +1325,97 @@ 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_slippage( + self, + slippage_tolerance: Optional[float], + ) -> float: + """ + Gets slippage tolerance from args, config, or default. + + Args: + slippage_tolerance (Optional[float]): Explicitly provided slippage value + + Returns: + float: Slippage tolerance value + """ + if slippage_tolerance is not None: + console.print( + f"[dim][blue]Slippage tolerance[/blue] is: [bold cyan]{slippage_tolerance} ({slippage_tolerance*100}%)[/bold cyan]." ) - ) + return slippage_tolerance + elif self.config.get("slippage_tolerance") is not None: + config_slippage = self.config["slippage_tolerance"] + console.print( + f"[dim][blue]Slippage tolerance[/blue] is: [bold cyan]{config_slippage} ({config_slippage*100}%)[/bold cyan] (from config)." + ) + return config_slippage + else: + console.print( + f"[dim][blue]Slippage tolerance[/blue] is: [bold cyan]{defaults.slippage_tolerance} ({defaults.slippage_tolerance*100}%)[/bold cyan] by default." + ) + return defaults.slippage_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] is: [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] is: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] (from config)." + ) + return safe_staking + else: + safe_staking = True + console.print( + f"[dim][blue]Safe staking[/blue] is: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] by default." + ) + 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] is: [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] is: [bold cyan]{'enabled' if config_partial else 'disabled'}[/bold cyan] (from config)." + ) + return config_partial + else: + console.print( + f"[dim][blue]Partial staking[/blue] is: [bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan] by default." + ) + return False def wallet_ask( self, @@ -2631,6 +2789,9 @@ def stake_add( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, + slippage_tolerance: Optional[float] = Options.slippage_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, @@ -2647,6 +2808,10 @@ def stake_add( [green]$[/green] btcli stake add --amount 100 --wallet-name --wallet-hotkey """ self.verbosity_handler(quiet, verbose) + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + allow_partial_stake = self.ask_partial_stake(allow_partial_stake) + slippage_tolerance = self.ask_slippage(slippage_tolerance) netuid = get_optional_netuid(netuid, all_netuids) if stake_all and amount: @@ -2795,6 +2960,9 @@ def stake_add( all_hotkeys, included_hotkeys, excluded_hotkeys, + safe_staking, + slippage_tolerance, + allow_partial_stake, ) ) @@ -2873,9 +3041,9 @@ def stake_remove( [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) - # TODO: Coldkey related unstakes need to be updated. Patching for now. - unstake_all_alpha = False - unstake_all = False + # # TODO: Coldkey related unstakes need to be updated. Patching for now. + # unstake_all_alpha = False + # unstake_all = False if interactive and any( [hotkey_ss58_address, include_hotkeys, exclude_hotkeys, all_hotkeys] diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 6d3b805c3..821fe5e04 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -65,6 +65,7 @@ def decode(key: str, default=""): class Defaults: netuid = 1 + slippage_tolerance = 0.005 class config: base_path = "~/.bittensor" diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 5e8091a79..09c79d9b0 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -1226,3 +1226,17 @@ def print_linux_dependency_message(): def is_linux(): """Returns True if the operating system is Linux.""" return platform.system().lower() == "linux" + +def validate_slippage_tolerance(value: Optional[float]) -> Optional[float]: + """Validates slippage tolerance input""" + if value is not None: + if value < 0: + raise typer.BadParameter("Slippage tolerance cannot be negative (less than 0%).") + if value > 1: + raise typer.BadParameter("Slippage tolerance cannot be greater than 1 (100%).") + if value > 0.5: + console.print( + f"[yellow]Warning: High slippage tolerance of {value*100}% specified. " + "This may result in unfavorable transaction execution.[/yellow]" + ) + return value diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index bc3dae98b..910c39153 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -4,7 +4,7 @@ import typer from typing import TYPE_CHECKING, Optional from rich.table import Table -from rich.prompt import Confirm +from rich.prompt import Confirm, Prompt from async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src import COLOR_PALETTE @@ -17,7 +17,6 @@ is_valid_ss58_address, print_error, print_verbose, - prompt_stake_amount, ) from bittensor_wallet import Wallet from bittensor_wallet.errors import KeyFileError @@ -25,6 +24,49 @@ if TYPE_CHECKING: from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +# 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, @@ -80,7 +122,12 @@ def _get_hotkeys_to_stake_to( return [(None, wallet.hotkey.ss58_address)] -def define_stake_stable(wallet: Wallet, subtensor: "SubtensorInterface") -> Table: +def define_stake_table( + wallet: Wallet, + subtensor: "SubtensorInterface", + safe_staking: bool, + slippage_tolerance: float, +) -> Table: """Creates and initializes a table for displaying stake information. Args: @@ -128,10 +175,21 @@ def define_stake_stable(wallet: Wallet, subtensor: "SubtensorInterface") -> Tabl "Slippage", justify="center", style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"] ) + if safe_staking: + table.add_column( + f"Rate with tolerance: [blue]({slippage_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): +def print_table_and_slippage(table: Table, max_slippage: float, safe_staking: bool): """Prints the stake table, slippage warning, and table description. Args: @@ -148,8 +206,7 @@ def print_table_and_slippage(table: Table, max_slippage: float): console.print(message) # Table description - console.print( - """ + 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: @@ -158,14 +215,16 @@ def print_table_and_slippage(table: Table, max_slippage: float): - [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). -""" - ) + - [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.""" + console.print(base_description + (safe_staking_description if safe_staking else "")) -def calculate_slippage( - subnet_info, amount: Balance -) -> tuple[Balance, str, float]: + +def calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, float]: """Calculate slippage when adding stake. Args: @@ -192,6 +251,7 @@ def calculate_slippage( return received_amount, slippage_str, slippage_pct_float, rate +# Command async def stake_add( wallet: Wallet, subtensor: "SubtensorInterface", @@ -202,9 +262,11 @@ async def stake_add( all_hotkeys: bool, include_hotkeys: list[str], exclude_hotkeys: list[str], + safe_staking: bool, + slippage_tolerance: float, + allow_partial_stake: bool, ): """ - Args: wallet: wallet object subtensor: SubtensorInterface object @@ -217,12 +279,94 @@ async def stake_add( 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 + slippage_tolerance: slippage 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}" + ) - async def add_stake_extrinsic( + 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: + err_out( + f"\n{failure_prelude} with error: {format_error_message(e, subtensor.substrate)}" + ) + 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=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + ), + ) + 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_wallet_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + + amount_staked = current_wallet_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) @@ -232,6 +376,7 @@ async def add_stake_extrinsic( 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", @@ -260,20 +405,14 @@ async def add_stake_extrinsic( f"\n{failure_prelude} with error: {format_error_message(await response.error_message, subtensor.substrate)}" ) else: - new_balance, stake_info_dict = await asyncio.gather( + new_balance, new_stake = await asyncio.gather( subtensor.get_balance(wallet.coldkeypub.ss58_address), - subtensor.get_stake_for_coldkey( + subtensor.get_stake( + hotkey_ss58=staking_address_ss58, coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid_i, ), ) - new_stake = Balance.from_rao(0) - for stake_info in stake_info_dict: - if ( - stake_info.hotkey_ss58 == staking_address_ss58 - and stake_info.netuid == netuid_i - ): - new_stake = stake_info.stake.set_unit(netuid_i) - break console.print( f":white_heavy_check_mark: [dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" ) @@ -330,14 +469,15 @@ async def add_stake_extrinsic( 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. - dynamic_info = all_subnets.get(netuid) - if not dynamic_info: + 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]) @@ -367,44 +507,101 @@ async def add_stake_extrinsic( # Calculate slippage received_amount, slippage_pct, slippage_pct_float, rate = ( - calculate_slippage(dynamic_info, amount_to_stake) + calculate_slippage(subnet_info, amount_to_stake) ) max_slippage = max(slippage_pct_float, max_slippage) # Add rows for the table - rows.append( - ( - str(netuid), # netuid - f"{hotkey[1]}", # hotkey - str(amount_to_stake), # amount - rate + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate - str(received_amount.set_unit(netuid)), # received - str(slippage_pct), # slippage + 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 + slippage_tolerance) # Rate only for display + price_with_tolerance = subnet_info.price.rao * (1 + slippage_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( + [ + str(rate_with_tolerance) + + f" {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_stable(wallet, subtensor) + table = define_stake_table( + wallet, subtensor, safe_staking, slippage_tolerance + ) for row in rows: table.add_row(*row) - print_table_and_slippage(table, max_slippage) + print_table_and_slippage(table, max_slippage, safe_staking) if prompt: if not Confirm.ask("Would you like to continue?"): raise typer.Exit() - - # Perform staking operation. try: wallet.unlock_coldkey() except KeyFileError: err_console.print("Error decrypting coldkey (possibly incorrect password)") return False - stake_coroutines = [ - add_stake_extrinsic(ni, am, curr, 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 - ] - await asyncio.gather(*stake_coroutines) + 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} ..."): + await asyncio.gather(*stake_coroutines) + \ No newline at end of file diff --git a/bittensor_cli/src/commands/stake/stake.py b/bittensor_cli/src/commands/stake/stake.py index 68aef7242..b94b08edf 100644 --- a/bittensor_cli/src/commands/stake/stake.py +++ b/bittensor_cli/src/commands/stake/stake.py @@ -293,7 +293,12 @@ async def _unstake_all( else "Unstaking Summary - All Alpha Stakes" ) table = Table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{table_title}\nWallet: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.name}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}], Coldkey ss58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\nNetwork: {subtensor.network}[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n", + 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", From a43c451b407a21e727862ce5ea7bf300ffa25f36 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Mon, 10 Feb 2025 12:33:11 -0800 Subject: [PATCH 05/11] slippage_tolerance -> price_tolerance + good error handling --- bittensor_cli/cli.py | 91 +++++++++++++------------ bittensor_cli/src/__init__.py | 2 +- bittensor_cli/src/bittensor/utils.py | 10 +-- bittensor_cli/src/commands/stake/add.py | 76 +++++++++++++-------- 4 files changed, 102 insertions(+), 77 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 67dec15c4..107fa1e21 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -55,9 +55,9 @@ 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, InvalidHandshake from yaml import safe_dump, safe_load @@ -243,12 +243,13 @@ class Options: help="Create wallet from uri (e.g. 'Alice', 'Bob', 'Charlie', 'Dave', 'Eve')", callback=validate_uri, ) - slippage_tolerance = typer.Option( + rate_tolerance = typer.Option( None, "--slippage", "--slippage-tolerance", "--tolerance", - help="Set the slippage tolerance percentage for transactions (default: 0.05%).", + help="Set the rate tolerance percentage for transactions (default: 0.05%).", + callback=validate_rate_tolerance, ) safe_staking = typer.Option( None, @@ -261,6 +262,7 @@ class Options: "--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).", ) @@ -537,7 +539,7 @@ def __init__(self): "wallet_hotkey": None, "network": None, "use_cache": True, - "slippage_tolerance": None, + "rate_tolerance": None, "safe_staking": True, "allow_partial_stake": False, # Commenting this out as this needs to get updated @@ -1081,12 +1083,12 @@ 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`.", ), - slippage_tolerance: Optional[float] = typer.Option( + rate_tolerance: Optional[float] = typer.Option( None, "--slippage", "--slippage-tolerance", "--tolerance", - help="Set the slippage tolerance percentage for transactions (e.g. 0.1 for 0.1%).", + help="Set the rate tolerance percentage for transactions (e.g. 0.1 for 0.1%).", ), safe_staking: Optional[bool] = typer.Option( None, @@ -1110,7 +1112,7 @@ def set_config( "wallet_hotkey": wallet_hotkey, "network": network, "use_cache": use_cache, - "slippage_tolerance": slippage_tolerance, + "rate_tolerance": rate_tolerance, "safe_staking": safe_staking, "allow_partial_stake": allow_partial_stake, } @@ -1139,16 +1141,19 @@ def set_config( ) self.config[arg] = nc - elif arg == "slippage_tolerance": - 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, - ) - if val < 0 or val > 1: - print_error("Slippage tolerance must be between 0 and 1.") - raise typer.Exit() - self.config[arg] = val - + 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]?" @@ -1206,7 +1211,7 @@ 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"), - slippage_tolerance: bool = typer.Option( + rate_tolerance: bool = typer.Option( False, "--slippage", "--slippage-tolerance", "--tolerance" ), safe_staking: bool = typer.Option( @@ -1247,7 +1252,7 @@ def del_config( "wallet_hotkey": wallet_hotkey, "network": network, "use_cache": use_cache, - "slippage_tolerance": slippage_tolerance, + "rate_tolerance": rate_tolerance, "safe_staking": safe_staking, "allow_partial_stake": allow_partial_stake, } @@ -1311,8 +1316,8 @@ def get_config(self): else: if value in Constants.networks: value = value + f" ({Constants.network_map[value]})" - if key == "slippage_tolerance": - value = f"{value} ({value*100}%)" + if key == "rate_tolerance": + value = f"{value} ({value*100}%)" if value is not None else "None" elif key in deprecated_configs: continue @@ -1326,35 +1331,35 @@ def get_config(self): console.print(table) - def ask_slippage( + def ask_rate_tolerance( self, - slippage_tolerance: Optional[float], + rate_tolerance: Optional[float], ) -> float: """ - Gets slippage tolerance from args, config, or default. + Gets rate tolerance from args, config, or default. Args: - slippage_tolerance (Optional[float]): Explicitly provided slippage value + rate_tolerance (Optional[float]): Explicitly provided slippage value Returns: - float: Slippage tolerance value + float: rate tolerance value """ - if slippage_tolerance is not None: + if rate_tolerance is not None: console.print( - f"[dim][blue]Slippage tolerance[/blue] is: [bold cyan]{slippage_tolerance} ({slippage_tolerance*100}%)[/bold cyan]." + f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{rate_tolerance} ({rate_tolerance*100}%)[/bold cyan]." ) - return slippage_tolerance - elif self.config.get("slippage_tolerance") is not None: - config_slippage = self.config["slippage_tolerance"] + return rate_tolerance + elif self.config.get("rate_tolerance") is not None: + config_slippage = self.config["rate_tolerance"] console.print( - f"[dim][blue]Slippage tolerance[/blue] is: [bold cyan]{config_slippage} ({config_slippage*100}%)[/bold cyan] (from config)." + f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{config_slippage} ({config_slippage*100}%)[/bold cyan] (from config)." ) return config_slippage else: console.print( - f"[dim][blue]Slippage tolerance[/blue] is: [bold cyan]{defaults.slippage_tolerance} ({defaults.slippage_tolerance*100}%)[/bold cyan] by default." + f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{defaults.rate_tolerance} ({defaults.rate_tolerance*100}%)[/bold cyan] by default. You can set this using `btcli config set`" ) - return defaults.slippage_tolerance + return defaults.rate_tolerance def ask_safe_staking( self, @@ -1371,19 +1376,19 @@ def ask_safe_staking( """ if safe_staking is not None: console.print( - f"[dim][blue]Safe staking[/blue] is: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan]." + 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] is: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] (from config)." + 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( - f"[dim][blue]Safe staking[/blue] is: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] by default." + f"[dim][blue]Safe staking[/blue]: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] by default. You can set this using `btcli config set`" ) return safe_staking @@ -1402,18 +1407,18 @@ def ask_partial_stake( """ if allow_partial_stake is not None: console.print( - f"[dim][blue]Partial staking[/blue] is: [bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan]." + 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] is: [bold cyan]{'enabled' if config_partial else 'disabled'}[/bold cyan] (from config)." + f"[dim][blue]Partial staking[/blue]: [bold cyan]{'enabled' if config_partial else 'disabled'}[/bold cyan] (from config)." ) return config_partial else: console.print( - f"[dim][blue]Partial staking[/blue] is: [bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan] by default." + f"[dim][blue]Partial staking[/blue]: [bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan] by default. You can set this using `btcli config set`" ) return False @@ -2789,7 +2794,7 @@ def stake_add( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, - slippage_tolerance: Optional[float] = Options.slippage_tolerance, + 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, @@ -2810,8 +2815,8 @@ def stake_add( 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) - slippage_tolerance = self.ask_slippage(slippage_tolerance) netuid = get_optional_netuid(netuid, all_netuids) if stake_all and amount: @@ -2961,7 +2966,7 @@ def stake_add( included_hotkeys, excluded_hotkeys, safe_staking, - slippage_tolerance, + rate_tolerance, allow_partial_stake, ) ) diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 821fe5e04..af0786d31 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -65,7 +65,7 @@ def decode(key: str, default=""): class Defaults: netuid = 1 - slippage_tolerance = 0.005 + rate_tolerance = 0.005 class config: base_path = "~/.bittensor" diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 09c79d9b0..657244220 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -1227,16 +1227,16 @@ def is_linux(): """Returns True if the operating system is Linux.""" return platform.system().lower() == "linux" -def validate_slippage_tolerance(value: Optional[float]) -> Optional[float]: - """Validates slippage tolerance input""" +def validate_rate_tolerance(value: Optional[float]) -> Optional[float]: + """Validates rate tolerance input""" if value is not None: if value < 0: - raise typer.BadParameter("Slippage tolerance cannot be negative (less than 0%).") + raise typer.BadParameter("Rate tolerance cannot be negative (less than 0%).") if value > 1: - raise typer.BadParameter("Slippage tolerance cannot be greater than 1 (100%).") + raise typer.BadParameter("Rate tolerance cannot be greater than 1 (100%).") if value > 0.5: console.print( - f"[yellow]Warning: High slippage tolerance of {value*100}% specified. " + f"[yellow]Warning: High rate tolerance of {value*100}% specified. " "This may result in unfavorable transaction execution.[/yellow]" ) return value diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 910c39153..4d3c86504 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + # Helper functions def prompt_stake_amount( current_balance: Balance, netuid: int, action_name: str @@ -126,7 +127,7 @@ def define_stake_table( wallet: Wallet, subtensor: "SubtensorInterface", safe_staking: bool, - slippage_tolerance: float, + rate_tolerance: float, ) -> Table: """Creates and initializes a table for displaying stake information. @@ -177,7 +178,7 @@ def define_stake_table( if safe_staking: table.add_column( - f"Rate with tolerance: [blue]({slippage_tolerance*100}%)[/blue]", + f"Rate with tolerance: [blue]({rate_tolerance*100}%)[/blue]", justify="center", style=COLOR_PALETTE["POOLS"]["RATE"], ) @@ -242,11 +243,13 @@ def calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, floa ) if subnet_info.is_dynamic: slippage_str = f"{slippage_pct_float:.4f} %" - rate = str(1 / (float(subnet_info.price) or 1)) + rate = f"{(1 / subnet_info.price.tao or 1):.4f}" + print(f"rate: {rate}") else: slippage_pct_float = 0 slippage_str = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]N/A[/{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]" - rate = str(1) + rate = "1" + print(f"rate: {rate}") return received_amount, slippage_str, slippage_pct_float, rate @@ -263,7 +266,7 @@ async def stake_add( include_hotkeys: list[str], exclude_hotkeys: list[str], safe_staking: bool, - slippage_tolerance: float, + rate_tolerance: float, allow_partial_stake: bool, ): """ @@ -280,12 +283,13 @@ async def stake_add( 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 - slippage_tolerance: slippage tolerance percentage for stake operations + 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, @@ -294,13 +298,13 @@ async def safe_stake_extrinsic( price_limit: Balance, wallet: Wallet, subtensor: "SubtensorInterface", - status=None + 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 ) @@ -323,9 +327,18 @@ async def safe_stake_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, subtensor.substrate)}" - ) + 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, subtensor.substrate)}" + ) return else: await response.process_events() @@ -334,22 +347,24 @@ async def safe_stake_extrinsic( 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), + 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_wallet_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" ) - amount_staked = current_wallet_balance - new_balance + amount_staked = current_balance - new_balance if allow_partial_stake and (amount_staked != amount): console.print( "Partial stake transaction. Staked:\n" @@ -370,13 +385,13 @@ 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", @@ -417,7 +432,7 @@ async def stake_extrinsic( 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_wallet_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + 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']}] " @@ -526,17 +541,21 @@ async def stake_extrinsic( if safe_staking: if subnet_info.is_dynamic: rate = 1 / subnet_info.price.tao or 1 - rate_with_tolerance = rate * (1 + slippage_tolerance) # Rate only for display - price_with_tolerance = subnet_info.price.rao * (1 + slippage_tolerance) # Actual price to pass to extrinsic + _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 + rate_with_tolerance = "1" price_with_tolerance = Balance.from_rao(1) prices_with_tolerance.append(price_with_tolerance) base_row.extend( [ - str(rate_with_tolerance) - + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", + 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 ] ) @@ -544,9 +563,7 @@ async def stake_extrinsic( rows.append(tuple(base_row)) # Define and print stake table + slippage warning - table = define_stake_table( - wallet, subtensor, safe_staking, slippage_tolerance - ) + 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) @@ -563,7 +580,9 @@ async def stake_extrinsic( 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) + zip( + netuids, amounts_to_stake, current_stake_balances, prices_with_tolerance + ) ): for _, staking_address in hotkeys_to_stake_to: # Regular extrinsic for root subnet @@ -601,7 +620,8 @@ async def stake_extrinsic( ) for _, staking_address in hotkeys_to_stake_to ] - + with console.status(f"\n:satellite: Staking on netuid(s): {netuids} ..."): - await asyncio.gather(*stake_coroutines) - \ No newline at end of file + # We can gather them all at once but balance reporting will be in race-condition. + for coroutine in stake_coroutines: + await coroutine From 33880d423b52585acf0130c692d14daa53add09e Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Mon, 10 Feb 2025 17:13:49 -0800 Subject: [PATCH 06/11] Adds stake.remove - working --- bittensor_cli/cli.py | 52 +- bittensor_cli/src/commands/stake/add.py | 18 +- bittensor_cli/src/commands/stake/remove.py | 994 +++++++++++++++++++++ bittensor_cli/src/commands/stake/stake.py | 806 +---------------- 4 files changed, 1030 insertions(+), 840 deletions(-) create mode 100644 bittensor_cli/src/commands/stake/remove.py diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 585d12889..df427caa8 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -37,7 +37,8 @@ children_hotkeys, stake, move, - add as stake_add, + 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 @@ -1038,7 +1039,7 @@ def main_callback( uvloop.install() self.asyncio_runner = asyncio.run except ModuleNotFoundError: - self.asyncio_runner = asyncio + self.asyncio_runner = asyncio.run def verbosity_handler(self, quiet: bool, verbose: bool): if quiet and verbose: @@ -2975,7 +2976,7 @@ def stake_add( raise typer.Exit() return self._run_command( - stake_add.stake_add( + add_stake.stake_add( wallet, self.initialize_chain(network), netuid, @@ -3020,12 +3021,6 @@ def stake_remove( "", 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.", - ), include_hotkeys: str = typer.Option( "", "--include-hotkeys", @@ -3185,23 +3180,30 @@ def stake_remove( else: excluded_hotkeys = [] - 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, - interactive, - netuid=netuid, - unstake_all_alpha=unstake_all_alpha, + if unstake_all or unstake_all_alpha: + return self._run_command( + remove_stake.unstake_all( + wallet=wallet, + subtensor=self.initialize_chain(network), + unstake_all_alpha=unstake_all_alpha, + prompt=prompt, + ) + ) + else: + return self._run_command( + remove_stake.unstake( + wallet=wallet, + subtensor=self.initialize_chain(network), + hotkey_ss58_address=hotkey_ss58_address, + all_hotkeys=all_hotkeys, + include_hotkeys=included_hotkeys, + exclude_hotkeys=excluded_hotkeys, + amount=amount, + prompt=prompt, + interactive=interactive, + netuid=netuid, + ) ) - ) def stake_move( self, diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 4d3c86504..f2067cbbd 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -26,7 +26,7 @@ # Helper functions -def prompt_stake_amount( +def _prompt_stake_amount( current_balance: Balance, netuid: int, action_name: str ) -> tuple[Balance, bool]: """Prompts user to input a stake amount with validation. @@ -123,7 +123,7 @@ def _get_hotkeys_to_stake_to( return [(None, wallet.hotkey.ss58_address)] -def define_stake_table( +def _define_stake_table( wallet: Wallet, subtensor: "SubtensorInterface", safe_staking: bool, @@ -190,7 +190,7 @@ def define_stake_table( return table -def print_table_and_slippage(table: Table, max_slippage: float, safe_staking: bool): +def _print_table_and_slippage(table: Table, max_slippage: float, safe_staking: bool): """Prints the stake table, slippage warning, and table description. Args: @@ -225,7 +225,7 @@ def print_table_and_slippage(table: Table, max_slippage: float, safe_staking: bo console.print(base_description + (safe_staking_description if safe_staking else "")) -def calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, float]: +def _calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, float]: """Calculate slippage when adding stake. Args: @@ -244,12 +244,10 @@ def calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, floa if subnet_info.is_dynamic: slippage_str = f"{slippage_pct_float:.4f} %" rate = f"{(1 / subnet_info.price.tao or 1):.4f}" - print(f"rate: {rate}") else: slippage_pct_float = 0 slippage_str = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]N/A[/{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]" rate = "1" - print(f"rate: {rate}") return received_amount, slippage_str, slippage_pct_float, rate @@ -504,7 +502,7 @@ async def stake_extrinsic( elif stake_all: amount_to_stake = current_wallet_balance / len(netuids) elif not amount: - amount_to_stake, _ = prompt_stake_amount( + amount_to_stake, _ = _prompt_stake_amount( current_balance=remaining_wallet_balance, netuid=netuid, action_name="stake", @@ -522,7 +520,7 @@ async def stake_extrinsic( # Calculate slippage received_amount, slippage_pct, slippage_pct_float, rate = ( - calculate_slippage(subnet_info, amount_to_stake) + _calculate_slippage(subnet_info, amount_to_stake) ) max_slippage = max(slippage_pct_float, max_slippage) @@ -563,10 +561,10 @@ async def stake_extrinsic( rows.append(tuple(base_row)) # Define and print stake table + slippage warning - table = define_stake_table(wallet, subtensor, safe_staking, rate_tolerance) + 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) + _print_table_and_slippage(table, max_slippage, safe_staking) if prompt: if not Confirm.ask("Would you like to continue?"): diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py new file mode 100644 index 000000000..8dd49ad69 --- /dev/null +++ b/bittensor_cli/src/commands/stake/remove.py @@ -0,0 +1,994 @@ +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 bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.utils import ( + # TODO add back in caching + console, + err_console, + print_verbose, + print_error, + get_hotkey_wallets_for_wallet, + is_valid_ss58_address, + format_error_message, + group_subnets, + millify_tao, + get_subnet_name, +) + +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 = False, + netuid: Optional[int] = None, +): + """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 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_all_hk_ss58 = None + unstake_operations = [] + total_received_amount = Balance.from_tao(0) + max_float_slippage = 0 + 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 unstake_all_from_hk: + amount_to_unstake_as_balance = current_stake_balance + unstake_all_hk_ss58 = staking_address_ss58 + elif 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) + + unstake_operations.append( + { + "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, + } + ) + + 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, + ) + + for op in unstake_operations: + subnet_info = op["dynamic_info"] + table.add_row( + str(op["netuid"]), # Netuid + op["hotkey_name"], # Hotkey Name + str(op["amount_to_unstake"]), # Amount to Unstake + str(float(subnet_info.price)) + + f"({Balance.get_unit(0)}/{Balance.get_unit(op['netuid'])})", # Rate + str(op["received_amount"]), # Received Amount + op["slippage_pct"], # Slippage Percent + ) + + _print_table_and_slippage(table, max_float_slippage) + 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 unstake_all_from_hk: + await _unstake_all_extrinsic( + hotkey_ss58=unstake_all_hk_ss58, + wallet=wallet, + subtensor=subtensor, + status=status, + ) + else: + for op in unstake_operations: + await _unstake_extrinsic( + netuid=op["netuid"], + amount=op["amount_to_unstake"], + current_stake=op["current_stake_balance"], + hotkey_ss58=op["hotkey_ss58"], + wallet=wallet, + subtensor=subtensor, + status=status, + ) + console.print( + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]Unstaking operations completed." + ) + + +async def unstake_all( + wallet: Wallet, + subtensor: "SubtensorInterface", + 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 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..." + ) + 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": wallet.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=wallet.hotkey.ss58_address, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=0, + ) + console.print( + f"Root Stake:\n [blue]{current_wallet_balance}[/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( + netuid: int, + amount: Balance, + current_stake: Balance, + hotkey_ss58: str, + wallet: Wallet, + subtensor: "SubtensorInterface", + 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 _unstake_all_extrinsic( + hotkey_ss58: str, + wallet: Wallet, + subtensor: "SubtensorInterface", + status=None, +) -> None: + """Execute an unstake_all extrinsic. + + Args: + hotkey_ss58: Hotkey SS58 address + wallet: Wallet instance + subtensor: Subtensor interface + status: Optional status for console updates + """ + current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="unstake_all", + call_params={"hotkey": hotkey_ss58}, + ) + 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: + print_error( + f":cross_mark: [red]Failed[/red] with error: " + f"{format_error_message(await response.error_message, subtensor.substrate)}", + status, + ) + return + + new_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + 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}" + ) + + except Exception as e: + print_error(f":cross_mark: [red]Failed[/red] with error: {str(e)}", status) + + +# 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, +) -> 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"] + ) + + return table + + +def _print_table_and_slippage( + table: Table, + max_float_slippage: float, +) -> 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" + ) + console.print( + """ +[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[/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). +""" + ) diff --git a/bittensor_cli/src/commands/stake/stake.py b/bittensor_cli/src/commands/stake/stake.py index b94b08edf..fb8dfed14 100644 --- a/bittensor_cli/src/commands/stake/stake.py +++ b/bittensor_cli/src/commands/stake/stake.py @@ -4,8 +4,7 @@ import typer from bittensor_wallet import Wallet -from bittensor_wallet.errors import KeyFileError -from rich.prompt import Confirm, Prompt +from rich.prompt import Prompt from rich.table import Table from rich import box from rich.progress import Progress, BarColumn, TextColumn @@ -18,13 +17,7 @@ from bittensor_cli.src.bittensor.utils import ( # TODO add back in caching console, - err_console, - print_verbose, print_error, - get_hotkey_wallets_for_wallet, - is_valid_ss58_address, - format_error_message, - group_subnets, millify_tao, get_subnet_name, ) @@ -33,803 +26,6 @@ from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -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.""" - while True: - response = Prompt.ask( - f"Unstake all: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_stake_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f" from [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{staking_address_name if staking_address_name else staking_address_ss58}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f" on netuid: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{netuid}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]? [y/n/q]", - choices=["y", "n", "q"], - default="n" if interactive else "y", - show_choices=True, - ).lower() - - if response == "q": - return None # Quit - - elif response == "y": - return current_stake_balance - - elif response == "n": - while True: - amount_input = Prompt.ask( - f"Enter amount to unstake in [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{Balance.get_unit(netuid)}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f" from subnet: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{netuid}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f" (Max: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_stake_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}])" - ) - if amount_input.lower() == "q": - return None # Quit - - try: - amount_value = float(amount_input) - if amount_value <= 0: - console.print("[red]Amount must be greater than zero.[/red]") - continue # Re-prompt - - 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 # Re-prompt - - return amount_to_unstake - - except ValueError: - console.print( - "[red]Invalid input. Please enter a numeric value or 'q' to quit.[/red]" - ) - - else: - console.print("[red]Invalid input. Please enter 'y', 'n', or 'q'.[/red]") - - -async def _unstake_all( - wallet: Wallet, - subtensor: "SubtensorInterface", - 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 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_} - - # Calculate total value and slippage for all stakes - total_received_value = Balance(0) - 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"], - ) - max_slippage = 0.0 - for stake in stake_info: - if stake.stake.rao == 0: - continue - - dynamic_info = all_sn_dynamic_info.get(stake.netuid) - stake_amount = stake.stake - received_amount, _, slippage_pct_float = ( - dynamic_info.alpha_to_tao_with_slippage(stake_amount) - ) - - total_received_value += received_amount - - # 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 - - if dynamic_info.is_dynamic: - slippage_pct = f"{slippage_pct_float:.4f} %" - else: - slippage_pct_float = 0 - slippage_pct = "[red]N/A[/red]" - - max_slippage = max(max_slippage, slippage_pct_float) - - table.add_row( - str(stake.netuid), - hotkey_display, - str(stake_amount), - str(float(dynamic_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..." - ) - 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": wallet.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}" - ) - return True - else: - err_console.print( - f":cross_mark: [red]Failed to unstake[/red]: {error_message}" - ) - return False - - -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, - interactive: bool = False, - netuid: Optional[int] = None, - unstake_all_alpha: bool = False, -): - """Unstake tokens from hotkey(s).""" - - if unstake_all or unstake_all_alpha: - return await _unstake_all(wallet, subtensor, unstake_all_alpha, prompt) - - 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 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() - ) - - # 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 from specific hotkey. - hotkeys_to_unstake_from = [(None, hotkey_ss58_address)] - elif all_hotkeys: - print_verbose("Unstaking from all hotkeys") - # Unstake from 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 - ] - elif include_hotkeys: - print_verbose("Unstaking from included hotkeys") - # Unstake from specific hotkeys. - for hotkey_identifier in include_hotkeys: - if is_valid_ss58_address(hotkey_identifier): - # If the hotkey is a valid ss58 address, we add it to the list. - hotkeys_to_unstake_from.append((None, hotkey_identifier)) - 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_identifier, - ) - hotkeys_to_unstake_from.append( - (wallet_.hotkey_str, wallet_.hotkey.ss58_address) - ) - else: - # Only cli.config.wallet.hotkey is specified. - # So we unstake from 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 = [(wallet.hotkey_str, wallet.hotkey.ss58_address)] - - with console.status( - f"Retrieving stake data from {subtensor.network}...", - spinner="earth", - ): - # Prepare unstaking transactions - unstake_operations = [] - total_received_amount = Balance.from_tao(0) - current_wallet_balance: Balance = await subtensor.get_balance( - wallet.coldkeypub.ss58_address - ) - max_float_slippage = 0 - - # 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 hotkeys_to_unstake_from: - console.print( - "[dark_sea_green3]Tip: Enter 'q' any time to skip further entries and process existing unstakes" - ) - - # Iterate over hotkeys and netuids to collect unstake operations - unstake_all_hk_ss58 = None - 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 - - if len(netuids_to_process) > 1: - console.print( - "[dark_sea_green3]Tip: Enter 'q' any time to stop going over remaining subnets and process current unstakes.\n" - ) - - for netuid in netuids_to_process: - if skip_remaining_subnets: - break # Exit the loop over netuids - - dynamic_info = all_sn_dynamic_info.get(netuid) - current_stake_balance = stake_in_netuids[staking_address_ss58][netuid] - if current_stake_balance.tao == 0: - continue # No stake to unstake - - # Determine the amount we are unstaking. - if unstake_all_from_hk or unstake_all: - amount_to_unstake_as_balance = current_stake_balance - unstake_all_hk_ss58 = staking_address_ss58 - elif 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]" - ) - continue # Skip to the next subnet - useful when single amount is specified for all subnets - - received_amount, _, slippage_pct_float = ( - dynamic_info.alpha_to_tao_with_slippage(amount_to_unstake_as_balance) - ) - total_received_amount += received_amount - if dynamic_info.is_dynamic: - slippage_pct = f"{slippage_pct_float:.4f} %" - else: - slippage_pct_float = 0 - slippage_pct = "[red]N/A[/red]" - max_float_slippage = max(max_float_slippage, slippage_pct_float) - - unstake_operations.append( - { - "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": dynamic_info, - } - ) - - if not unstake_operations: - console.print("[red]No unstake operations to perform.[/red]") - return False - - # Build the table - table = Table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Unstaking to: \nWallet: [{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(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=f"{total_received_amount}", - ) - table.add_column( - "Slippage", justify="center", style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"] - ) - - for op in unstake_operations: - dynamic_info = op["dynamic_info"] - table.add_row( - str(op["netuid"]), - op["hotkey_name"], - str(op["amount_to_unstake"]), - str(float(dynamic_info.price)) - + f"({Balance.get_unit(0)}/{Balance.get_unit(op['netuid'])})", - str(op["received_amount"]), - op["slippage_pct"], - ) - - 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" - ) - - console.print( - """ -[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[/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). -""" - ) - if prompt: - if not Confirm.ask("Would you like to continue?"): - raise typer.Exit() - - # Perform unstaking operations - 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 unstake_all_from_hk: - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="unstake_all", - call_params={ - "hotkey": unstake_all_hk_ss58, - }, - ) - 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 - ) - await response.process_events() - if not await response.is_success: - print_error( - f":cross_mark: [red]Failed[/red] with error: " - f"{format_error_message(await response.error_message, subtensor.substrate)}", - status, - ) - else: - new_balance = await subtensor.get_balance( - wallet.coldkeypub.ss58_address - ) - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - console.print( - f"Balance:\n [blue]{current_wallet_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" - ) - else: - for op in unstake_operations: - netuid_i = op["netuid"] - staking_address_name = op["hotkey_name"] - staking_address_ss58 = op["hotkey_ss58"] - amount = op["amount_to_unstake"] - current_stake_balance = op["current_stake_balance"] - - status.update( - f"\n:satellite: Unstaking {amount} from {staking_address_name} on netuid: {netuid_i} ..." - ) - - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="remove_stake", - call_params={ - "hotkey": staking_address_ss58, - "netuid": netuid_i, - "amount_unstaked": amount.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 - ) - await response.process_events() - if not await response.is_success: - print_error( - f":cross_mark: [red]Failed[/red] with error: " - f"{format_error_message(await response.error_message, subtensor.substrate)}", - status, - ) - else: - new_balance = await subtensor.get_balance( - wallet.coldkeypub.ss58_address - ) - new_stake_info = await subtensor.get_stake_for_coldkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, - ) - new_stake = Balance.from_rao(0) - for stake_info in new_stake_info: - if ( - stake_info.hotkey_ss58 == staking_address_ss58 - and stake_info.netuid == netuid_i - ): - new_stake = stake_info.stake.set_unit(netuid_i) - break - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - console.print( - f"Balance:\n [blue]{current_wallet_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 [blue]{current_stake_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" - ) - console.print( - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]Unstaking operations completed." - ) - - async def stake_list( wallet: Wallet, coldkey_ss58: str, From f5a30acc9568f21d13ba32487fe6975919c5c487 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Mon, 10 Feb 2025 17:15:15 -0800 Subject: [PATCH 07/11] updates names --- bittensor_cli/cli.py | 12 ++++++------ .../src/commands/stake/{stake.py => list.py} | 0 2 files changed, 6 insertions(+), 6 deletions(-) rename bittensor_cli/src/commands/stake/{stake.py => list.py} (100%) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index df427caa8..c030fb328 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -35,8 +35,8 @@ from bittensor_cli.src.commands.subnets import price, subnets from bittensor_cli.src.commands.stake import ( children_hotkeys, - stake, - move, + list as list_stake, + move as move_stake, add as add_stake, remove as remove_stake, ) @@ -2768,7 +2768,7 @@ def stake_list( ) return self._run_command( - stake.stake_list( + list_stake.stake_list( wallet, coldkey_ss58, self.initialize_chain(network), @@ -3344,7 +3344,7 @@ def stake_move( ) return self._run_command( - move.move_stake( + move_stake.move_stake( subtensor=self.initialize_chain(network), wallet=wallet, origin_netuid=origin_netuid, @@ -3462,7 +3462,7 @@ def stake_transfer( ) return self._run_command( - move.transfer_stake( + move_stake.transfer_stake( wallet=wallet, subtensor=self.initialize_chain(network), origin_netuid=origin_netuid, @@ -3561,7 +3561,7 @@ def stake_swap( amount = FloatPrompt.ask("Enter the [blue]amount[/blue] to swap") return self._run_command( - move.swap_stake( + move_stake.swap_stake( wallet=wallet, subtensor=self.initialize_chain(network), origin_netuid=origin_netuid, diff --git a/bittensor_cli/src/commands/stake/stake.py b/bittensor_cli/src/commands/stake/list.py similarity index 100% rename from bittensor_cli/src/commands/stake/stake.py rename to bittensor_cli/src/commands/stake/list.py From bad2f5000a678aad5a722ef5c39e5879e5201598 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Mon, 10 Feb 2025 18:51:00 -0800 Subject: [PATCH 08/11] Adds safe staking to remove_stake --- bittensor_cli/cli.py | 36 ++- bittensor_cli/src/commands/stake/remove.py | 243 ++++++++++++++++++--- 2 files changed, 241 insertions(+), 38 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index c030fb328..3c5bbde95 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -1371,7 +1371,12 @@ def ask_rate_tolerance( return config_slippage else: console.print( - f"[dim][blue]Rate tolerance[/blue]: [bold cyan]{defaults.rate_tolerance} ({defaults.rate_tolerance*100}%)[/bold cyan] by default. You can set this using `btcli config set`" + "[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 @@ -1402,7 +1407,12 @@ def ask_safe_staking( else: safe_staking = True console.print( - f"[dim][blue]Safe staking[/blue]: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] by default. You can set this using `btcli config set`" + "[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 @@ -1432,7 +1442,12 @@ def ask_partial_stake( return config_partial else: console.print( - f"[dim][blue]Partial staking[/blue]: [bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan] by default. You can set this using `btcli config set`" + "[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 @@ -3039,6 +3054,9 @@ def stake_remove( 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, @@ -3061,9 +3079,12 @@ def stake_remove( [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) - # # TODO: Coldkey related unstakes need to be updated. Patching for now. - # unstake_all_alpha = False - # unstake_all = False + 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] @@ -3202,6 +3223,9 @@ def stake_remove( prompt=prompt, interactive=interactive, netuid=netuid, + safe_staking=safe_staking, + rate_tolerance=rate_tolerance, + allow_partial_stake=allow_partial_stake, ) ) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 8dd49ad69..893bab256 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -9,6 +9,7 @@ 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 ( @@ -39,8 +40,11 @@ async def unstake( exclude_hotkeys: list[str], amount: float, prompt: bool, - interactive: bool = False, - netuid: Optional[int] = None, + interactive: bool, + netuid: Optional[int], + safe_staking: bool, + rate_tolerance: float, + allow_partial_stake: bool, ): """Unstake from hotkey(s).""" unstake_all_from_hk = False @@ -113,6 +117,7 @@ async def unstake( 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 @@ -179,8 +184,7 @@ async def unstake( total_received_amount += received_amount max_float_slippage = max(max_float_slippage, slippage_pct_float) - unstake_operations.append( - { + base_unstake_op = { "netuid": netuid, "hotkey_name": staking_address_name if staking_address_name @@ -192,8 +196,42 @@ async def unstake( "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]") @@ -204,25 +242,17 @@ async def unstake( 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) - for op in unstake_operations: - subnet_info = op["dynamic_info"] - table.add_row( - str(op["netuid"]), # Netuid - op["hotkey_name"], # Hotkey Name - str(op["amount_to_unstake"]), # Amount to Unstake - str(float(subnet_info.price)) - + f"({Balance.get_unit(0)}/{Balance.get_unit(op['netuid'])})", # Rate - str(op["received_amount"]), # Received Amount - op["slippage_pct"], # Slippage Percent - ) - - _print_table_and_slippage(table, max_float_slippage) + _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() @@ -233,20 +263,33 @@ async def unstake( with console.status("\n:satellite: Performing unstaking operations...") as status: if unstake_all_from_hk: await _unstake_all_extrinsic( - hotkey_ss58=unstake_all_hk_ss58, wallet=wallet, subtensor=subtensor, + hotkey_ss58=unstake_all_hk_ss58, status=status, ) - else: + elif safe_staking: for op in unstake_operations: - await _unstake_extrinsic( + 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( @@ -444,12 +487,12 @@ async def unstake_all( # Extrinsics async def _unstake_extrinsic( + wallet: Wallet, + subtensor: "SubtensorInterface", netuid: int, amount: Balance, current_stake: Balance, hotkey_ss58: str, - wallet: Wallet, - subtensor: "SubtensorInterface", status=None, ) -> None: """Execute a standard unstake extrinsic. @@ -526,9 +569,9 @@ async def _unstake_extrinsic( async def _unstake_all_extrinsic( - hotkey_ss58: str, wallet: Wallet, subtensor: "SubtensorInterface", + hotkey_ss58: str, status=None, ) -> None: """Execute an unstake_all extrinsic. @@ -574,6 +617,125 @@ async def _unstake_all_extrinsic( print_error(f":cross_mark: [red]Failed[/red] with error: {str(e)}", status) +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, subtensor.substrate)}" + ) + 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. @@ -902,6 +1064,8 @@ def _create_unstake_table( wallet_coldkey_ss58: str, network: str, total_received_amount: Balance, + safe_staking: bool, + rate_tolerance: float, ) -> Table: """Create a table summarizing unstake operations. @@ -955,6 +1119,17 @@ def _create_unstake_table( 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 @@ -962,6 +1137,7 @@ def _create_unstake_table( def _print_table_and_slippage( table: Table, max_float_slippage: float, + safe_staking: bool, ) -> None: """Print the unstake summary table and additional information. @@ -979,16 +1155,19 @@ def _print_table_and_slippage( " this may result in a loss of funds.\n" f"-------------------------------------------------------------------------------------------------------------------\n" ) - console.print( - """ + 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[/bold white]: The stake amount you are removing from this key. + - [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). -""" - ) + - [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.""" + + console.print(base_description + (safe_staking_description if safe_staking else "")) From 69a6ca90c52d9fb213499a5f99b6416fb1468653 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Mon, 10 Feb 2025 21:09:53 -0800 Subject: [PATCH 09/11] Fixes unstaking with safe staking --- bittensor_cli/cli.py | 88 ++-- bittensor_cli/src/commands/stake/add.py | 454 ++++++++++----------- bittensor_cli/src/commands/stake/remove.py | 119 ++---- 3 files changed, 331 insertions(+), 330 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 3c5bbde95..857792495 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -3160,6 +3160,52 @@ def stake_remove( validate=WV.WALLET_AND_HOTKEY, ) + 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 @@ -3201,33 +3247,23 @@ def stake_remove( else: excluded_hotkeys = [] - if unstake_all or unstake_all_alpha: - return self._run_command( - remove_stake.unstake_all( - wallet=wallet, - subtensor=self.initialize_chain(network), - unstake_all_alpha=unstake_all_alpha, - prompt=prompt, - ) - ) - else: - return self._run_command( - remove_stake.unstake( - wallet=wallet, - subtensor=self.initialize_chain(network), - hotkey_ss58_address=hotkey_ss58_address, - all_hotkeys=all_hotkeys, - include_hotkeys=included_hotkeys, - exclude_hotkeys=excluded_hotkeys, - amount=amount, - prompt=prompt, - interactive=interactive, - netuid=netuid, - safe_staking=safe_staking, - rate_tolerance=rate_tolerance, - allow_partial_stake=allow_partial_stake, - ) + return self._run_command( + remove_stake.unstake( + wallet=wallet, + subtensor=self.initialize_chain(network), + hotkey_ss58_address=hotkey_ss58_address, + all_hotkeys=all_hotkeys, + include_hotkeys=included_hotkeys, + exclude_hotkeys=excluded_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_move( self, diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index f2067cbbd..f6f161f0e 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -25,233 +25,6 @@ from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -# 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.""" - - 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 - - # Command async def stake_add( wallet: Wallet, @@ -623,3 +396,230 @@ async def stake_extrinsic( # 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.""" + + 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/remove.py b/bittensor_cli/src/commands/stake/remove.py index 893bab256..dcce01bc2 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -68,6 +68,20 @@ async def unstake( 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 @@ -113,7 +127,6 @@ async def unstake( ) # Iterate over hotkeys and netuids to collect unstake operations - unstake_all_hk_ss58 = None unstake_operations = [] total_received_amount = Balance.from_tao(0) max_float_slippage = 0 @@ -150,10 +163,7 @@ async def unstake( continue # No stake to unstake # Determine the amount we are unstaking. - if unstake_all_from_hk: - amount_to_unstake_as_balance = current_stake_balance - unstake_all_hk_ss58 = staking_address_ss58 - elif initial_amount: + if initial_amount: amount_to_unstake_as_balance = Balance.from_tao(initial_amount) else: amount_to_unstake_as_balance = _ask_unstake_amount( @@ -185,17 +195,17 @@ async def unstake( 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, + "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 = [ @@ -221,7 +231,7 @@ async def unstake( else: rate_with_tolerance = 1 price_with_tolerance = 1 - + base_unstake_op["price_with_tolerance"] = price_with_tolerance base_table_row.extend( [ @@ -261,14 +271,7 @@ async def unstake( return False with console.status("\n:satellite: Performing unstaking operations...") as status: - if unstake_all_from_hk: - await _unstake_all_extrinsic( - wallet=wallet, - subtensor=subtensor, - hotkey_ss58=unstake_all_hk_ss58, - status=status, - ) - elif safe_staking: + if safe_staking: for op in unstake_operations: await _safe_unstake_extrinsic( wallet=wallet, @@ -300,6 +303,7 @@ async def unstake( async def unstake_all( wallet: Wallet, subtensor: "SubtensorInterface", + hotkey_ss58_address: str, unstake_all_alpha: bool = False, prompt: bool = True, ) -> bool: @@ -322,6 +326,11 @@ async def unstake_all( 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] @@ -443,12 +452,17 @@ async def unstake_all( 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": wallet.hotkey.ss58_address}, + call_params={"hotkey": hotkey_ss58_address}, ) success, error_message = await subtensor.sign_and_send_extrinsic( call=call, @@ -470,12 +484,12 @@ async def unstake_all( ) if unstake_all_alpha: root_stake = await subtensor.get_stake( - hotkey_ss58=wallet.hotkey.ss58_address, + hotkey_ss58=hotkey_ss58_address, coldkey_ss58=wallet.coldkeypub.ss58_address, netuid=0, ) console.print( - f"Root Stake:\n [blue]{current_wallet_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{root_stake}" + f"Root Stake:\n [blue]{previous_root_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{root_stake}" ) return True else: @@ -568,55 +582,6 @@ async def _unstake_extrinsic( err_out(f"{failure_prelude} with error: {str(e)}") -async def _unstake_all_extrinsic( - wallet: Wallet, - subtensor: "SubtensorInterface", - hotkey_ss58: str, - status=None, -) -> None: - """Execute an unstake_all extrinsic. - - Args: - hotkey_ss58: Hotkey SS58 address - wallet: Wallet instance - subtensor: Subtensor interface - status: Optional status for console updates - """ - current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) - - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="unstake_all", - call_params={"hotkey": hotkey_ss58}, - ) - 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: - print_error( - f":cross_mark: [red]Failed[/red] with error: " - f"{format_error_message(await response.error_message, subtensor.substrate)}", - status, - ) - return - - new_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) - 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}" - ) - - except Exception as e: - print_error(f":cross_mark: [red]Failed[/red] with error: {str(e)}", status) - - async def _safe_unstake_extrinsic( wallet: Wallet, subtensor: "SubtensorInterface", From d61df2a243dd44781a523264a741fe71a0c35947 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Mon, 10 Feb 2025 21:26:33 -0800 Subject: [PATCH 10/11] Updates root unstake --- bittensor_cli/src/commands/stake/remove.py | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index dcce01bc2..cbede0c35 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -273,17 +273,28 @@ async def unstake( with console.status("\n:satellite: Performing unstaking operations...") as status: if safe_staking: for op in unstake_operations: - 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, - ) + 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( From ba37e0ec9d9aabe63ea8461936bd1d1e0d5555e2 Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Tue, 11 Feb 2025 09:16:48 -0800 Subject: [PATCH 11/11] cleanup --- bittensor_cli/src/commands/stake/list.py | 1 - bittensor_cli/src/commands/stake/remove.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index fb8dfed14..9f2ba7763 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -15,7 +15,6 @@ from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.chain_data import StakeInfo from bittensor_cli.src.bittensor.utils import ( - # TODO add back in caching console, print_error, millify_tao, diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index cbede0c35..80d77a6ab 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -13,7 +13,6 @@ from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.utils import ( - # TODO add back in caching console, err_console, print_verbose, @@ -22,8 +21,6 @@ is_valid_ss58_address, format_error_message, group_subnets, - millify_tao, - get_subnet_name, ) if TYPE_CHECKING: