diff --git a/CHANGELOG.md b/CHANGELOG.md index 2447096f0..bb37bfc03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 9.16.0 /2025-12-09 + +## What's Changed +* proxy help text by @chideraao in https://github.com/opentensor/btcli/pull/707 +* In `btcli st list`, display the total even with the `--verbose` flag by @thewhaleking in https://github.com/opentensor/btcli/pull/717 +* Make `btcli st wizard` by @OxLeOx in https://github.com/opentensor/btcli/pull/720 +* Feat/Root claim update for netuids by @ibraheem-abe in https://github.com/opentensor/btcli/pull/722 +* Improve e2e tests workflow by @basfroman in https://github.com/opentensor/btcli/pull/728 +* Feat: MeV protection in staking operations by @ibraheem-abe in https://github.com/opentensor/btcli/pull/724 +* proxy app by @thewhaleking in https://github.com/opentensor/btcli/pull/706 +* Fixes: updated mev shield by @thewhaleking in https://github.com/opentensor/btcli/pull/731 + +## New Contributors +* @chideraao made their first contribution in https://github.com/opentensor/btcli/pull/707 +* @OxLeOx made their first contribution in https://github.com/opentensor/btcli/pull/720 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.15.3...v9.16.0 + ## 9.15.3 /2025-11-17 ## What's Changed diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 73e77736d..f2ffcfbb2 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -2,6 +2,7 @@ import asyncio import copy import curses +import datetime import importlib import json import logging @@ -13,7 +14,7 @@ import warnings from dataclasses import fields from pathlib import Path -from typing import Coroutine, Optional, Union, Literal +from typing import Coroutine, Optional, Union import numpy as np import rich @@ -24,6 +25,9 @@ InvalidHandshake, ) from bittensor_wallet import Wallet +from bittensor_wallet.utils import ( + is_valid_ss58_address as btwallet_is_valid_ss58_address, +) from rich import box from rich.prompt import Confirm, FloatPrompt, Prompt, IntPrompt from rich.table import Column, Table @@ -64,6 +68,9 @@ prompt_for_subnet_identity, validate_rate_tolerance, get_hotkey_pub_ss58, + ensure_address_book_tables_exist, + ProxyAddressBook, + ProxyAnnouncements, ) from bittensor_cli.src.commands import sudo, wallets, view from bittensor_cli.src.commands import weights as weights_cmds @@ -80,6 +87,8 @@ prompt_liquidity, prompt_position_id, ) +from bittensor_cli.src.commands import proxy as proxy_commands +from bittensor_cli.src.commands.proxy import ProxyType from bittensor_cli.src.commands.stake import ( auto_staking as auto_stake, children_hotkeys, @@ -88,13 +97,13 @@ add as add_stake, remove as remove_stake, claim as claim_stake, + wizard as stake_wizard, ) from bittensor_cli.src.commands.subnets import ( price, subnets, mechanisms as subnet_mechanisms, ) -from bittensor_cli.src.commands.wallets import SortByBalance from bittensor_cli.version import __version__, __version_as_int__ try: @@ -119,6 +128,27 @@ def arg__(arg_name: str) -> str: return f"[{COLORS.G.ARG}]{arg_name}[/{COLORS.G.ARG}]" +def is_valid_ss58_address_param(address: Optional[str]) -> Optional[str]: + """ + Evaluates whether a non-None address is a valid SS58 address. Used as a callback for + Annotated typer params. + + Args: + address: an SS58 address, or None + + Returns: + the SS58 address (if valid) or None (if None) + + Raises: + typer.BadParameter: if the address is not a valid SS58 address + """ + if address is None: + return None + elif not btwallet_is_valid_ss58_address(address): + raise typer.BadParameter(f"Invalid SS58 address: {address}") + return address + + class Options: """ Re-usable typer args @@ -345,6 +375,12 @@ def edit_help(cls, option_name: str, help_text: str): "--dashboard.path", help="Path to save the dashboard HTML file. For example: `~/.bittensor/dashboard`.", ) + mev_protection = typer.Option( + True, + "--mev-protection/--no-mev-protection", + show_default=False, + help="Enable or disable MEV protection [dim](default: enabled)[/dim].", + ) json_output = typer.Option( False, "--json-output", @@ -357,6 +393,28 @@ def edit_help(cls, option_name: str, help_text: str): "--era", help="Length (in blocks) for which the transaction should be valid.", ) + proxy_type: ProxyType = typer.Option( + ProxyType.Any.value, + "--proxy-type", + help="Type of proxy", + prompt=True, + ) + proxy: Optional[str] = typer.Option( + None, + "--proxy", + help="Optional proxy to use for the transaction: either the SS58 or the name of the proxy if you " + f"have added it with {arg__('btcli config add-proxy')}.", + ) + real_proxy: Optional[str] = typer.Option( + None, + "--real", + help="The real account making this call. If omitted, the proxy's ss58 is used.", + ) + announce_only: bool = typer.Option( + False, + help=f"If set along with [{COLORS.G.ARG}]--proxy[/{COLORS.G.ARG}], will not actually make the extrinsic call, " + f"but rather just announce it to be made later.", + ) def list_prompt(init_var: list, list_type: type, help_text: str) -> list: @@ -727,6 +785,7 @@ def __init__(self): # "COLDKEY": True, # }, } + self.proxies = {} self.subtensor = None if sys.version_info < (3, 10): @@ -746,6 +805,9 @@ def __init__(self): self.debug_file_path = os.getenv("BTCLI_DEBUG_FILE") or os.path.expanduser( defaults.config.debug_file_path ) + self.proxies_path = os.getenv("BTCLI_PROXIES_PATH") or os.path.expanduser( + defaults.proxies.path + ) self.app = typer.Typer( rich_markup_mode="rich", @@ -769,6 +831,7 @@ def __init__(self): self.liquidity_app = typer.Typer(epilog=_epilog) self.crowd_app = typer.Typer(epilog=_epilog) self.utils_app = typer.Typer(epilog=_epilog) + self.proxy_app = typer.Typer(epilog=_epilog) # config alias self.app.add_typer( @@ -867,10 +930,22 @@ def __init__(self): no_args_is_help=True, ) + # proxy app + self.app.add_typer( + self.proxy_app, + name="proxy", + short_help="Proxy commands", + no_args_is_help=True, + ) + # config commands self.config_app.command("set")(self.set_config) self.config_app.command("get")(self.get_config) self.config_app.command("clear")(self.del_config) + self.config_app.command("add-proxy")(self.config_add_proxy) + self.config_app.command("proxies")(self.config_get_proxies) + self.config_app.command("remove-proxy")(self.config_remove_proxy) + self.config_app.command("update-proxy")(self.config_update_proxy) # self.config_app.command("metagraph", hidden=True)(self.metagraph_config) # wallet commands @@ -971,6 +1046,9 @@ def __init__(self): self.stake_app.command( "swap", rich_help_panel=HELP_PANELS["STAKE"]["MOVEMENT"] )(self.stake_swap) + self.stake_app.command( + "wizard", rich_help_panel=HELP_PANELS["STAKE"]["MOVEMENT"] + )(self.stake_wizard) self.stake_app.command( "set-claim", rich_help_panel=HELP_PANELS["STAKE"]["CLAIM"] )(self.stake_set_claim_type) @@ -1092,6 +1170,24 @@ def __init__(self): "dashboard", rich_help_panel=HELP_PANELS["VIEW"]["DASHBOARD"] )(self.view_dashboard) + # proxy commands + self.proxy_app.command("create", rich_help_panel=HELP_PANELS["PROXY"]["MGMT"])( + self.proxy_create + ) + self.proxy_app.command("add", rich_help_panel=HELP_PANELS["PROXY"]["MGMT"])( + self.proxy_add + ) + self.proxy_app.command("remove", rich_help_panel=HELP_PANELS["PROXY"]["MGMT"])( + self.proxy_remove + ) + self.proxy_app.command("kill", rich_help_panel=HELP_PANELS["PROXY"]["MGMT"])( + self.proxy_kill + ) + self.proxy_app.command( + "execute", + rich_help_panel=HELP_PANELS["PROXY"]["MGMT"], + )(self.proxy_execute_announced) + # Sub command aliases # Wallet self.wallet_app.command( @@ -1395,7 +1491,7 @@ def main_callback( # Load or create the config file if os.path.exists(self.config_path): with open(self.config_path, "r") as f: - config = safe_load(f) + config = safe_load(f) or {} else: directory_path = Path(self.config_base_path) directory_path.mkdir(exist_ok=True, parents=True) @@ -1446,9 +1542,27 @@ def main_callback( asi_logger.addHandler(handler) logger.addHandler(handler) + ensure_address_book_tables_exist() + # load proxies address book + with ProxyAddressBook.get_db() as (conn, cursor): + rows = ProxyAddressBook.read_rows(conn, cursor, include_header=False) + proxies = {} + for name, ss58_address, delay, spawner, proxy_type, _ in rows: + proxies[name] = { + "address": ss58_address, + "spawner": spawner, + "proxy_type": proxy_type, + "delay": delay, + } + self.proxies = proxies + def verbosity_handler( - self, quiet: bool, verbose: bool, json_output: bool = False + self, quiet: bool, verbose: bool, json_output: bool = False, prompt: bool = True ) -> None: + if json_output and prompt: + raise typer.BadParameter( + f"Cannot specify both '--json-output' and '--prompt'" + ) if quiet and verbose: err_console.print("Cannot specify both `--quiet` and `--verbose`") raise typer.Exit() @@ -1752,7 +1866,7 @@ def get_config(self): box=box.SIMPLE_HEAD, title=f"[{COLORS.G.HEADER}]BTCLI Config[/{COLORS.G.HEADER}]: {arg__(self.config_path)}", ) - + value: Optional[str] for key, value in self.config.items(): if key == "network": if value is None: @@ -1775,6 +1889,210 @@ def get_config(self): console.print(table) + def config_add_proxy( + self, + name: Annotated[ + str, + typer.Option( + help="Name of the proxy", prompt="Enter a name for this proxy" + ), + ], + address: Annotated[ + str, + typer.Option( + callback=is_valid_ss58_address_param, + help="The SS58 address of the pure proxy/delegatee", + prompt="Enter the SS58 address of the pure proxy/delegatee", + ), + ], + proxy_type: Annotated[ + ProxyType, + typer.Option( + help="The type of this proxy", + prompt="Enter the type of this proxy", + ), + ], + spawner: Annotated[ + str, + typer.Option( + "--spawner", + "--delegator", + callback=is_valid_ss58_address_param, + help="The SS58 address of the spawner (pure proxy)/delegator (regular proxy)", + prompt="Enter the SS58 address of the spawner (pure proxy)/delegator (regular proxy)", + ), + ], + delay: int = typer.Option(0, help="Delay, in blocks."), + note: str = typer.Option("", help="Any notes about this entry"), + ): + """ + Adds a new pure proxy to the address book. + """ + if self.proxies.get(name) is not None: + err_console.print( + f"Proxy {name} already exists. Use `btcli config update-proxy` to update it." + ) + raise typer.Exit() + proxy_type_val: str + if isinstance(proxy_type, ProxyType): + proxy_type_val = proxy_type.value + else: + proxy_type_val = proxy_type + + self.proxies[name] = { + "proxy_type": proxy_type_val, + "address": address, + "spawner": spawner, + "delay": delay, + "note": note, + } + with ProxyAddressBook.get_db() as (conn, cursor): + ProxyAddressBook.add_entry( + conn, + cursor, + name=name, + ss58_address=address, + spawner=spawner, + proxy_type=proxy_type_val, + delay=delay, + note=note, + ) + self.config_get_proxies() + + def config_remove_proxy( + self, + name: Annotated[ + str, + typer.Option( + help="Name of the proxy to be removed", + prompt="Enter the name of the proxy to be removed", + ), + ], + ): + """ + Removes a pure proxy from the address book. + + Note: Does not remove the proxy on chain. Only removes it from the address book. + """ + if name in self.proxies: + del self.proxies[name] + with ProxyAddressBook.get_db() as (conn, cursor): + ProxyAddressBook.delete_entry(conn, cursor, name=name) + console.print(f"Removed {name} from the address book.") + else: + err_console.print(f"Proxy {name} not found in address book.") + self.config_get_proxies() + + def config_get_proxies(self): + """ + Displays the current proxies address book + """ + table = Table( + Column("[bold white]Name", style=f"{COLORS.G.ARG}"), + Column("Address", style="gold1"), + Column("Spawner/Delegator", style="medium_purple"), + Column("Proxy Type", style="medium_purple"), + Column("Delay", style="dim"), + Column("Note", style="dim"), + box=box.SIMPLE_HEAD, + title=f"[{COLORS.G.HEADER}]BTCLI Proxies Address Book[/{COLORS.G.HEADER}]: {arg__(self.proxies_path)}", + ) + with ProxyAddressBook.get_db() as (conn, cursor): + rows = ProxyAddressBook.read_rows(conn, cursor, include_header=False) + for name, ss58_address, delay, spawner, proxy_type, note in rows: + table.add_row(name, ss58_address, spawner, proxy_type, str(delay), note) + console.print(table) + + def config_update_proxy( + self, + name: Annotated[ + str, + typer.Option( + help="Name of the proxy", prompt="Enter a name for this proxy" + ), + ], + address: Annotated[ + Optional[str], + typer.Option( + callback=is_valid_ss58_address_param, + help="The SS58 address of the pure proxy", + ), + ] = None, + proxy_type: Annotated[ + Optional[ProxyType], + typer.Option( + help="The type of this pure proxy", + ), + ] = None, + spawner: Annotated[ + str, + typer.Option( + callback=is_valid_ss58_address_param, + help="The SS58 address of the spawner", + ), + ] = None, + delay: Optional[int] = typer.Option(None, help="Delay, in blocks."), + note: Optional[str] = typer.Option(None, help="Any notes about this entry"), + ): + if name not in self.proxies: + err_console.print(f"Proxy {name} not found in address book.") + return + else: + if isinstance(proxy_type, ProxyType): + proxy_type_val = proxy_type.value + else: + proxy_type_val = proxy_type + with ProxyAddressBook.get_db() as (conn, cursor): + ProxyAddressBook.update_entry( + conn, + cursor, + name=name, + ss58_address=address, + proxy_type=proxy_type_val, + spawner=spawner, + note=note, + delay=delay, + ) + console.print("Proxy updated") + self.config_get_proxies() + + def is_valid_proxy_name_or_ss58( + self, address: Optional[str], announce_only: bool + ) -> Optional[str]: + """ + Evaluates whether a non-None address is a valid SS58 address. Used as a callback for + Annotated typer params. + + Args: + address: an SS58 address, proxy name in config, or None + announce_only: whether the call should be made as just an announcement or the actual call made + + Returns: + the SS58 address (if valid) or None (if None) + + Raises: + typer.BadParameter: if the address is not a valid SS58 address, or if `--announce-only` is supplied but + without a proxy. + """ + if address is None: + if announce_only is True: + raise typer.BadParameter( + f"Cannot supply '--announce-only' without supplying '--proxy'" + ) + return None + outer_proxy_from_config = self.proxies.get(address, {}) + proxy_from_config = outer_proxy_from_config.get("address") + if proxy_from_config is not None: + if not btwallet_is_valid_ss58_address(proxy_from_config): + raise typer.BadParameter( + f"Invalid SS58 address: {proxy_from_config} from config {address}" + ) + else: + return proxy_from_config + elif not btwallet_is_valid_ss58_address(address): + raise typer.BadParameter(f"Invalid SS58 address: {address}") + return address + def ask_rate_tolerance( self, rate_tolerance: Optional[float], @@ -1892,8 +2210,8 @@ def ask_partial_stake( logger.debug(f"Partial staking {partial_staking}") return False + @staticmethod def ask_subnet_mechanism( - self, mechanism_id: Optional[int], mechanism_count: int, netuid: int, @@ -2040,6 +2358,8 @@ def wallet_ask( else: return wallet + # Wallet + def wallet_list( self, wallet_name: Optional[str] = Options.wallet_name, @@ -2062,7 +2382,7 @@ def wallet_list( [bold]NOTE[/bold]: This command is read-only and does not modify the filesystem or the blockchain state. It is intended for use with the Bittensor CLI to provide a quick overview of the user's wallets. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) wallet = self.wallet_ask( None, wallet_path, None, ask_for=[WO.PATH], validate=WV.NONE ) @@ -2129,7 +2449,7 @@ def wallet_overview( It provides a quick and comprehensive view of the user's network presence, making it useful for monitoring account status, stake distribution, and overall contribution to the Bittensor network. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if include_hotkeys and exclude_hotkeys: utils.err_console.print( "[red]You have specified both the inclusion and exclusion options. Only one of these options is allowed currently." @@ -2213,6 +2533,8 @@ def wallet_transfer( help="Transfer balance even if the resulting balance falls below the existential deposit.", ), period: int = Options.period, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, @@ -2244,7 +2566,8 @@ def wallet_transfer( print_error("You have entered an incorrect ss58 address. Please try again.") raise typer.Exit() - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -2280,6 +2603,8 @@ def wallet_transfer( era=period, prompt=prompt, json_output=json_output, + proxy=proxy, + announce_only=announce_only, ) ) @@ -2298,6 +2623,8 @@ def wallet_swap_hotkey( verbose: bool = Options.verbose, prompt: bool = Options.prompt, json_output: bool = Options.json_output, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, ): """ Swap hotkeys of a given wallet on the blockchain. For a registered key pair, for example, a (coldkeyA, hotkeyA) pair, this command swaps the hotkeyA with a new, unregistered, hotkeyB to move the original registration to the (coldkeyA, hotkeyB) pair. @@ -2324,7 +2651,8 @@ def wallet_swap_hotkey( [green]$[/green] btcli wallet swap_hotkey destination_hotkey_name --wallet-name your_wallet_name --wallet-hotkey original_hotkey --netuid 1 """ netuid = get_optional_netuid(netuid, all_netuids) - self.verbosity_handler(quiet, verbose, json_output) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + self.verbosity_handler(quiet, verbose, json_output, prompt) # Warning for netuid 0 - only swaps on root network, not a full swap if netuid == 0 and prompt: @@ -2377,7 +2705,13 @@ def wallet_swap_hotkey( self.initialize_chain(network) return self._run_command( wallets.swap_hotkey( - original_wallet, new_wallet, self.subtensor, netuid, prompt, json_output + original_wallet=original_wallet, + new_wallet=new_wallet, + subtensor=self.subtensor, + netuid=netuid, + proxy=proxy, + prompt=prompt, + json_output=json_output, ) ) @@ -2432,7 +2766,7 @@ def wallet_inspect( """ print_error("This command is disabled on the 'rao' network.") raise typer.Exit() - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if netuids: netuids = parse_to_list( @@ -2595,7 +2929,7 @@ def wallet_regen_coldkey( [bold]Note[/bold]: This command is critical for users who need to regenerate their coldkey either for recovery or for security reasons. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not wallet_path: wallet_path = Prompt.ask( @@ -2656,7 +2990,7 @@ def wallet_regen_coldkey_pub( [bold]Note[/bold]: This command is particularly useful for users who need to regenerate their coldkeypub, perhaps due to file corruption or loss. You will need either ss58 address or public hex key from your old coldkeypub.txt for the wallet. It is a recovery-focused utility that ensures continued access to your wallet functionalities. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not wallet_path: wallet_path = Prompt.ask( @@ -2727,7 +3061,7 @@ def wallet_regen_hotkey( [bold]Note[/bold]: This command is essential for users who need to regenerate their hotkey, possibly for security upgrades or key recovery. It should be used with caution to avoid accidental overwriting of existing keys. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -2779,7 +3113,7 @@ def wallet_regen_hotkey_pub( [bold]Note[/bold]: This command is particularly useful for users who need to regenerate their hotkeypub, perhaps due to file corruption or loss. You will need either ss58 address or public hex key from your old hotkeypub.txt for the wallet. It is a recovery-focused utility that ensures continued access to your wallet functionalities. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not wallet_path: wallet_path = Prompt.ask( @@ -2850,7 +3184,7 @@ def wallet_new_hotkey( [italic]Note[/italic]: This command is useful to create additional hotkeys for different purposes, such as running multiple subnet miners or subnet validators or separating operational roles within the Bittensor network. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not wallet_name: wallet_name = Prompt.ask( @@ -2886,6 +3220,8 @@ def wallet_associate_hotkey( wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hotkey_ss58, network: Optional[list[str]] = Options.network, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -2902,7 +3238,8 @@ def wallet_associate_hotkey( [green]$[/green] btcli wallet associate-hotkey --hotkey-name hotkey_name [green]$[/green] btcli wallet associate-hotkey --hotkey-ss58 5DkQ4... """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output=False, prompt=prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if not wallet_name: wallet_name = Prompt.ask( "Enter the [blue]wallet name[/blue] [dim](which you want to associate with the hotkey)[/dim]", @@ -2954,6 +3291,7 @@ def wallet_associate_hotkey( hotkey_ss58, hotkey_display, prompt, + proxy=proxy, ) ) @@ -2988,7 +3326,7 @@ def wallet_new_coldkey( [bold]Note[/bold]: This command is crucial for users who need to create a new coldkey for enhanced security or as part of setting up a new wallet. It is a foundational step in establishing a secure presence on the Bittensor network. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not wallet_path: wallet_path = Prompt.ask( @@ -3062,7 +3400,7 @@ def wallet_check_ck_swap( [green]$[/green] btcli wallet swap-check --wallet-name my_wallet --block 12345 """ # TODO add json_output if this ever gets used again (doubtful) - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output=False, prompt=False) self.initialize_chain(network) if show_all: @@ -3140,7 +3478,7 @@ def wallet_create_wallet( [bold]Note[/bold]: This command is for new users setting up their wallet for the first time, or for those who wish to completely renew their wallet keys. It ensures a fresh start with new keys for secure and effective participation in the Bittensor network. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not wallet_path: wallet_path = Prompt.ask( "Enter the path of wallets directory", @@ -3219,7 +3557,7 @@ def wallet_balance( [green]$[/green] btcli w balance --ss58 --ss58 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) wallet = None if all_balances: ask_for = [WO.PATH] @@ -3325,7 +3663,7 @@ def wallet_history( print_error("This command is disabled on the 'rao' network.") raise typer.Exit() - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, False, False) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -3378,6 +3716,8 @@ def wallet_set_id( "--github", help="The GitHub repository for the identity.", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, quiet: bool = Options.quiet, verbose: bool = Options.verbose, prompt: bool = Options.prompt, @@ -3400,7 +3740,8 @@ def wallet_set_id( [bold]Note[/bold]: This command should only be used if the user is willing to incur the a recycle fee associated with setting an identity on the blockchain. It is a high-level command that makes changes to the blockchain state and should not be used programmatically as part of other scripts or applications. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -3440,17 +3781,17 @@ def wallet_set_id( return self._run_command( wallets.set_id( - wallet, - self.initialize_chain(network), - identity["name"], - identity["url"], - identity["image"], - identity["discord"], - identity["description"], - identity["additional"], - identity["github_repo"], - prompt, - json_output, + wallet=wallet, + subtensor=self.initialize_chain(network), + name=identity["name"], + web_url=identity["url"], + image_url=identity["image"], + discord=identity["discord"], + description=identity["description"], + additional=identity["additional"], + github_repo=identity["github_repo"], + json_output=json_output, + proxy=proxy, ) ) @@ -3491,7 +3832,7 @@ def wallet_get_id( [bold]Note[/bold]: This command is primarily used for informational purposes and has no side effects on the blockchain network state. """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not wallet_name: if coldkey_ss58: if not is_valid_ss58_address(coldkey_ss58): @@ -3517,7 +3858,11 @@ def wallet_get_id( coldkey_ss58 = wallet.coldkeypub.ss58_address return self._run_command( - wallets.get_id(self.initialize_chain(network), coldkey_ss58, json_output) + wallets.get_id( + subtensor=self.initialize_chain(network), + ss58_address=coldkey_ss58, + json_output=json_output, + ) ) def wallet_sign( @@ -3549,7 +3894,7 @@ def wallet_sign( [green]$[/green] btcli wallet sign --wallet-name default --wallet-hotkey hotkey --message '{"something": "here", "timestamp": 1719908486}' """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if use_hotkey is None: use_hotkey = Confirm.ask( f"Would you like to sign the transaction using your [{COLORS.G.HK}]hotkey[/{COLORS.G.HK}]?" @@ -3606,7 +3951,7 @@ def wallet_verify( [green]$[/green] btcli wallet verify -m "Test message" -s "0xdef456..." -p "0x1234abcd..." """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) if not public_key_or_ss58: public_key_or_ss58 = Prompt.ask( @@ -3637,6 +3982,8 @@ def wallet_swap_coldkey( help="SS58 address of the new coldkey that will replace the current one.", ), network: Optional[list[str]] = Options.network, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, quiet: bool = Options.quiet, verbose: bool = Options.verbose, force_swap: bool = typer.Option( @@ -3658,7 +4005,8 @@ def wallet_swap_coldkey( [green]$[/green] btcli wallet schedule-coldkey-swap --new-coldkey-ss58 5Dk...X3q """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, prompt=False, json_output=False) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if not wallet_name: wallet_name = Prompt.ask( @@ -3710,9 +4058,12 @@ def wallet_swap_coldkey( subtensor=self.initialize_chain(network), new_coldkey_ss58=new_wallet_coldkey_ss58, force_swap=force_swap, + proxy=proxy, ) ) + # Stake + def get_auto_stake( self, network: Optional[list[str]] = Options.network, @@ -3732,7 +4083,7 @@ def get_auto_stake( ): """Display auto-stake destinations for a wallet across all subnets.""" - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) wallet = None if coldkey_ss58: @@ -3774,6 +4125,8 @@ def set_auto_stake( wallet_name: Optional[str] = Options.wallet_name, wallet_path: Optional[str] = Options.wallet_path, netuid: Optional[int] = Options.netuid_not_req, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, quiet: bool = Options.quiet, verbose: bool = Options.verbose, prompt: bool = Options.prompt, @@ -3783,7 +4136,8 @@ def set_auto_stake( ): """Set the auto-stake destination hotkey for a coldkey.""" - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, @@ -3836,6 +4190,7 @@ def set_auto_stake( self.initialize_chain(network), netuid, hotkey_ss58, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, prompt_user=prompt, @@ -3883,7 +4238,7 @@ def stake_list( 4. Verbose output with full values: [green]$[/green] btcli stake list --wallet.name my_wallet --verbose """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) wallet = None if coldkey_ss58: @@ -3964,10 +4319,13 @@ def stake_add( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, network: Optional[list[str]] = Options.network, rate_tolerance: Optional[float] = Options.rate_tolerance, safe_staking: Optional[bool] = Options.safe_staking, allow_partial_stake: Optional[bool] = Options.allow_partial_stake, + mev_protection: bool = Options.mev_protection, period: int = Options.period, prompt: bool = Options.prompt, quiet: bool = Options.quiet, @@ -4002,6 +4360,9 @@ def stake_add( 7. Stake the same amount to multiple subnets: [green]$[/green] btcli stake add --amount 100 --netuids 4,5,6 + 8. Stake without MEV protection: + [green]$[/green] btcli stake add --amount 100 --netuid 1 --no-mev-protection + [bold]Safe Staking Parameters:[/bold] • [blue]--safe[/blue]: Enables rate tolerance checks • [blue]--tolerance[/blue]: Maximum % rate change allowed (0.05 = 5%) @@ -4009,7 +4370,8 @@ def stake_add( """ netuids = netuids or [] - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) safe_staking = self.ask_safe_staking(safe_staking) if safe_staking: rate_tolerance = self.ask_rate_tolerance(rate_tolerance) @@ -4197,23 +4559,26 @@ def stake_add( f"rate_tolerance: {rate_tolerance}\n" f"allow_partial_stake: {allow_partial_stake}\n" f"period: {period}\n" + f"mev_protection: {mev_protection}\n" ) return self._run_command( add_stake.stake_add( - wallet, - self.initialize_chain(network), - netuids, - stake_all, - amount, - prompt, - all_hotkeys, - include_hotkeys, - exclude_hotkeys, - safe_staking, - rate_tolerance, - allow_partial_stake, - json_output, - period, + wallet=wallet, + subtensor=self.initialize_chain(network), + netuids=netuids, + stake_all=stake_all, + amount=amount, + prompt=prompt, + all_hotkeys=all_hotkeys, + include_hotkeys=include_hotkeys, + exclude_hotkeys=exclude_hotkeys, + safe_staking=safe_staking, + rate_tolerance=rate_tolerance, + allow_partial_stake=allow_partial_stake, + json_output=json_output, + era=period, + proxy=proxy, + mev_protection=mev_protection, ) ) @@ -4262,10 +4627,13 @@ 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`.", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, rate_tolerance: Optional[float] = Options.rate_tolerance, safe_staking: Optional[bool] = Options.safe_staking, allow_partial_stake: Optional[bool] = Options.allow_partial_stake, period: int = Options.period, + mev_protection: bool = Options.mev_protection, prompt: bool = Options.prompt, interactive: bool = typer.Option( False, @@ -4302,12 +4670,16 @@ def stake_remove( 6. Unstake all Alpha from a hotkey and stake to Root: [green]$[/green] btcli stake remove --all-alpha + 7. Unstake without MEV protection: + [green]$[/green] btcli stake remove --amount 100 --netuid 1 --no-mev-protection + [bold]Safe Staking Parameters:[/bold] • [blue]--safe[/blue]: Enables rate tolerance checks during unstaking • [blue]--tolerance[/blue]: Max allowed rate change (0.05 = 5%) • [blue]--partial[/blue]: Complete partial unstake if rates exceed tolerance """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if not unstake_all and not unstake_all_alpha: safe_staking = self.ask_safe_staking(safe_staking) if safe_staking: @@ -4474,7 +4846,8 @@ def stake_remove( f"all_hotkeys: {all_hotkeys}\n" f"include_hotkeys: {include_hotkeys}\n" f"exclude_hotkeys: {exclude_hotkeys}\n" - f"era: {period}" + f"era: {period}\n" + f"mev_protection: {mev_protection}" ) return self._run_command( remove_stake.unstake_all( @@ -4488,6 +4861,8 @@ def stake_remove( prompt=prompt, json_output=json_output, era=period, + mev_protection=mev_protection, + proxy=proxy, ) ) elif ( @@ -4540,7 +4915,8 @@ def stake_remove( f"safe_staking: {safe_staking}\n" f"rate_tolerance: {rate_tolerance}\n" f"allow_partial_stake: {allow_partial_stake}\n" - f"era: {period}" + f"era: {period}\n" + f"mev_protection: {mev_protection}\n" ) return self._run_command( @@ -4560,6 +4936,8 @@ def stake_remove( allow_partial_stake=allow_partial_stake, json_output=json_output, era=period, + proxy=proxy, + mev_protection=mev_protection, ) ) @@ -4604,7 +4982,10 @@ def stake_move( stake_all: bool = typer.Option( False, "--stake-all", "--all", help="Stake all", prompt=False ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, period: int = Options.period, + mev_protection: bool = Options.mev_protection, prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -4626,11 +5007,16 @@ def stake_move( If no arguments are provided, an interactive selection menu will be shown. - EXAMPLE + EXAMPLES - [green]$[/green] btcli stake move + 1. Interactive move (guided prompts): + [green]$[/green] btcli stake move + + 2. Move stake without MEV protection: + [green]$[/green] btcli stake move --no-mev-protection """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if prompt: if not Confirm.ask( "This transaction will [bold]move stake[/bold] to another hotkey while keeping the same " @@ -4743,6 +5129,8 @@ def stake_move( f"era: {period}\n" f"interactive_selection: {interactive_selection}\n" f"prompt: {prompt}\n" + f"proxy: {proxy}\n" + f"mev_protection: {mev_protection}\n" ) result, ext_id = self._run_command( move_stake.move_stake( @@ -4757,6 +5145,8 @@ def stake_move( era=period, interactive_selection=interactive_selection, prompt=prompt, + proxy=proxy, + mev_protection=mev_protection, ) ) if json_output: @@ -4797,7 +5187,10 @@ def stake_transfer( stake_all: bool = typer.Option( False, "--stake-all", "--all", help="Stake all", prompt=False ), + mev_protection: bool = Options.mev_protection, period: int = Options.period, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -4834,8 +5227,12 @@ def stake_transfer( Transfer all available stake from origin hotkey: [green]$[/green] btcli stake transfer --all --origin-netuid 1 --dest-netuid 2 + + Transfer stake without MEV protection: + [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --amount 100 --no-mev-protection """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if prompt: if not Confirm.ask( "This transaction will [bold]transfer ownership[/bold] from one coldkey to another, in subnets " @@ -4929,7 +5326,9 @@ def stake_transfer( f"dest_coldkey_ss58: {dest_ss58}\n" f"amount: {amount}\n" f"era: {period}\n" - f"stake_all: {stake_all}" + f"stake_all: {stake_all}\n" + f"mev_protection: {mev_protection}" + f"proxy: {proxy}" ) result, ext_id = self._run_command( move_stake.transfer_stake( @@ -4944,6 +5343,8 @@ def stake_transfer( interactive_selection=interactive_selection, stake_all=stake_all, prompt=prompt, + proxy=proxy, + mev_protection=mev_protection, ) ) if json_output: @@ -4984,10 +5385,13 @@ def stake_swap( "--all", help="Swap all available stake", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, period: int = Options.period, prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, + mev_protection: bool = Options.mev_protection, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -5007,12 +5411,16 @@ def stake_swap( If no arguments are provided, an interactive selection menu will be shown. - EXAMPLE + EXAMPLES + + 1. Swap 100 TAO from subnet 1 to subnet 2: + [green]$[/green] btcli stake swap --wallet-name default --wallet-hotkey default --origin-netuid 1 --dest-netuid 2 --amount 100 - Swap 100 TAO from subnet 1 to subnet 2: - [green]$[/green] btcli stake swap --wallet-name default --wallet-hotkey default --origin-netuid 1 --dest-netuid 2 --amount 100 + 2. Swap stake without MEV protection: + [green]$[/green] btcli stake swap --origin-netuid 1 --dest-netuid 2 --amount 100 --no-mev-protection """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) console.print( "[dim]This command moves stake from one subnet to another subnet while keeping " "the same coldkey-hotkey pair.[/dim]" @@ -5048,10 +5456,12 @@ def stake_swap( f"amount: {amount}\n" f"swap_all: {swap_all}\n" f"era: {period}\n" + f"proxy: {proxy}\n" f"interactive_selection: {interactive_selection}\n" f"prompt: {prompt}\n" f"wait_for_inclusion: {wait_for_inclusion}\n" f"wait_for_finalization: {wait_for_finalization}\n" + f"mev_protection: {mev_protection}\n" ) result, ext_id = self._run_command( move_stake.swap_stake( @@ -5064,8 +5474,10 @@ def stake_swap( era=period, interactive_selection=interactive_selection, prompt=prompt, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + mev_protection=mev_protection, ) ) if json_output: @@ -5074,86 +5486,350 @@ def stake_swap( ) return result - def stake_get_children( + def stake_wizard( self, + network: Optional[list[str]] = Options.network, wallet_name: Optional[str] = Options.wallet_name, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, wallet_path: Optional[str] = Options.wallet_path, - network: Optional[list[str]] = Options.network, - netuid: Optional[int] = typer.Option( - None, - help="The netuid of the subnet (e.g. 2)", - prompt=False, - ), - all_netuids: bool = typer.Option( - False, - "--all-netuids", - "--all", - "--allnetuids", - help="When set, gets the child hotkeys from all the subnets.", - ), + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + period: int = Options.period, + mev_protection: bool = Options.mev_protection, + prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, - json_output: bool = Options.json_output, ): """ - Get all the child hotkeys on a specified subnet. + Interactive wizard that guides you through stake movement operations. - Users can specify the subnet and see the child hotkeys and the proportion that is given to them. This command is used to view the authority delegated to different hotkeys on the subnet. + This wizard helps you understand and choose the right stake movement command: + - [bold]Move[/bold]: Move stake between hotkeys (same coldkey) + - [bold]Transfer[/bold]: Transfer stake between coldkeys (same hotkey) + - [bold]Swap[/bold]: Swap stake between subnets (same coldkey-hotkey pair) + + The wizard will: + 1. Explain the differences between each operation + 2. Help you select the appropriate operation + 3. Guide you through the selection process + 4. Execute the operation with your choices EXAMPLE - [green]$[/green] btcli stake child get --netuid 1 - [green]$[/green] btcli stake child get --all-netuids + Start the wizard: + [green]$[/green] btcli stake wizard """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose) + wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, - ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, ) - if all_netuids and netuid: - err_console.print("Specify either a netuid or `--all`, not both.") - raise typer.Exit() + subtensor = self.initialize_chain(network) - if all_netuids: - netuid = None + wizard_result = self._run_command( + stake_wizard.stake_movement_wizard( + subtensor=subtensor, + wallet=wallet, + ), + exit_early=False, + ) - elif not netuid: - netuid = IntPrompt.ask( - "Enter a netuid (leave blank for all)", default=None, show_default=True + if not wizard_result or not isinstance(wizard_result, dict): + return False + + operation = wizard_result.get("operation") + if not operation: + return False + + if operation == "move": + # Execute move operation + result, ext_id = self._run_command( + move_stake.move_stake( + subtensor=subtensor, + wallet=wallet, + origin_netuid=wizard_result["origin_netuid"], + origin_hotkey=wizard_result["origin_hotkey"], + destination_netuid=wizard_result["destination_netuid"], + destination_hotkey=wizard_result["destination_hotkey"], + amount=wizard_result.get("amount"), + stake_all=wizard_result.get("stake_all", False), + era=period, + interactive_selection=False, + prompt=prompt, + mev_protection=mev_protection, + ) ) + elif operation == "transfer": + # Execute transfer operation + dest_coldkey = wizard_result.get("destination_coldkey") + if not is_valid_ss58_address(dest_coldkey): + # Assume it's a wallet name + dest_wallet = self.wallet_ask( + dest_coldkey, + wallet_path, + None, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + dest_coldkey = dest_wallet.coldkeypub.ss58_address - result = self._run_command( - children_hotkeys.get_children( - wallet, self.initialize_chain(network), netuid + result, ext_id = self._run_command( + move_stake.transfer_stake( + wallet=wallet, + subtensor=subtensor, + origin_hotkey=wizard_result["origin_hotkey"], + origin_netuid=wizard_result["origin_netuid"], + dest_netuid=wizard_result["destination_netuid"], + dest_coldkey_ss58=dest_coldkey, + amount=wizard_result.get("amount"), + stake_all=wizard_result.get("stake_all", False), + era=period, + interactive_selection=False, + prompt=prompt, + mev_protection=mev_protection, + ) ) - ) - if json_output: - json_console.print(json.dumps(result)) + elif operation == "swap": + # Execute swap operation + result, ext_id = self._run_command( + move_stake.swap_stake( + wallet=wallet, + subtensor=subtensor, + origin_netuid=wizard_result["origin_netuid"], + destination_netuid=wizard_result["destination_netuid"], + amount=wizard_result.get("amount"), + swap_all=False, + era=period, + interactive_selection=False, + prompt=prompt, + mev_protection=mev_protection, + ) + ) + else: + print_error(f"Unknown operation: {operation}") + return False return result - def stake_set_children( + def stake_set_claim_type( self, - children: list[str] = typer.Option( - [], "--children", "-c", help="Enter child hotkeys (ss58)", prompt=False + claim_type: Annotated[ + Optional[claim_stake.ClaimType], + typer.Argument( + help="Claim type: 'Keep' or 'Swap'. If omitted, user will be prompted.", + ), + ] = None, + netuids: Optional[str] = typer.Option( + None, + "--netuids", + "-n", + help="Netuids to select. Supports ranges and comma-separated values, e.g., '1-5,10,20-30'.", ), - wallet_name: str = Options.wallet_name, - wallet_hotkey: str = Options.wallet_hotkey, - wallet_path: str = Options.wallet_path, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, - netuid: Optional[int] = Options.netuid_not_req, - all_netuids: bool = Options.all_netuids, - proportions: list[float] = typer.Option( - [], - "--proportions", - "--prop", + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Set the root claim type for your coldkey. + + Root claim types control how staking emissions are handled on the ROOT network (subnet 0): + + [bold]Claim Types:[/bold] + • [green]Swap[/green]: Future Root Alpha Emissions are swapped to TAO and added to root stake (default) + • [yellow]Keep[/yellow]: Future Root Alpha Emissions are kept as Alpha tokens + • [cyan]Keep Specific[/cyan]: Keep specific subnets as Alpha, swap others to TAO. You can use this type by selecting the netuids. + + USAGE: + + [green]$[/green] btcli stake claim [cyan](Full wizard)[/cyan] + [green]$[/green] btcli stake claim keep [cyan](Keep all subnets)[/cyan] + [green]$[/green] btcli stake claim swap [cyan](Swap all subnets)[/cyan] + [green]$[/green] btcli stake claim keep --netuids 1-5,10,20-30 [cyan](Keep specific subnets)[/cyan] + [green]$[/green] btcli stake claim swap --netuids 1-30 [cyan](Swap specific subnets)[/cyan] + + With specific wallet: + + [green]$[/green] btcli stake claim swap --wallet-name my_wallet + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME], + ) + return self._run_command( + claim_stake.set_claim_type( + wallet=wallet, + subtensor=self.initialize_chain(network), + claim_type=claim_type, + netuids=netuids, + proxy=proxy, + prompt=prompt, + json_output=json_output, + ) + ) + + def stake_process_claim( + self, + netuids: Optional[str] = Options.netuids, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + network: Optional[list[str]] = Options.network, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Manually claim accumulated root network emissions for your coldkey. + + [bold]Note:[/bold] The network will eventually process your pending emissions automatically. + However, you can choose to manually claim your emissions with a small extrinsic fee. + + A maximum of 5 netuids can be processed in one call. + + USAGE: + + [green]$[/green] btcli stake process-claim + + Claim from specific netuids: + + [green]$[/green] btcli stake process-claim --netuids 1,2,3 + + Claim with specific wallet: + + [green]$[/green] btcli stake process-claim --netuids 1,2 --wallet-name my_wallet + + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + parsed_netuids = None + if netuids: + parsed_netuids = parse_to_list( + netuids, + int, + "Netuids must be a comma-separated list of ints, e.g., `--netuids 1,2,3`.", + ) + + if len(parsed_netuids) > 5: + print_error("Maximum 5 netuids allowed per claim") + return + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME], + ) + + return self._run_command( + claim_stake.process_pending_claims( + wallet=wallet, + subtensor=self.initialize_chain(network), + netuids=parsed_netuids, + proxy=proxy, + prompt=prompt, + json_output=json_output, + verbose=verbose, + ) + ) + + def stake_get_children( + self, + wallet_name: Optional[str] = Options.wallet_name, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + wallet_path: Optional[str] = Options.wallet_path, + network: Optional[list[str]] = Options.network, + netuid: Optional[int] = typer.Option( + None, + help="The netuid of the subnet (e.g. 2)", + prompt=False, + ), + all_netuids: bool = typer.Option( + False, + "--all-netuids", + "--all", + "--allnetuids", + help="When set, gets the child hotkeys from all the subnets.", + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Get all the child hotkeys on a specified subnet. + + Users can specify the subnet and see the child hotkeys and the proportion that is given to them. This command is used to view the authority delegated to different hotkeys on the subnet. + + EXAMPLE + + [green]$[/green] btcli stake child get --netuid 1 + [green]$[/green] btcli stake child get --all-netuids + """ + self.verbosity_handler(quiet, verbose, json_output, False) + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + + if all_netuids and netuid: + err_console.print("Specify either a netuid or `--all`, not both.") + raise typer.Exit() + + if all_netuids: + netuid = None + + elif not netuid: + netuid = IntPrompt.ask( + "Enter a netuid (leave blank for all)", default=None, show_default=True + ) + + result = self._run_command( + children_hotkeys.get_children( + wallet, self.initialize_chain(network), netuid + ) + ) + if json_output: + json_console.print(json.dumps(result)) + return result + + def stake_set_children( + self, + children: list[str] = typer.Option( + [], "--children", "-c", help="Enter child hotkeys (ss58)", prompt=False + ), + wallet_name: str = Options.wallet_name, + wallet_hotkey: str = Options.wallet_hotkey, + wallet_path: str = Options.wallet_path, + network: Optional[list[str]] = Options.network, + netuid: Optional[int] = Options.netuid_not_req, + all_netuids: bool = Options.all_netuids, + proportions: list[float] = typer.Option( + [], + "--proportions", + "--prop", help="Enter the stake weight proportions for the child hotkeys (sum should be less than or equal to 1)", prompt=False, ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, quiet: bool = Options.quiet, @@ -5172,7 +5848,8 @@ def stake_set_children( [green]$[/green] btcli stake child set -c 5FCL3gmjtQV4xxxxuEPEFQVhyyyyqYgNwX7drFLw7MSdBnxP -c 5Hp5dxxxxtGg7pu8dN2btyyyyVA1vELmM9dy8KQv3LxV8PA7 --hotkey default --netuid 1 --prop 0.3 --prop 0.7 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) netuid = get_optional_netuid(netuid, all_netuids) children = list_prompt( @@ -5207,6 +5884,7 @@ def stake_set_children( "args:\n" f"network: {network}\n" f"netuid: {netuid}\n" + f"proxy: {proxy}\n" f"children: {children}\n" f"proportions: {proportions}\n" f"wait_for_inclusion: {wait_for_inclusion}\n" @@ -5223,6 +5901,7 @@ def stake_set_children( wait_for_inclusion=wait_for_inclusion, prompt=prompt, json_output=json_output, + proxy=proxy, ) ) @@ -5244,6 +5923,8 @@ def stake_revoke_children( "--allnetuids", help="When this flag is used it sets child hotkeys on all the subnets.", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, quiet: bool = Options.quiet, @@ -5260,7 +5941,8 @@ def stake_revoke_children( [green]$[/green] btcli stake child revoke --hotkey --netuid 1 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -5281,16 +5963,18 @@ def stake_revoke_children( "args:\n" f"network: {network}\n" f"netuid: {netuid}\n" + f"proxy: {proxy}\n" f"wait_for_inclusion: {wait_for_inclusion}\n" f"wait_for_finalization: {wait_for_finalization}\n" ) return self._run_command( children_hotkeys.revoke_children( - wallet, - self.initialize_chain(network), - netuid, - wait_for_inclusion, - wait_for_finalization, + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + proxy=proxy, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, prompt=prompt, json_output=json_output, ) @@ -5328,6 +6012,8 @@ def stake_childkey_take( "take value.", prompt=False, ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, prompt: bool = Options.prompt, @@ -5350,7 +6036,8 @@ def stake_childkey_take( [green]$[/green] btcli stake child take --child-hotkey-ss58 --take 0.12 --netuid 1 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -5372,6 +6059,7 @@ def stake_childkey_take( f"network: {network}\n" f"netuid: {netuid}\n" f"take: {take}\n" + f"proxy: {proxy}\n" f"wait_for_inclusion: {wait_for_inclusion}\n" f"wait_for_finalization: {wait_for_finalization}\n" ) @@ -5381,6 +6069,7 @@ def stake_childkey_take( subtensor=self.initialize_chain(network), netuid=netuid, take=take, + proxy=proxy, hotkey=child_hotkey_ss58, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -5394,6 +6083,8 @@ def stake_childkey_take( json_console.print(json.dumps(output)) return results + # Mechanism + def mechanism_count_set( self, network: Optional[list[str]] = Options.network, @@ -5407,6 +6098,8 @@ def mechanism_count_set( "--mech-count", help="Number of mechanisms to set for the subnet.", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, prompt: bool = Options.prompt, @@ -5428,8 +6121,8 @@ def mechanism_count_set( [green]$[/green] btcli subnet mech set --netuid 12 --count 2 --wallet.name my_wallet --wallet.hotkey admin """ - - self.verbosity_handler(quiet, verbose, json_output) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + self.verbosity_handler(quiet, verbose, json_output, prompt) subtensor = self.initialize_chain(network) if not json_output: @@ -5489,6 +6182,7 @@ def mechanism_count_set( f"network: {network}\n" f"netuid: {netuid}\n" f"mechanism_count: {mechanism_count}\n" + f"proxy: {proxy}\n" ) result, err_msg, ext_id = self._run_command( @@ -5497,6 +6191,7 @@ def mechanism_count_set( subtensor=subtensor, netuid=netuid, mechanism_count=mechanism_count, + proxy=proxy, previous_count=current_count or 0, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -5532,7 +6227,7 @@ def mechanism_count_get( [green]$[/green] btcli subnet mech count --netuid 12 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) subtensor = self.initialize_chain(network) return self._run_command( subnet_mechanisms.count( @@ -5554,6 +6249,8 @@ def mechanism_emission_set( "--split", help="Comma-separated relative weights for each mechanism (normalised automatically).", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, prompt: bool = Options.prompt, @@ -5576,8 +6273,8 @@ def mechanism_emission_set( 2. Apply a 70/30 distribution in one command: [green]$[/green] btcli subnet mech emissions-split --netuid 12 --split 70,30 --wallet.name my_wallet --wallet.hotkey admin """ - - self.verbosity_handler(quiet, verbose, json_output) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + self.verbosity_handler(quiet, verbose, json_output, prompt) subtensor = self.initialize_chain(network) wallet = self.wallet_ask( wallet_name, @@ -5591,6 +6288,7 @@ def mechanism_emission_set( subtensor=subtensor, wallet=wallet, netuid=netuid, + proxy=proxy, new_emission_split=split, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -5616,7 +6314,7 @@ def mechanism_emission_get( [green]$[/green] btcli subnet mech emissions --netuid 12 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) subtensor = self.initialize_chain(network) return self._run_command( subnet_mechanisms.get_emission_split( @@ -5626,6 +6324,8 @@ def mechanism_emission_get( ) ) + # Sudo + def sudo_set( self, network: Optional[list[str]] = Options.network, @@ -5639,6 +6339,8 @@ def sudo_set( param_value: Optional[str] = typer.Option( "", "--value", help="Value to set the hyperparameter to." ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -5653,7 +6355,8 @@ def sudo_set( [green]$[/green] btcli sudo set --netuid 1 --param tempo --value 400 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if not param_name or not param_value: hyperparams = self._run_command( @@ -5661,6 +6364,7 @@ def sudo_set( exit_early=False, ) if not hyperparams: + # TODO this will cause a hanging connection, subtensor needs to be gracefully exited raise typer.Exit() if not param_name: @@ -5742,18 +6446,20 @@ def sudo_set( "args:\n" f"network: {network}\n" f"netuid: {netuid}\n" + f"proxy: {proxy}\n" f"param_name: {param_name}\n" f"param_value: {param_value}" ) result, err_msg, ext_id = self._run_command( sudo.sudo_set_hyperparameter( - wallet, - self.initialize_chain(network), - netuid, - param_name, - param_value, - prompt, - json_output, + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + proxy=proxy, + param_name=param_name, + param_value=param_value, + prompt=prompt, + json_output=json_output, ) ) if json_output: @@ -5783,7 +6489,7 @@ def sudo_get( [green]$[/green] btcli sudo get --netuid 1 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) return self._run_command( sudo.get_hyperparameters( self.initialize_chain(network), netuid, json_output @@ -5805,7 +6511,7 @@ def sudo_senate( EXAMPLE [green]$[/green] btcli sudo senate """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, False) return self._run_command( sudo.get_senate(self.initialize_chain(network), json_output) ) @@ -5825,7 +6531,7 @@ def sudo_proposals( EXAMPLE [green]$[/green] btcli sudo proposals """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) return self._run_command( sudo.proposals(self.initialize_chain(network), verbose, json_output) ) @@ -5843,6 +6549,8 @@ def sudo_senate_vote( prompt="Enter the proposal hash", help="The hash of the proposal to vote on.", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -5865,7 +6573,8 @@ def sudo_senate_vote( [green]$[/green] btcli sudo senate_vote --proposal """ # TODO discuss whether this should receive json_output. I don't think it should. - self.verbosity_handler(quiet, verbose) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + self.verbosity_handler(quiet, verbose, json_output=False, prompt=False) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -5873,10 +6582,17 @@ def sudo_senate_vote( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - logger.debug(f"args:\nnetwork: {network}\nproposal: {proposal}\nvote: {vote}\n") + logger.debug( + f"args:\nnetwork: {network}\nproposal: {proposal}\nvote: {vote}\nproxy: {proxy}" + ) return self._run_command( sudo.senate_vote( - wallet, self.initialize_chain(network), proposal, vote, prompt + wallet=wallet, + subtensor=self.initialize_chain(network), + proxy=proxy, + proposal_hash=proposal, + vote=vote, + prompt=prompt, ) ) @@ -5886,6 +6602,8 @@ def sudo_set_take( wallet_name: Optional[str] = Options.wallet_name, wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, take: float = typer.Option(None, help="The new take value."), quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -5902,7 +6620,8 @@ def sudo_set_take( """ max_value = 0.18 min_value = 0.00 - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, @@ -5913,6 +6632,7 @@ def sudo_set_take( ) self._run_command( + # TODO does this need to take the proxy account? sudo.display_current_take(self.initialize_chain(network), wallet), exit_early=False, ) @@ -5926,9 +6646,14 @@ def sudo_set_take( f"Take value must be between {min_value} and {max_value}. Provided value: {take}" ) raise typer.Exit() - logger.debug(f"args:\nnetwork: {network}\ntake: {take}") + logger.debug(f"args:\nnetwork: {network}\ntake: {take}\nproxy: {proxy}\n") result, ext_id = self._run_command( - sudo.set_take(wallet, self.initialize_chain(network), take) + sudo.set_take( + wallet=wallet, + subtensor=self.initialize_chain(network), + take=take, + proxy=proxy, + ) ) if json_output: json_console.print( @@ -5954,7 +6679,7 @@ def sudo_get_take( EXAMPLE [green]$[/green] btcli sudo get-take --wallet-name my_wallet --wallet-hotkey my_hotkey """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) wallet = self.wallet_ask( wallet_name, @@ -5980,6 +6705,8 @@ def sudo_trim( wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hotkey, netuid: int = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, max_uids: int = typer.Option( None, "--max", @@ -5999,7 +6726,8 @@ def sudo_trim( EXAMPLE [green]$[/green] btcli sudo trim --netuid 95 --wallet-name my_wallet --wallet-hotkey my_hotkey --max 64 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, @@ -6015,11 +6743,14 @@ def sudo_trim( netuid=netuid, max_n=max_uids, period=period, + proxy=proxy, json_output=json_output, prompt=prompt, ) ) + # Subnets + def subnets_list( self, network: Optional[list[str]] = Options.network, @@ -6057,7 +6788,7 @@ def subnets_list( if json_output and live_mode: print_error("Cannot use `--json-output` and `--live` at the same time.") return - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) subtensor = self.initialize_chain(network) return self._run_command( subnets.subnets_list( @@ -6132,7 +6863,9 @@ def subnets_price( f"Cannot specify both {arg__('--current')} and {arg__('--html')}" ) return - self.verbosity_handler(quiet=quiet, verbose=verbose, json_output=json_output) + self.verbosity_handler( + quiet=quiet, verbose=verbose, json_output=json_output, prompt=False + ) subtensor = self.initialize_chain(network) non_archives = ["finney", "latent-lite"] @@ -6215,7 +6948,7 @@ def subnets_show( 2. Pick mechanism 1 explicitly: [green]$[/green] btcli subnets show --netuid 12 --mechid 1 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) subtensor = self.initialize_chain(network) if netuid == 0: mechanism_count = 1 @@ -6263,7 +6996,7 @@ def subnets_burn_cost( [green]$[/green] btcli subnets burn_cost """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) return self._run_command( subnets.burn_cost(self.initialize_chain(network), json_output) ) @@ -6274,6 +7007,8 @@ def subnets_create( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, subnet_name: Optional[str] = typer.Option( None, "--subnet-name", help="Name of the subnet" ), @@ -6300,6 +7035,7 @@ def subnets_create( additional_info: Optional[str] = typer.Option( None, "--additional-info", help="Additional information" ), + mev_protection: bool = Options.mev_protection, json_output: bool = Options.json_output, prompt: bool = Options.prompt, quiet: bool = Options.quiet, @@ -6318,8 +7054,12 @@ def subnets_create( 2. Create with GitHub repo and contact email: [green]$[/green] btcli subnets create --subnet-name MySubnet --github-repo https://github.com/myorg/mysubnet --subnet-contact team@mysubnet.net + + 3. Create subnet without MEV protection: + [green]$[/green] btcli subnets create --no-mev-protection """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -6329,7 +7069,7 @@ def subnets_create( WO.HOTKEY, WO.PATH, ], - validate=WV.WALLET_AND_HOTKEY, + validate=WV.WALLET, ) identity = prompt_for_subnet_identity( current_identity={}, @@ -6342,10 +7082,18 @@ def subnets_create( logo_url=logo_url, additional=additional_info, ) - logger.debug(f"args:\nnetwork: {network}\nidentity: {identity}\n") + logger.debug( + f"args:\nnetwork: {network}\nidentity: {identity}\nproxy: {proxy}\n" + ) self._run_command( subnets.create( - wallet, self.initialize_chain(network), identity, json_output, prompt + wallet=wallet, + subtensor=self.initialize_chain(network), + subnet_identity=identity, + proxy=proxy, + json_output=json_output, + prompt=prompt, + mev_protection=mev_protection, ) ) @@ -6364,7 +7112,7 @@ def subnets_check_start( Example: [green]$[/green] btcli subnets check-start --netuid 1 """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output=False, prompt=False) return self._run_command( subnets.get_start_schedule(self.initialize_chain(network), netuid) ) @@ -6375,6 +7123,8 @@ def subnets_start( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, netuid: int = Options.netuid, prompt: bool = Options.prompt, quiet: bool = Options.quiet, @@ -6389,7 +7139,8 @@ def subnets_start( [green]$[/green] btcli subnets start --netuid 1 [green]$[/green] btcli subnets start --netuid 1 --wallet-name alice """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output=False, prompt=prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if not wallet_name: wallet_name = Prompt.ask( "Enter the [blue]wallet name[/blue] [dim](which you used to create the subnet)[/dim]", @@ -6404,13 +7155,14 @@ def subnets_start( ], validate=WV.WALLET, ) - logger.debug(f"args:\nnetwork: {network}\nnetuid: {netuid}\n") + logger.debug(f"args:\nnetwork: {network}\nnetuid: {netuid}\nproxy: {proxy}\n") return self._run_command( subnets.start_subnet( - wallet, - self.initialize_chain(network), - netuid, - prompt, + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + proxy=proxy, + prompt=prompt, ) ) @@ -6429,7 +7181,7 @@ def subnets_get_identity( [green]$[/green] btcli subnets get-identity --netuid 1 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) return self._run_command( subnets.get_identity( self.initialize_chain(network), netuid, json_output=json_output @@ -6443,6 +7195,8 @@ def subnets_set_identity( wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, netuid: int = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, subnet_name: Optional[str] = typer.Option( None, "--subnet-name", "--sn-name", help="Name of the subnet" ), @@ -6487,7 +7241,8 @@ def subnets_set_identity( 2. Set subnet identity with specific values: [green]$[/green] btcli subnets set-identity --netuid 1 --subnet-name MySubnet --github-repo https://github.com/myorg/mysubnet --subnet-contact team@mysubnet.net """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -6521,11 +7276,16 @@ def subnets_set_identity( additional=additional_info, ) logger.debug( - f"args:\nnetwork: {network}\nnetuid: {netuid}\nidentity: {identity}" + f"args:\nnetwork: {network}\nnetuid: {netuid}\nidentity: {identity}\nproxy: {proxy}\n" ) success, ext_id = self._run_command( subnets.set_identity( - wallet, self.initialize_chain(network), netuid, identity, prompt + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + subnet_identity=identity, + prompt=prompt, + proxy=proxy, ) ) if json_output: @@ -6638,6 +7398,8 @@ def subnets_register( help="Length (in blocks) for which the transaction should be valid. Note that it is possible that if you " "use an era for this transaction that you may pay a different fee to register than the one stated.", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, json_output: bool = Options.json_output, prompt: bool = Options.prompt, quiet: bool = Options.quiet, @@ -6654,7 +7416,8 @@ def subnets_register( [green]$[/green] btcli subnets register --netuid 1 """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -6662,15 +7425,18 @@ def subnets_register( ask_for=[WO.NAME, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - logger.debug(f"args:\nnetwork: {network}\nnetuid: {netuid}\nperiod: {period}\n") + logger.debug( + f"args:\nnetwork: {network}\nnetuid: {netuid}\nperiod: {period}\nproxy: {proxy}\n" + ) return self._run_command( subnets.register( - wallet, - self.initialize_chain(network), - netuid, - period, - json_output, - prompt, + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + era=period, + json_output=json_output, + prompt=prompt, + proxy=proxy, ) ) @@ -6740,7 +7506,7 @@ def subnets_metagraph( [blue bold]Note[/blue bold]: This command is not intended to be used as a standalone function within user code. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output=False, prompt=False) if (reuse_last or html_output) and self.config.get("use_cache") is False: err_console.print( "Unable to use `--reuse-last` or `--html` when config `no-cache` is set to `True`. " @@ -6782,6 +7548,9 @@ def subnets_set_symbol( wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, netuid: int = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, + period: int = Options.period, json_output: bool = Options.json_output, prompt: bool = Options.prompt, quiet: bool = Options.quiet, @@ -6802,7 +7571,8 @@ def subnets_set_symbol( If --json-output is used, the output will be in the following schema: [#AFEFFF]{success: [dark_orange]bool[/dark_orange], message: [dark_orange]str[/dark_orange]}[/#AFEFFF] """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if len(symbol) > 1: err_console.print("Your symbol must be a single character.") return False @@ -6813,17 +7583,28 @@ def subnets_set_symbol( ask_for=[WO.NAME, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) + logger.debug( + "args:\n" + f"network: {network}\n" + f"netuid: {netuid}\n" + f"proxy: {proxy}\n" + f"symbol: {symbol}\n" + ) return self._run_command( subnets.set_symbol( wallet=wallet, subtensor=self.initialize_chain(network), netuid=netuid, symbol=symbol, + proxy=proxy, + period=period, prompt=prompt, json_output=json_output, ) ) + # Weights + def weights_reveal( self, network: Optional[list[str]] = Options.network, @@ -6831,6 +7612,8 @@ def weights_reveal( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, netuid: int = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, uids: str = typer.Option( None, "--uids", @@ -6858,12 +7641,8 @@ def weights_reveal( [green]$[/green] btcli wt reveal --netuid 1 --uids 1,2,3,4 --weights 0.1,0.2,0.3,0.4 --salt 163,241,217,11,161,142,147,189 """ - self.verbosity_handler(quiet, verbose, json_output) - # TODO think we need to ','.split uids and weights ? - uids = list_prompt(uids, int, "UIDs of interest for the specified netuid") - weights = list_prompt( - weights, float, "Corresponding weights for the specified UIDs" - ) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if uids: uids = parse_to_list( uids, @@ -6872,7 +7651,7 @@ def weights_reveal( ) else: uids = list_prompt( - uids, int, "Corresponding UIDs for the specified netuid (eg: 1,2,3)" + [], int, "Corresponding UIDs for the specified netuid (eg: 1,2,3)" ) if weights: @@ -6883,7 +7662,7 @@ def weights_reveal( ) else: weights = list_prompt( - weights, + [], float, "Corresponding weights for the specified UIDs (eg: 0.2,0.3,0.4)", ) @@ -6901,7 +7680,7 @@ def weights_reveal( "Salt must be a comma-separated list of ints, e.g., `--weights 123,163,194`.", ) else: - salt = list_prompt(salt, int, "Corresponding salt for the hash function") + salt = list_prompt([], int, "Corresponding salt for the hash function") wallet = self.wallet_ask( wallet_name, @@ -6912,13 +7691,14 @@ def weights_reveal( ) return self._run_command( weights_cmds.reveal_weights( - self.initialize_chain(network), - wallet, - netuid, - uids, - weights, - salt, - __version_as_int__, + subtensor=self.initialize_chain(network), + wallet=wallet, + netuid=netuid, + proxy=proxy, + uids=uids, + weights=weights, + salt=salt, + version=__version_as_int__, prompt=prompt, json_output=json_output, ) @@ -6931,6 +7711,8 @@ def weights_commit( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, netuid: int = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, uids: str = typer.Option( None, "--uids", @@ -6962,8 +7744,8 @@ def weights_commit( [italic]Note[/italic]: This command is used to commit weights for a specific subnet and requires the user to have the necessary permissions. """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if uids: uids = parse_to_list( uids, @@ -6972,7 +7754,7 @@ def weights_commit( ) else: uids = list_prompt( - uids, int, "UIDs of interest for the specified netuid (eg: 1,2,3)" + [], int, "UIDs of interest for the specified netuid (eg: 1,2,3)" ) if weights: @@ -6983,7 +7765,7 @@ def weights_commit( ) else: weights = list_prompt( - weights, + [], float, "Corresponding weights for the specified UIDs (eg: 0.2,0.3,0.4)", ) @@ -7000,7 +7782,7 @@ def weights_commit( "Salt must be a comma-separated list of ints, e.g., `--weights 123,163,194`.", ) else: - salt = list_prompt(salt, int, "Corresponding salt for the hash function") + salt = list_prompt([], int, "Corresponding salt for the hash function") wallet = self.wallet_ask( wallet_name, @@ -7011,18 +7793,21 @@ def weights_commit( ) return self._run_command( weights_cmds.commit_weights( - self.initialize_chain(network), - wallet, - netuid, - uids, - weights, - salt, - __version_as_int__, + subtensor=self.initialize_chain(network), + wallet=wallet, + netuid=netuid, + uids=uids, + proxy=proxy, + weights=weights, + salt=salt, + version=__version_as_int__, json_output=json_output, prompt=prompt, ) ) + # View + def view_dashboard( self, network: Optional[list[str]] = Options.network, @@ -7048,7 +7833,7 @@ def view_dashboard( """ Display html dashboard with subnets list, stake, and neuron information. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output=False, prompt=False) if use_wry and save_file: print_error("Cannot save file when using browser output.") @@ -7083,132 +7868,7 @@ def view_dashboard( ) ) - def stake_set_claim_type( - self, - claim_type: Optional[str] = typer.Argument( - None, - help="Claim type: 'keep' or 'swap'. If not provided, you'll be prompted to choose.", - ), - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - network: Optional[list[str]] = Options.network, - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - json_output: bool = Options.json_output, - ): - """ - Set the root claim type for your coldkey. - - Root claim types control how staking emissions are handled on the ROOT network (subnet 0): - - [bold]Claim Types:[/bold] - • [green]Swap[/green]: Future Root Alpha Emissions are swapped to TAO and added to root stake (default) - • [yellow]Keep[/yellow]: Future Root Alpha Emissions are kept as Alpha tokens - - USAGE: - - [green]$[/green] btcli stake claim - [green]$[/green] btcli stake claim keep - [green]$[/green] btcli stake claim swap - - With specific wallet: - - [green]$[/green] btcli stake claim swap --wallet-name my_wallet - """ - self.verbosity_handler(quiet, verbose, json_output) - - if claim_type is not None: - claim_type_normalized = claim_type.capitalize() - if claim_type_normalized not in ["Keep", "Swap"]: - err_console.print( - f":cross_mark: [red]Invalid claim type '{claim_type}'. Must be 'keep' or 'swap'.[/red]" - ) - raise typer.Exit() - else: - claim_type_normalized = None - - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME], - ) - return self._run_command( - claim_stake.set_claim_type( - wallet=wallet, - subtensor=self.initialize_chain(network), - claim_type=claim_type_normalized, - prompt=prompt, - json_output=json_output, - ) - ) - - def stake_process_claim( - self, - netuids: Optional[str] = Options.netuids, - wallet_name: Optional[str] = Options.wallet_name, - wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, - network: Optional[list[str]] = Options.network, - prompt: bool = Options.prompt, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - json_output: bool = Options.json_output, - ): - """ - Manually claim accumulated root network emissions for your coldkey. - - [bold]Note:[/bold] The network will eventually process your pending emissions automatically. - However, you can choose to manually claim your emissions with a small extrinsic fee. - - A maximum of 5 netuids can be processed in one call. - - USAGE: - - [green]$[/green] btcli stake process-claim - - Claim from specific netuids: - - [green]$[/green] btcli stake process-claim --netuids 1,2,3 - - Claim with specific wallet: - - [green]$[/green] btcli stake process-claim --netuids 1,2 --wallet-name my_wallet - - """ - self.verbosity_handler(quiet, verbose, json_output) - - parsed_netuids = None - if netuids: - parsed_netuids = parse_to_list( - netuids, - int, - "Netuids must be a comma-separated list of ints, e.g., `--netuids 1,2,3`.", - ) - - if len(parsed_netuids) > 5: - print_error("Maximum 5 netuids allowed per claim") - return - - wallet = self.wallet_ask( - wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME], - ) - - return self._run_command( - claim_stake.process_pending_claims( - wallet=wallet, - subtensor=self.initialize_chain(network), - netuids=parsed_netuids, - prompt=prompt, - json_output=json_output, - verbose=verbose, - ) - ) + # Liquidity def liquidity_add( self, @@ -7217,6 +7877,8 @@ def liquidity_add( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, netuid: Optional[int] = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, liquidity_: Optional[float] = typer.Option( None, "--liquidity", @@ -7244,7 +7906,8 @@ def liquidity_add( json_output: bool = Options.json_output, ): """Add liquidity to the swap (as a combination of TAO + Alpha).""" - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if not netuid: netuid = Prompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", @@ -7289,6 +7952,7 @@ def liquidity_add( f"liquidity: {liquidity_}\n" f"price_low: {price_low}\n" f"price_high: {price_high}\n" + f"proxy: {proxy}\n" ) return self._run_command( liquidity.add_liquidity( @@ -7296,6 +7960,7 @@ def liquidity_add( wallet=wallet, hotkey_ss58=hotkey, netuid=netuid, + proxy=proxy, liquidity=liquidity_, price_low=price_low, price_high=price_high, @@ -7316,7 +7981,7 @@ def liquidity_list( json_output: bool = Options.json_output, ): """Displays liquidity positions in given subnet.""" - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) if not netuid: netuid = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", @@ -7347,6 +8012,8 @@ def liquidity_remove( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, netuid: Optional[int] = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, position_id: Optional[int] = typer.Option( None, "--position-id", @@ -7366,8 +8033,8 @@ def liquidity_remove( ): """Remove liquidity from the swap (as a combination of TAO + Alpha).""" - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if all_liquidity_ids and position_id: print_error("Cannot specify both --all and --position-id.") return @@ -7404,6 +8071,7 @@ def liquidity_remove( wallet=wallet, hotkey_ss58=hotkey, netuid=netuid, + proxy=proxy, position_id=position_id, prompt=prompt, all_liquidity_ids=all_liquidity_ids, @@ -7418,6 +8086,8 @@ def liquidity_modify( wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, netuid: Optional[int] = Options.netuid, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, position_id: Optional[int] = typer.Option( None, "--position-id", @@ -7436,7 +8106,8 @@ def liquidity_modify( json_output: bool = Options.json_output, ): """Modifies the liquidity position for the given subnet.""" - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if not netuid: netuid = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", @@ -7468,7 +8139,8 @@ def liquidity_modify( f"hotkey: {hotkey}\n" f"netuid: {netuid}\n" f"position_id: {position_id}\n" - f"liquidity_delta: {liquidity_delta}" + f"liquidity_delta: {liquidity_delta}\n" + f"proxy: {proxy}\n" ) return self._run_command( @@ -7477,6 +8149,7 @@ def liquidity_modify( wallet=wallet, hotkey_ss58=hotkey, netuid=netuid, + proxy=proxy, position_id=position_id, liquidity_delta=liquidity_delta, prompt=prompt, @@ -7484,6 +8157,8 @@ def liquidity_modify( ) ) + # Crowd + def crowd_list( self, network: Optional[list[str]] = Options.network, @@ -7506,7 +8181,7 @@ def crowd_list( [green]$[/green] btcli crowd list --verbose """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) return self._run_command( view_crowdloan.list_crowdloans( subtensor=self.initialize_chain(network), @@ -7543,7 +8218,7 @@ def crowd_info( [green]$[/green] btcli crowd info --id 1 --verbose """ - self.verbosity_handler(quiet, verbose, json_output) + self.verbosity_handler(quiet, verbose, json_output, prompt=False) if crowdloan_id is None: crowdloan_id = IntPrompt.ask( @@ -7578,6 +8253,8 @@ def crowd_create( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, deposit: Optional[float] = typer.Option( None, "--deposit", @@ -7653,8 +8330,8 @@ def crowd_create( Subnet lease ending at block 500000: [green]$[/green] btcli crowd create --subnet-lease --emissions-share 25 --lease-end-block 500000 """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) wallet = self.wallet_ask( wallet_name=wallet_name, wallet_path=wallet_path, @@ -7667,6 +8344,7 @@ def crowd_create( create_crowdloan.create_crowdloan( subtensor=self.initialize_chain(network), wallet=wallet, + proxy=proxy, deposit_tao=deposit, min_contribution_tao=min_contribution, cap_tao=cap, @@ -7702,6 +8380,8 @@ def crowd_contribute( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, @@ -7720,8 +8400,8 @@ def crowd_contribute( [green]$[/green] btcli crowd contribute --id 1 """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if crowdloan_id is None: crowdloan_id = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", @@ -7741,6 +8421,7 @@ def crowd_contribute( crowd_contribute.contribute_to_crowdloan( subtensor=self.initialize_chain(network), wallet=wallet, + proxy=proxy, crowdloan_id=crowdloan_id, amount=amount, prompt=prompt, @@ -7763,6 +8444,8 @@ def crowd_withdraw( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, @@ -7776,8 +8459,8 @@ def crowd_withdraw( Non-creators can withdraw their full contribution. Creators can only withdraw amounts above their initial deposit. """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if crowdloan_id is None: crowdloan_id = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", @@ -7797,6 +8480,7 @@ def crowd_withdraw( crowd_contribute.withdraw_from_crowdloan( subtensor=self.initialize_chain(network), wallet=wallet, + proxy=proxy, crowdloan_id=crowdloan_id, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -7818,6 +8502,8 @@ def crowd_finalize( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, @@ -7831,8 +8517,8 @@ def crowd_finalize( Only the creator can finalize. This will transfer funds to the target address (if specified) and execute any attached call (e.g., subnet creation). """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if crowdloan_id is None: crowdloan_id = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", @@ -7853,6 +8539,7 @@ def crowd_finalize( subtensor=self.initialize_chain(network), wallet=wallet, crowdloan_id=crowdloan_id, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, prompt=prompt, @@ -7890,6 +8577,8 @@ def crowd_update( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, @@ -7906,8 +8595,8 @@ def crowd_update( against the chain constants (absolute minimum contribution, block-duration bounds, etc.). """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if crowdloan_id is None: crowdloan_id = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", @@ -7932,6 +8621,7 @@ def crowd_update( crowd_update.update_crowdloan( subtensor=self.initialize_chain(network), wallet=wallet, + proxy=proxy, crowdloan_id=crowdloan_id, min_contribution=min_contribution_balance, end=end, @@ -7952,6 +8642,8 @@ def crowd_refund( "--id", help="The ID of the crowdloan to refund", ), + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, network: Optional[list[str]] = Options.network, wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, @@ -7971,8 +8663,8 @@ def crowd_refund( Contributors can call `btcli crowdloan withdraw` at will. """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if crowdloan_id is None: crowdloan_id = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", @@ -7992,6 +8684,7 @@ def crowd_refund( crowd_refund.refund_crowdloan( subtensor=self.initialize_chain(network), wallet=wallet, + proxy=proxy, crowdloan_id=crowdloan_id, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -8013,6 +8706,8 @@ def crowd_dissolve( wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, @@ -8031,8 +8726,8 @@ def crowd_dissolve( If there are funds still available other than the creator's contribution, you can run `btcli crowd refund` to refund the remaining contributors. """ - self.verbosity_handler(quiet, verbose, json_output) - + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) if crowdloan_id is None: crowdloan_id = IntPrompt.ask( f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", @@ -8052,6 +8747,7 @@ def crowd_dissolve( crowd_dissolve.dissolve_crowdloan( subtensor=self.initialize_chain(network), wallet=wallet, + proxy=proxy, crowdloan_id=crowdloan_id, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -8060,6 +8756,500 @@ def crowd_dissolve( ) ) + # Proxy + # TODO check announcements: how do they work? + + def proxy_create( + self, + network: Optional[list[str]] = Options.network, + proxy_type: ProxyType = Options.proxy_type, + delay: int = typer.Option(0, help="Delay, in number of blocks"), + idx: int = typer.Option( + 0, + "--index", + help="A disambiguation index, in case this is called multiple times in the same transaction" + " (e.g. with utility::batch). Unless you're using batch you probably just want to use 0.", + ), + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + period: int = Options.period, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Creates a new pure proxy account. The pure proxy account is a keyless account controlled by your wallet. + + [bold]Note[/bold]: The proxy account has no private key and cannot sign transactions independently. + All operations must be initiated and signed by the delegator. + + + [bold]Common Examples:[/bold] + 1. Create a pure proxy account + [green]$[/green] btcli proxy create --proxy-type Any + + 2. Create a delayed pure proxy account + [green]$[/green] btcli proxy create --proxy-type Any --delay 1000 + + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + logger.debug( + "args:\n" + f"network: {network}\n" + f"proxy_type: {proxy_type}\n" + f"delay: {delay}\n" + f"idx: {idx}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + f"era: {period}\n" + f"prompt: {prompt}\n" + ) + + self._run_command( + proxy_commands.create_proxy( + subtensor=self.initialize_chain(network), + wallet=wallet, + proxy_type=proxy_type, + delay=delay, + idx=idx, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + period=period, + ) + ) + + def proxy_add( + self, + delegate: Annotated[ + str, + typer.Option( + callback=is_valid_ss58_address_param, + prompt="Enter the SS58 address of the delegate to add, e.g. 5dxds...", + help="The SS58 address of the delegate to add", + ), + ] = "", + network: Optional[list[str]] = Options.network, + proxy_type: ProxyType = Options.proxy_type, + delay: int = typer.Option(0, help="Delay, in number of blocks"), + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + period: int = Options.period, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Registers an existing account as a standard proxy for the delegator. + + Grants an existing account permission to execute transactions on your behalf with + specified restrictions. + + [bold]Common Examples:[/bold] + 1. Create a standard proxy account + [green]$[/green] btcli proxy add --delegate 5GDeleg... --proxy-type SmallTransfer + + 2. Create a delayed standard proxy account + [green]$[/green] btcli proxy add --delegate 5GDeleg... --proxy-type Transfer --delay 500 + + """ + logger.debug( + "args:\n" + f"network: {network}\n" + f"delegate: {delegate}\n" + f"proxy_type: {proxy_type}\n" + f"delay: {delay}\n" + f"prompt: {prompt}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"era: {period}\n" + ) + self.verbosity_handler(quiet, verbose, json_output, prompt) + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + return self._run_command( + proxy_commands.add_proxy( + subtensor=self.initialize_chain(network), + wallet=wallet, + delegate=delegate, + proxy_type=proxy_type, + delay=delay, + prompt=prompt, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + json_output=json_output, + ) + ) + + def proxy_remove( + self, + delegate: Annotated[ + str, + typer.Option( + callback=is_valid_ss58_address_param, + prompt="Enter the SS58 address of the delegate to remove, e.g. 5dxds...", + help="The SS58 address of the delegate to remove", + ), + ] = "", + network: Optional[list[str]] = Options.network, + proxy_type: ProxyType = Options.proxy_type, + delay: int = typer.Option(0, help="Delay, in number of blocks"), + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + period: int = Options.period, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Unregisters a proxy from an account. + + Revokes proxy permissions previously granted to another account. This prevents the delegate account from executing any further transactions on your behalf. + + [bold]Note[/bold]: You can specify a delegate to remove a single proxy or use the `--all` flag to remove all existing proxies linked to an account. + + + [bold]Common Examples:[/bold] + 1. Revoke proxy permissions from a single proxy account + [green]$[/green] btcli proxy remove --delegate 5GDel... --proxy-type Transfer + + 2. Remove all proxies linked to an account + [green]$[/green] btcli proxy remove --all + + """ + # TODO should add a --all flag to call Proxy.remove_proxies ? + logger.debug( + "args:\n" + f"delegate: {delegate}\n" + f"network: {network}\n" + f"proxy_type: {proxy_type}\n" + f"delay: {delay}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"era: {period}\n" + ) + self.verbosity_handler(quiet, verbose, json_output, prompt) + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + return self._run_command( + proxy_commands.remove_proxy( + subtensor=self.initialize_chain(network), + wallet=wallet, + delegate=delegate, + proxy_type=proxy_type, + delay=delay, + prompt=prompt, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + json_output=json_output, + ) + ) + + def proxy_kill( + self, + height: int = typer.Option( + help="The block number that the proxy was created at", + prompt="Enter the block number at which the proxy was created", + ), + ext_index: int = typer.Option( + help=f"The extrinsic index of the Proxy.PureCreated event" + f" ([{COLORS.G.ARG}]btcli proxy create[/{COLORS.G.ARG}])", + prompt="Enter the extrinsic index of the `btcli proxy create` event.", + ), + spawner: Annotated[ + Optional[str], + typer.Option( + callback=is_valid_ss58_address_param, + help="The SS58 of the pure proxy creator account. If omitted, the wallet's coldkeypub is used.", + ), + ] = None, + network: Optional[list[str]] = Options.network, + proxy_type: ProxyType = Options.proxy_type, + proxy: str = Options.proxy, + announce_only: bool = Options.announce_only, + idx: int = typer.Option( + 0, + "--index", + help="A disambiguation index, in case this is called multiple times in the same transaction" + " (e.g. with utility::batch). Unless you're using batch you probably just want to use 0.", + ), + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + period: int = Options.period, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Permanently removes a pure proxy account. + + Once killed, the pure proxy account is cleared from chain storage and cannot be recovered. + + [bold]⚠️ WARNING[/bold]: Killing a pure proxy permanently removes access to the account, and any funds remaining in it are lost. + + EXAMPLE + + [green]$[/green] btcli proxy kill --height 6345834 --index 3 --proxy-type Any --spawner 5x34SPAWN... --proxy 5CCProxy... + """ + logger.debug( + "args:\n" + f"height: {height}\n" + f"ext_index: {ext_index}\n" + f"proxy_type: {proxy_type}\n" + f"spawner: {spawner}\n" + f"proxy: {proxy}\n" + f"network: {network}\n" + f"idx: {idx}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + f"era: {period}\n" + ) + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + proxy_commands.kill_proxy( + subtensor=self.initialize_chain(network), + wallet=wallet, + proxy_type=proxy_type, + height=height, + proxy=proxy, + announce_only=announce_only, + ext_index=ext_index, + idx=idx, + spawner=spawner, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + period=period, + ) + ) + + def proxy_execute_announced( + self, + proxy: str = Options.proxy, + real: Optional[str] = Options.real_proxy, + delegate: Optional[str] = typer.Option( + None, + "--delegate", + help="The delegate of the call. If omitted, the wallet's coldkey ss58 is used.", + ), + call_hash: Optional[str] = typer.Option( + None, + help="The hash proxy call to execute", + ), + call_hex: Optional[str] = typer.Option( + None, help="The hex of the call to specify" + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + period: int = Options.period, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + self.verbosity_handler(quiet, verbose, json_output, prompt) + outer_proxy_from_config = self.proxies.get(proxy, {}) + proxy_from_config = outer_proxy_from_config.get("address") + delay = 0 + got_delay_from_config = False + if proxy_from_config is not None: + proxy = proxy_from_config + delay = outer_proxy_from_config["delay"] + got_delay_from_config = True + else: + if not is_valid_ss58_address(proxy): + raise typer.BadParameter( + f"proxy {proxy} is not a valid SS58 address or proxy address book name." + ) + proxy = self.is_valid_proxy_name_or_ss58(proxy, False) + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + real = self.is_valid_proxy_name_or_ss58(real, False) or proxy + delegate = delegate or wallet.coldkeypub.ss58_address + with ProxyAnnouncements.get_db() as (conn, cursor): + announcements = ProxyAnnouncements.read_rows(conn, cursor) + if not got_delay_from_config: + proxies = ProxyAddressBook.read_rows(conn, cursor) + else: + proxies = [] + potential_matches = [] + if not got_delay_from_config: + for row in proxies: + p_name, ss58_address, delay_, spawner, proxy_type, note = row + if proxy == ss58_address: + potential_matches.append(row) + if len(potential_matches) == 1: + delay = potential_matches[0][2] + got_delay_from_config = True + elif len(potential_matches) > 1: + if not prompt: + err_console.print( + f":cross_mark:[red]Error: The proxy ss58 you provided: {proxy} matched the address book" + f" ambiguously (more than one match). To use this (rather than the address book name), you will " + f"have to use without {arg__('--no-prompt')}" + ) + return + else: + console.print( + f"The proxy ss58 you provided matches the address book ambiguously. The results will be" + f"iterated, for you to select your intended proxy." + ) + for row in potential_matches: + p_name, ss58_address, delay_, spawner, proxy_type, note = row + console.print( + f"Name: {p_name}\n" + f"Delay: {delay_}\n" + f"Spawner/Delegator: {spawner}\n" + f"Proxy Type: {proxy_type}\n" + f"Note: {note}\n" + ) + if Confirm.ask("Is this the intended proxy?"): + delay = delay_ + got_delay_from_config = True + break + + if not got_delay_from_config: + verbose_console.print( + f"Unable to retrieve proxy from address book: {proxy}" + ) + block = None + # index of the call if retrieved from DB + got_call_from_db: Optional[int] = None + if not call_hex: + potential_call_matches = [] + for row in announcements: + ( + id_, + address, + epoch_time, + block_, + call_hash_, + call_hex_, + call_serialized, + executed_int, + ) = row + executed = bool(executed_int) + if call_hash_ == call_hash and address == proxy and executed is False: + potential_call_matches.append(row) + if len(potential_call_matches) == 1: + block = potential_call_matches[0][3] + call_hex = potential_call_matches[0][5] + got_call_from_db = potential_call_matches[0][0] + elif len(potential_call_matches) > 1: + if not prompt: + err_console.print( + f":cross_mark:[red]Error: The call hash you have provided matches {len(potential_call_matches)}" + f" possible entries. In order to choose which one, you will need to run " + f"without {arg__('--no-prompt')}" + ) + return + else: + console.print( + f"The call hash you have provided matches {len(potential_call_matches)}" + f" possible entries. The results will be iterated for you to selected your intended" + f"call." + ) + for row in potential_call_matches: + ( + id_, + address, + epoch_time, + block_, + call_hash_, + call_hex_, + call_serialized, + executed_int, + ) = row + console.print( + f"Time: {datetime.datetime.fromtimestamp(epoch_time)}\nCall:\n" + ) + console.print_json(call_serialized) + if Confirm.ask("Is this the intended call?"): + call_hex = call_hex_ + block = block_ + got_call_from_db = row + break + if got_call_from_db is None: + console.print("Unable to retrieve call from DB. Proceeding without.") + if isinstance(call_hex, str) and call_hex[0:2] == "0x": + call_hex = call_hex[2:] + + success = self._run_command( + proxy_commands.execute_announced( + subtensor=self.initialize_chain(network), + wallet=wallet, + # TODO this might be backwards with pure vs non-pure proxies + delegate=delegate, + real=real, + period=period, + call_hex=call_hex, + delay=delay, + created_block=block, + prompt=prompt, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + json_output=json_output, + ) + ) + if success and got_call_from_db is not None: + with ProxyAnnouncements.get_db() as (conn, cursor): + ProxyAnnouncements.mark_as_executed(conn, cursor, got_call_from_db) + @staticmethod def convert( from_rao: Optional[str] = typer.Option( diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index cc27ad38a..9b4e1c3c3 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -114,6 +114,11 @@ class config: }, } + class proxies: + base_path = "~/.bittensor" + path = "~/.bittensor/bittensor.db" + dictionary = {} + class subtensor: network = "finney" chain_endpoint = None @@ -734,6 +739,9 @@ class RootSudoOnly(Enum): "PARTICIPANT": "Crowdloan Participation", "INFO": "Crowdloan Information", }, + "PROXY": { + "MGMT": "Proxy Account Management", + }, } diff --git a/bittensor_cli/src/bittensor/extrinsics/mev_shield.py b/bittensor_cli/src/bittensor/extrinsics/mev_shield.py new file mode 100644 index 000000000..4a95dbb73 --- /dev/null +++ b/bittensor_cli/src/bittensor/extrinsics/mev_shield.py @@ -0,0 +1,175 @@ +import hashlib +from typing import TYPE_CHECKING, Optional + +from async_substrate_interface import AsyncExtrinsicReceipt +from bittensor_drand import encrypt_mlkem768 +from bittensor_cli.src.bittensor.utils import format_error_message + +if TYPE_CHECKING: + from bittensor_wallet import Keypair + from scalecodec import GenericCall, GenericExtrinsic + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def encrypt_extrinsic( + subtensor: "SubtensorInterface", + signed_extrinsic: "GenericExtrinsic", +) -> "GenericCall": + """ + Encrypt a signed extrinsic using MEV Shield. + + Takes a pre-signed extrinsic and returns a MevShield.submit_encrypted call. + + Args: + subtensor: The SubtensorInterface instance for chain queries. + signed_extrinsic: The signed extrinsic to encrypt. + + Returns: + A MevShield.submit_encrypted call to be signed with the current nonce. + + Raises: + ValueError: If MEV Shield NextKey is not available on chain. + """ + + ml_kem_768_public_key = await subtensor.get_mev_shield_next_key() + if ml_kem_768_public_key is None: + raise ValueError("MEV Shield NextKey not available on chain") + + plaintext = bytes(signed_extrinsic.data.data) + + # Encrypt using ML-KEM-768 + ciphertext = encrypt_mlkem768(ml_kem_768_public_key, plaintext) + + # Commitment: blake2_256(payload_core) + commitment_hash = hashlib.blake2b(plaintext, digest_size=32).digest() + commitment_hex = "0x" + commitment_hash.hex() + + # Create the MevShield.submit_encrypted call + encrypted_call = await subtensor.substrate.compose_call( + call_module="MevShield", + call_function="submit_encrypted", + call_params={ + "commitment": commitment_hex, + "ciphertext": ciphertext, + }, + ) + + return encrypted_call + + +async def extract_mev_shield_id(response: "AsyncExtrinsicReceipt") -> Optional[str]: + """ + Extract the MEV Shield wrapper ID from an extrinsic response. + + After submitting a MEV Shield encrypted call, the EncryptedSubmitted event + contains the wrapper ID needed to track execution. + + Args: + response: The extrinsic receipt from submit_extrinsic. + + Returns: + The wrapper ID (hex string) or None if not found. + """ + for event in await response.triggered_events: + if event["event_id"] == "EncryptedSubmitted": + return event["attributes"]["id"] + return None + + +async def wait_for_extrinsic_by_hash( + subtensor: "SubtensorInterface", + extrinsic_hash: str, + shield_id: str, + submit_block_hash: str, + timeout_blocks: int = 2, + status=None, +) -> tuple[bool, Optional[str], Optional[AsyncExtrinsicReceipt]]: + """ + Wait for the result of a MeV Shield encrypted extrinsic. + + After submit_encrypted succeeds, the block author will decrypt and submit + the inner extrinsic directly. This function polls subsequent blocks looking + for either: + - an extrinsic matching the provided hash (success) + OR + - a markDecryptionFailed extrinsic with matching shield ID (failure) + + Args: + subtensor: SubtensorInterface instance. + extrinsic_hash: The hash of the inner extrinsic to find. + shield_id: The wrapper ID from EncryptedSubmitted event (for detecting decryption failures). + submit_block_hash: Block hash where submit_encrypted was included. + timeout_blocks: Max blocks to wait (default 2). + status: Optional rich.Status object for progress updates. + + Returns: + Tuple of (success: bool, error: Optional[str], receipt: Optional[AsyncExtrinsicReceipt]). + - (True, None, receipt) if extrinsic was found and succeeded. + - (False, error_message, receipt) if extrinsic was found but failed. + - (False, "Timeout...", None) if not found within timeout. + """ + + async def _noop(_): + return True + + starting_block = await subtensor.substrate.get_block_number(submit_block_hash) + current_block = starting_block + 1 + + while current_block - starting_block <= timeout_blocks: + if status: + status.update( + f"Waiting for :shield: MEV Protection " + f"(checking block {current_block - starting_block} of {timeout_blocks})..." + ) + + await subtensor.substrate.wait_for_block( + current_block, + result_handler=_noop, + task_return=False, + ) + + block_hash = await subtensor.substrate.get_block_hash(current_block) + extrinsics = await subtensor.substrate.get_extrinsics(block_hash) + + result_idx = None + for idx, extrinsic in enumerate(extrinsics): + # Success: Inner extrinsic executed + if f"0x{extrinsic.extrinsic_hash.hex()}" == extrinsic_hash: + result_idx = idx + break + + # Failure: Decryption failed + call = extrinsic.value.get("call", {}) + if ( + call.get("call_module") == "MevShield" + and call.get("call_function") == "mark_decryption_failed" + ): + call_args = call.get("call_args", []) + for arg in call_args: + if arg.get("name") == "id" and arg.get("value") == shield_id: + result_idx = idx + break + if result_idx is not None: + break + + if result_idx is not None: + receipt = AsyncExtrinsicReceipt( + substrate=subtensor.substrate, + block_hash=block_hash, + block_number=current_block, + extrinsic_idx=result_idx, + ) + + if not await receipt.is_success: + error_msg = format_error_message(await receipt.error_message) + return False, error_msg, receipt + + return True, None, receipt + + current_block += 1 + + return ( + False, + "Failed to find outcome of the shield extrinsic (The protected extrinsic wasn't decrypted)", + None, + ) diff --git a/bittensor_cli/src/bittensor/extrinsics/registration.py b/bittensor_cli/src/bittensor/extrinsics/registration.py index a32bc1c3d..0c4e4f585 100644 --- a/bittensor_cli/src/bittensor/extrinsics/registration.py +++ b/bittensor_cli/src/bittensor/extrinsics/registration.py @@ -681,6 +681,7 @@ async def burned_register_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = True, era: Optional[int] = None, + proxy: Optional[str] = None, ) -> tuple[bool, str, Optional[str]]: """Registers the wallet to chain by recycling TAO. @@ -693,7 +694,7 @@ async def burned_register_extrinsic( :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. :param era: the period (in blocks) for which the transaction should remain valid. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. + :param proxy: the proxy address to use for the call. :return: (success, msg), where success is `True` if extrinsic was finalized or included in the block. If we did not wait for finalization/inclusion, the response is `True`. @@ -758,7 +759,12 @@ async def burned_register_extrinsic( }, ) success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization, era=era_ + call, + wallet, + wait_for_inclusion, + wait_for_finalization, + era=era_, + proxy=proxy, ) if not success: @@ -1752,6 +1758,7 @@ async def swap_hotkey_extrinsic( wallet: Wallet, new_wallet: Wallet, netuid: Optional[int] = None, + proxy: Optional[str] = None, prompt: bool = False, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """ @@ -1837,7 +1844,7 @@ async def swap_hotkey_extrinsic( call_params=call_params, ) success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet + call=call, wallet=wallet, proxy=proxy ) if success: diff --git a/bittensor_cli/src/bittensor/extrinsics/root.py b/bittensor_cli/src/bittensor/extrinsics/root.py index ea515ed1a..f95d9990e 100644 --- a/bittensor_cli/src/bittensor/extrinsics/root.py +++ b/bittensor_cli/src/bittensor/extrinsics/root.py @@ -292,6 +292,7 @@ async def root_register_extrinsic( wallet: Wallet, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, + proxy: Optional[str] = None, ) -> tuple[bool, str, Optional[str]]: r"""Registers the wallet to root network. @@ -301,7 +302,7 @@ async def root_register_extrinsic( `False` if the extrinsic fails to enter the block within the timeout. :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. - :param prompt: If `True`, the call waits for confirmation from the user before proceeding. + :param proxy: Optional proxy to use for making the call. :return: (success, msg), with success being `True` if extrinsic was finalized or included in the block. If we did not wait for finalization/inclusion, the response is `True`. @@ -331,6 +332,7 @@ async def root_register_extrinsic( wallet=wallet, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + proxy=proxy, ) if not success: diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index 6a7976d11..cbc53683a 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -33,6 +33,8 @@ async def transfer_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, prompt: bool = False, + proxy: Optional[str] = None, + announce_only: bool = False, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Transfers funds from this wallet to the destination public key address. @@ -48,6 +50,9 @@ async def transfer_extrinsic( :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. :param prompt: If `True`, the call waits for confirmation from the user before proceeding. + :param proxy: Optional proxy to use for this call. + :param announce_only: If set along with proxy, will make this call as an announcement, rather than making the call + :return: success: Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for finalization / inclusion, the response is `True`, regardless of its inclusion. """ @@ -63,22 +68,11 @@ async def get_transfer_fee() -> Balance: call_function=call_function, call_params=call_params, ) + return await subtensor.get_extrinsic_fee( + call=call, keypair=wallet.coldkeypub, proxy=proxy + ) - try: - payment_info = await subtensor.substrate.get_payment_info( - call=call, keypair=wallet.coldkeypub - ) - except SubstrateRequestException as e: - payment_info = {"partial_fee": int(2e7)} # assume 0.02 Tao - err_console.print( - f":cross_mark: [red]Failed to get payment info[/red]:[bold white]\n" - f" {format_error_message(e)}[/bold white]\n" - f" Defaulting to default transfer fee: {payment_info['partialFee']}" - ) - - return Balance.from_rao(payment_info["partial_fee"]) - - async def do_transfer() -> tuple[bool, str, str, AsyncExtrinsicReceipt]: + async def do_transfer() -> tuple[bool, str, str, Optional[AsyncExtrinsicReceipt]]: """ Makes transfer from wallet to destination public key address. :return: success, block hash, formatted error message @@ -88,29 +82,17 @@ async def do_transfer() -> tuple[bool, str, str, AsyncExtrinsicReceipt]: call_function=call_function, call_params=call_params, ) - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, era={"period": era} - ) - response = await subtensor.substrate.submit_extrinsic( - extrinsic, - wait_for_inclusion=wait_for_inclusion, + success_, error_msg_, receipt_ = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, wait_for_finalization=wait_for_finalization, + wait_for_inclusion=wait_for_inclusion, + proxy=proxy, + era={"period": era}, + announce_only=announce_only, ) - # We only wait here if we expect finalization. - if not wait_for_finalization and not wait_for_inclusion: - return True, "", "", response - - # Otherwise continue with finalization. - if await response.is_success: - block_hash_ = response.block_hash - return True, block_hash_, "", response - else: - return ( - False, - "", - format_error_message(await response.error_message), - response, - ) + block_hash_ = receipt_.block_hash if receipt_ is not None else "" + return success_, block_hash_, error_msg_, receipt_ # Validate destination address. if not is_valid_bittensor_address_or_public_key(destination): @@ -142,36 +124,66 @@ async def do_transfer() -> tuple[bool, str, str, AsyncExtrinsicReceipt]: # check existential deposit and fee print_verbose("Fetching existential and fee", status) block_hash = await subtensor.substrate.get_chain_head() - account_balance, existential_deposit = await asyncio.gather( + if proxy: + proxy_balance = await subtensor.get_balance(proxy, block_hash=block_hash) + account_balance, existential_deposit, fee = await asyncio.gather( subtensor.get_balance( wallet.coldkeypub.ss58_address, block_hash=block_hash ), subtensor.get_existential_deposit(block_hash=block_hash), + get_transfer_fee(), ) - fee = await get_transfer_fee() if allow_death: # Check if the transfer should keep alive the account existential_deposit = Balance(0) - if account_balance < (amount + fee + existential_deposit) and not allow_death: - err_console.print( - ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" - f" balance: [bright_cyan]{account_balance}[/bright_cyan]\n" - f" amount: [bright_cyan]{amount}[/bright_cyan]\n" - f" for fee: [bright_cyan]{fee}[/bright_cyan]\n" - f" would bring you under the existential deposit: [bright_cyan]{existential_deposit}[/bright_cyan].\n" - f"You can try again with `--allow-death`." - ) - return False, None - elif account_balance < (amount + fee) and allow_death: - print_error( - ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" - f" balance: [bright_red]{account_balance}[/bright_red]\n" - f" amount: [bright_red]{amount}[/bright_red]\n" - f" for fee: [bright_red]{fee}[/bright_red]" - ) - return False, None + if proxy: + if proxy_balance < (amount + existential_deposit) and not allow_death: + err_console.print( + ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" + f" balance: [bright_cyan]{proxy_balance}[/bright_cyan]\n" + f" amount: [bright_cyan]{amount}[/bright_cyan]\n" + f" would bring you under the existential deposit: [bright_cyan]{existential_deposit}[/bright_cyan].\n" + f"You can try again with `--allow-death`." + ) + return False, None + if account_balance < fee: + err_console.print( + ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" + f" balance: [bright_cyan]{account_balance}[/bright_cyan]\n" + f" fee: [bright_cyan]{fee}[/bright_cyan]\n" + f" would bring you under the existential deposit: [bright_cyan]{existential_deposit}[/bright_cyan].\n" + ) + return False, None + if account_balance < amount and allow_death: + print_error( + ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" + f" balance: [bright_red]{account_balance}[/bright_red]\n" + f" amount: [bright_red]{amount}[/bright_red]\n" + ) + return False, None + else: + if account_balance < (amount + fee + existential_deposit) and not allow_death: + err_console.print( + ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" + f" balance: [bright_cyan]{account_balance}[/bright_cyan]\n" + f" amount: [bright_cyan]{amount}[/bright_cyan]\n" + f" for fee: [bright_cyan]{fee}[/bright_cyan]\n" + f" would bring you under the existential deposit: [bright_cyan]{existential_deposit}[/bright_cyan].\n" + f"You can try again with `--allow-death`." + ) + return False, None + elif account_balance < (amount + fee) and allow_death: + print_error( + ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" + f" balance: [bright_red]{account_balance}[/bright_red]\n" + f" amount: [bright_red]{amount}[/bright_red]\n" + f" for fee: [bright_red]{fee}[/bright_red]" + ) + return False, None + if proxy: + account_balance = proxy_balance # Ask before moving on. if prompt: @@ -213,7 +225,7 @@ async def do_transfer() -> tuple[bool, str, str, AsyncExtrinsicReceipt]: if success: with console.status(":satellite: Checking Balance...", spinner="aesthetic"): new_balance = await subtensor.get_balance( - wallet.coldkeypub.ss58_address, reuse_block=False + proxy or wallet.coldkeypub.ss58_address, reuse_block=False ) console.print( f"Balance:\n" diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 054d67f7a..10004797f 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1,7 +1,7 @@ import asyncio import os import time -from typing import Optional, Any, Union, TypedDict, Iterable +from typing import Optional, Any, Union, TypedDict, Iterable, Literal from async_substrate_interface import AsyncExtrinsicReceipt from async_substrate_interface.async_substrate import ( @@ -34,6 +34,7 @@ from bittensor_cli.src import DelegatesDetails from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float from bittensor_cli.src import Constants, defaults, TYPE_REGISTRY +from bittensor_cli.src.bittensor.extrinsics.mev_shield import encrypt_extrinsic from bittensor_cli.src.bittensor.utils import ( format_error_message, console, @@ -41,8 +42,9 @@ decode_hex_identity_dict, validate_chain_endpoint, u16_normalized_float, - U16_MAX, + MEV_SHIELD_PUBLIC_KEY_SIZE, get_hotkey_pub_ss58, + ProxyAnnouncements, ) GENESIS_ADDRESS = "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" @@ -652,6 +654,26 @@ async def subnet_exists( ) return result + async def total_networks( + self, block_hash: Optional[str] = None, reuse_block: bool = False + ) -> int: + """ + Returns the total number of subnets in the Bittensor network. + + :param block_hash: The hash of the blockchain block number at which to check the subnet existence. + :param reuse_block: Whether to reuse the last-used block hash. + + :return: The total number of subnets in the network. + """ + result = await self.query( + module="SubtensorModule", + storage_function="TotalNetworks", + params=[], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + return result + async def get_subnet_state( self, netuid: int, block_hash: Optional[str] = None ) -> Optional["SubnetState"]: @@ -1153,6 +1175,11 @@ async def sign_and_send_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, era: Optional[dict[str, int]] = None, + proxy: Optional[str] = None, + nonce: Optional[int] = None, + sign_with: Literal["coldkey", "hotkey", "coldkeypub"] = "coldkey", + announce_only: bool = False, + mev_protection: bool = False, ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """ Helper method to sign and submit an extrinsic call to chain. @@ -1162,18 +1189,74 @@ async def sign_and_send_extrinsic( :param wait_for_inclusion: whether to wait until the extrinsic call is included on the chain :param wait_for_finalization: whether to wait until the extrinsic call is finalized on the chain :param era: The length (in blocks) for which a transaction should be valid. - - :return: (success, error message) - """ - call_args: dict[str, Union[GenericCall, Keypair, dict[str, int]]] = { + :param proxy: The real account used to create the proxy. None if not using a proxy for this call. + :param nonce: The nonce used to submit this extrinsic call. + :param sign_with: Determine which of the wallet's keypairs to use to sign the extrinsic call. + :param announce_only: If set, makes the call as an announcement, rather than making the call. Cannot + be used with `mev_protection=True`. + :param mev_protection: If set, uses Mev Protection on the extrinsic, thus encrypting it. Cannot be + used with `announce_only=True`. + + :return: (success, error message or inner extrinsic hash (if using mev_protection), extrinsic receipt | None) + """ + + async def create_signed(call_to_sign, n): + kwargs = { + "call": call_to_sign, + "keypair": keypair, + "nonce": n, + } + if era is not None: + kwargs["era"] = era + return await self.substrate.create_signed_extrinsic(**kwargs) + + if announce_only and mev_protection: + raise ValueError( + "Cannot use announce-only and mev-protection. Calls should be announced without mev protection," + "and executed with them." + ) + if proxy is not None: + if announce_only: + call_to_announce = call + call = await self.substrate.compose_call( + "Proxy", + "announce", + { + "real": proxy, + "call_hash": f"0x{call_to_announce.call_hash.hex()}", + }, + ) + else: + call = await self.substrate.compose_call( + "Proxy", + "proxy", + {"real": proxy, "call": call, "force_proxy_type": None}, + ) + keypair = getattr(wallet, sign_with) + call_args: dict[str, Union[GenericCall, Keypair, dict[str, int], int]] = { "call": call, - "keypair": wallet.coldkey, + # sign with specified key + "keypair": keypair, } if era is not None: call_args["era"] = era - extrinsic = await self.substrate.create_signed_extrinsic( - **call_args - ) # sign with coldkey + if nonce is not None: + call_args["nonce"] = nonce + else: + call_args["nonce"] = await self.substrate.get_account_next_index( + keypair.ss58_address + ) + inner_hash = "" + if mev_protection: + next_nonce = await self.substrate.get_account_next_index( + keypair.ss58_address + ) + inner_extrinsic = await create_signed(call, next_nonce) + inner_hash = f"0x{inner_extrinsic.extrinsic_hash.hex()}" + shield_call = await encrypt_extrinsic(self, inner_extrinsic) + extrinsic = await create_signed(shield_call, nonce) + else: + extrinsic = await self.substrate.create_signed_extrinsic(**call_args) try: response = await self.substrate.submit_extrinsic( extrinsic, @@ -1182,13 +1265,42 @@ async def sign_and_send_extrinsic( ) # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - return True, "", response + return True, inner_hash, response if await response.is_success: - return True, "", response + if announce_only: + block = await self.substrate.get_block_number(response.block_hash) + with ProxyAnnouncements.get_db() as (conn, cursor): + ProxyAnnouncements.add_entry( + conn, + cursor, + address=proxy, + epoch_time=int(time.time()), + block=block, + call_hash=call_to_announce.call_hash.hex(), + call=call_to_announce, + ) + console.print( + f"Added entry {call_to_announce.call_hash} at block {block} to your ProxyAnnouncements address book." + ) + return True, inner_hash, response else: return False, format_error_message(await response.error_message), None except SubstrateRequestException as e: - return False, format_error_message(e), None + err_msg = format_error_message(e) + if proxy and "Invalid Transaction" in err_msg: + extrinsic_fee, signer_balance = await asyncio.gather( + self.get_extrinsic_fee( + call, keypair=wallet.coldkeypub, proxy=proxy + ), + self.get_balance(wallet.coldkeypub.ss58_address), + ) + if extrinsic_fee > signer_balance: + err_msg += ( + "\nAs this is a proxy transaction, the signing account needs to pay the extrinsic fee. " + f"However, the balance of the signing account is {signer_balance}, and the extrinsic fee is " + f"{extrinsic_fee}." + ) + return False, err_msg, None async def get_children(self, hotkey, netuid) -> tuple[bool, list, str]: """ @@ -1595,16 +1707,25 @@ async def get_owned_hotkeys( return [decode_account_id(hotkey[0]) for hotkey in owned_hotkeys or []] - async def get_extrinsic_fee(self, call: GenericCall, keypair: Keypair) -> Balance: + async def get_extrinsic_fee( + self, call: GenericCall, keypair: Keypair, proxy: Optional[str] = None + ) -> Balance: """ Determines the fee for the extrinsic call. Args: call: Created extrinsic call keypair: The keypair that would sign the extrinsic (usually you would just want to use the *pub for this) + proxy: Optional proxy for the extrinsic call Returns: Balance object representing the fee for this extrinsic. """ + if proxy is not None: + call = await self.substrate.compose_call( + "Proxy", + "proxy", + {"real": proxy, "call": call, "force_proxy_type": None}, + ) fee_dict = await self.substrate.get_payment_info(call, keypair) return Balance.from_rao(fee_dict["partial_fee"]) @@ -1832,13 +1953,14 @@ async def get_coldkey_claim_type( coldkey_ss58: str, block_hash: Optional[str] = None, reuse_block: bool = False, - ) -> str: + ) -> dict: """ Retrieves the root claim type for a specific coldkey. Root claim types control how staking emissions are handled on the ROOT network (subnet 0): - "Swap": Future Root Alpha Emissions are swapped to TAO at claim time and added to your root stake - "Keep": Future Root Alpha Emissions are kept as Alpha + - "KeepSubnets": Specific subnets kept as Alpha, rest swapped to TAO Args: coldkey_ss58: The SS58 address of the coldkey to query. @@ -1846,7 +1968,10 @@ async def get_coldkey_claim_type( reuse_block: Whether to reuse the last-used blockchain block hash. Returns: - str: The root claim type for the coldkey ("Swap" or "Keep"). + dict: Claim type information in one of these formats: + - {"type": "Swap"} + - {"type": "Keep"} + - {"type": "KeepSubnets", "subnets": [1, 5, 10, ...]} """ result = await self.query( module="SubtensorModule", @@ -1857,14 +1982,22 @@ async def get_coldkey_claim_type( ) if result is None: - return "Swap" - return next(iter(result.keys())) + return {"type": "Swap"} + + claim_type_key = next(iter(result.keys())) + + if claim_type_key == "KeepSubnets": + subnets_data = result["KeepSubnets"]["subnets"] + subnet_list = sorted([subnet for subnet in subnets_data[0]]) + return {"type": "KeepSubnets", "subnets": subnet_list} + else: + return {"type": claim_type_key} async def get_all_coldkeys_claim_type( self, block_hash: Optional[str] = None, reuse_block: bool = False, - ) -> dict[str, str]: + ) -> dict[str, dict]: """ Retrieves all root claim types for all coldkeys in the network. @@ -1873,7 +2006,7 @@ async def get_all_coldkeys_claim_type( reuse_block: Whether to reuse the last-used blockchain block hash. Returns: - dict[str, str]: A dictionary mapping coldkey SS58 addresses to their root claim type ("Keep" or "Swap"). + dict[str, dict]: Mapping of coldkey SS58 addresses to claim type dicts """ result = await self.substrate.query_map( module="SubtensorModule", @@ -1884,10 +2017,20 @@ async def get_all_coldkeys_claim_type( ) root_claim_types = {} - async for coldkey, claim_type in result: + async for coldkey, claim_type_data in result: coldkey_ss58 = decode_account_id(coldkey[0]) - claim_type = next(iter(claim_type.value.keys())) - root_claim_types[coldkey_ss58] = claim_type + + claim_type_key = next(iter(claim_type_data.value.keys())) + + if claim_type_key == "KeepSubnets": + subnets_data = claim_type_data.value["KeepSubnets"]["subnets"] + subnet_list = sorted([subnet for subnet in subnets_data[0]]) + root_claim_types[coldkey_ss58] = { + "type": "KeepSubnets", + "subnets": subnet_list, + } + else: + root_claim_types[coldkey_ss58] = {"type": claim_type_key} return root_claim_types @@ -2175,8 +2318,8 @@ async def get_claimable_stakes_for_coldkey( root_stake: Balance claimable_stake: Balance for hotkey, netuid in target_pairs: - root_stake = root_stakes[hotkey] - rate = claimable_rates[hotkey].get(netuid, 0.0) + root_stake = root_stakes.get(hotkey, Balance(0)) + rate = claimable_rates.get(hotkey, {}).get(netuid, 0.0) claimable_stake = rate * root_stake already_claimed = claimed_amounts.get((hotkey, netuid), Balance(0)) net_claimable = max(claimable_stake - already_claimed, Balance(0)) @@ -2198,6 +2341,7 @@ async def get_subnet_price( :return: The current Alpha price in TAO units for the specified subnet. """ + # TODO update this to use the runtime call SwapRuntimeAPI.current_alpha_price current_sqrt_price = await self.query( module="Swap", storage_function="AlphaSqrtPrice", @@ -2265,7 +2409,7 @@ async def get_all_subnet_ema_tao_inflow( ema_map[netuid] = Balance.from_rao(0) else: _, raw_ema_value = value - ema_value = fixed_to_float(raw_ema_value) + ema_value = int(fixed_to_float(raw_ema_value)) ema_map[netuid] = Balance.from_rao(ema_value) return ema_map @@ -2296,9 +2440,65 @@ async def get_subnet_ema_tao_inflow( if not value: return Balance.from_rao(0) _, raw_ema_value = value - ema_value = fixed_to_float(raw_ema_value) + ema_value = int(fixed_to_float(raw_ema_value)) return Balance.from_rao(ema_value) + async def get_mev_shield_next_key( + self, + block_hash: Optional[str] = None, + ) -> bytes: + """ + Get the next MEV Shield public key and epoch from chain storage. + + Args: + block_hash: Optional block hash to query at. + + Returns: + Tuple of (public_key_bytes, epoch) or None if not available. + """ + result = await self.query( + module="MevShield", + storage_function="NextKey", + block_hash=block_hash, + ) + public_key_bytes = bytes(next(iter(result))) + + if len(public_key_bytes) != MEV_SHIELD_PUBLIC_KEY_SIZE: + raise ValueError( + f"Invalid ML-KEM-768 public key size: {len(public_key_bytes)} bytes. " + f"Expected exactly {MEV_SHIELD_PUBLIC_KEY_SIZE} bytes." + ) + + return public_key_bytes + + async def get_mev_shield_current_key( + self, + block_hash: Optional[str] = None, + ) -> bytes: + """ + Get the current MEV Shield public key and epoch from chain storage. + + Args: + block_hash: Optional block hash to query at. + + Returns: + Tuple of (public_key_bytes, epoch) or None if not available. + """ + result = await self.query( + module="MevShield", + storage_function="CurrentKey", + block_hash=block_hash, + ) + public_key_bytes = bytes(next(iter(result))) + + if len(public_key_bytes) != MEV_SHIELD_PUBLIC_KEY_SIZE: + raise ValueError( + f"Invalid ML-KEM-768 public key size: {len(public_key_bytes)} bytes. " + f"Expected exactly {MEV_SHIELD_PUBLIC_KEY_SIZE} bytes." + ) + + return public_key_bytes + async def best_connection(networks: list[str]): """ diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index f8e322f01..ca4b56099 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -1,11 +1,13 @@ import ast +import json from collections import namedtuple import math import os import sqlite3 import webbrowser +from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Collection, Optional, Union, Callable +from typing import TYPE_CHECKING, Any, Collection, Optional, Union, Callable, Generator from urllib.parse import urlparse from functools import partial import re @@ -22,6 +24,7 @@ from numpy.typing import NDArray from rich.console import Console from rich.prompt import Prompt +from scalecodec import GenericCall from scalecodec.utils.ss58 import ss58_encode, ss58_decode import typer @@ -37,6 +40,7 @@ BT_DOCS_LINK = "https://docs.learnbittensor.org" GLOBAL_MAX_SUBNET_COUNT = 4096 +MEV_SHIELD_PUBLIC_KEY_SIZE = 1184 console = Console() json_console = Console() @@ -404,6 +408,15 @@ def is_valid_ss58_address(address: str) -> bool: return False +def is_valid_ss58_address_prompt(text: str) -> str: + valid = False + address = "" + while not valid: + address = Prompt.ask(text).strip() + valid = is_valid_ss58_address(address) + return address + + def is_valid_ed25519_pubkey(public_key: Union[str, bytes]) -> bool: """ Checks if the given public_key is a valid ed25519 key. @@ -806,16 +819,302 @@ def normalize_hyperparameters( return normalized_values +class TableDefinition: + """ + Base class for address book table definitions/functions + """ + + name: str + cols: tuple[tuple[str, str], ...] + + @staticmethod + @contextmanager + def get_db() -> Generator[tuple[sqlite3.Connection, sqlite3.Cursor], None, None]: + """ + Helper function to get a DB connection + """ + with DB() as (conn, cursor): + yield conn, cursor + + @classmethod + def create_if_not_exists(cls, conn: sqlite3.Connection, _: sqlite3.Cursor) -> None: + """ + Creates the table if it doesn't exist. + Args: + conn: sqlite3 connection + _: sqlite3 cursor + """ + columns_ = ", ".join([" ".join(x) for x in cls.cols]) + conn.execute(f"CREATE TABLE IF NOT EXISTS {cls.name} ({columns_})") + conn.commit() + + @classmethod + def read_rows( + cls, + _: sqlite3.Connection, + cursor: sqlite3.Cursor, + include_header: bool = True, + ) -> list[tuple[Union[str, int], ...]]: + """ + Reads rows from a table. + + Args: + _: sqlite3 connection + cursor: sqlite3 cursor + include_header: Whether to include the header row + + Returns: + rows of the table, with column names as the header row if `include_header` is set + + """ + header = tuple(x[0] for x in cls.cols) + cols = ", ".join(header) + cursor.execute(f"SELECT {cols} FROM {cls.name}") + rows = cursor.fetchall() + if not include_header: + return rows + else: + return [header] + rows + + @classmethod + def update_entry(cls, *args, **kwargs): + """ + Updates an existing entry in the table. + """ + raise NotImplementedError() + + @classmethod + def add_entry(cls, *args, **kwargs): + """ + Adds an entry to the table. + """ + raise NotImplementedError() + + @classmethod + def delete_entry(cls, *args, **kwargs): + """ + Deletes an entry from the table. + """ + raise NotImplementedError() + + +class AddressBook(TableDefinition): + name = "address_book" + cols = (("name", "TEXT"), ("ss58_address", "TEXT"), ("note", "TEXT")) + + @classmethod + def add_entry( + cls, + conn: sqlite3.Connection, + _: sqlite3.Cursor, + *, + name: str, + ss58_address: str, + note: str, + ) -> None: + conn.execute( + f"INSERT INTO {cls.name} (name, ss58_address, note) VALUES (?, ?, ?)", + (name, ss58_address, note), + ) + conn.commit() + + @classmethod + def update_entry( + cls, + conn: sqlite3.Connection, + cursor: sqlite3.Cursor, + *, + name: str, + ss58_address: Optional[str] = None, + note: Optional[str] = None, + ): + cursor.execute( + f"SELECT ss58_address, note FROM {cls.name} WHERE name = ?", + (name,), + ) + row = cursor.fetchone()[0] + ss58_address_ = ss58_address or row[0] + note_ = note or row[1] + conn.execute( + f"UPDATE {cls.name} SET ss58_address = ?, note = ? WHERE name = ?", + (ss58_address_, note_, name), + ) + conn.commit() + + @classmethod + def delete_entry( + cls, conn: sqlite3.Connection, cursor: sqlite3.Cursor, *, name: str + ): + conn.execute( + f"DELETE FROM {cls.name} WHERE name = ?", + (name,), + ) + conn.commit() + + +class ProxyAddressBook(TableDefinition): + name = "proxy_address_book" + cols = ( + ("name", "TEXT"), + ("ss58_address", "TEXT"), + ("delay", "INTEGER"), + ("spawner", "TEXT"), + ("proxy_type", "TEXT"), + ("note", "TEXT"), + ) + + @classmethod + def update_entry( + cls, + conn: sqlite3.Connection, + cursor: sqlite3.Cursor, + *, + name: str, + ss58_address: Optional[str] = None, + delay: Optional[int] = None, + spawner: Optional[str] = None, + proxy_type: Optional[str] = None, + note: Optional[str] = None, + ) -> None: + cursor.execute( + f"SELECT ss58_address, spawner, proxy_type, delay, note FROM {cls.name} WHERE name = ?", + (name,), + ) + row = cursor.fetchone()[0] + ss58_address_ = ss58_address or row[0] + spawner_ = spawner or row[1] + proxy_type_ = proxy_type or row[2] + delay = delay if delay is not None else row[3] + note_ = note or row[4] + conn.execute( + f"UPDATE {cls.name} SET ss58_address = ?, spawner = ?, proxy_type = ?, delay = ?, note = ? WHERE name = ?", + (ss58_address_, spawner_, proxy_type_, note_, delay, name), + ) + conn.commit() + + @classmethod + def add_entry( + cls, + conn: sqlite3.Connection, + _: sqlite3.Cursor, + *, + name: str, + ss58_address: str, + delay: int, + spawner: str, + proxy_type: str, + note: str, + ) -> None: + conn.execute( + f"INSERT INTO {cls.name} (name, ss58_address, delay, spawner, proxy_type, note) VALUES (?, ?, ?, ?, ?, ?)", + (name, ss58_address, delay, spawner, proxy_type, note), + ) + conn.commit() + + @classmethod + def delete_entry( + cls, + conn: sqlite3.Connection, + _: sqlite3.Cursor, + *, + name: str, + ): + conn.execute( + f"DELETE FROM {cls.name} WHERE name = ?", + (name,), + ) + conn.commit() + + +class ProxyAnnouncements(TableDefinition): + name = "proxy_announcements" + cols = ( + ("id", "INTEGER PRIMARY KEY"), + ("address", "TEXT"), + ("epoch_time", "INTEGER"), + ("block", "INTEGER"), + ("call_hash", "TEXT"), + ("call", "TEXT"), + ("call_serialized", "TEXT"), + ("executed", "INTEGER"), + ) + + @classmethod + def add_entry( + cls, + conn: sqlite3.Connection, + _: sqlite3.Cursor, + *, + address: str, + epoch_time: int, + block: int, + call_hash: str, + call: GenericCall, + executed: bool = False, + ) -> None: + call_hex = call.data.to_hex() + call_serialized = json.dumps(call.serialize()) + executed_int = int(executed) + conn.execute( + f"INSERT INTO {cls.name} (address, epoch_time, block, call_hash, call, call_serialized, executed)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + address, + epoch_time, + block, + call_hash, + call_hex, + call_serialized, + executed_int, + ), + ) + conn.commit() + + @classmethod + def delete_entry( + cls, + conn: sqlite3.Connection, + _: sqlite3.Cursor, + *, + address: str, + epoch_time: int, + block: int, + call_hash: str, + ): + conn.execute( + f"DELETE FROM {cls.name} WHERE call_hash = ?, address = ?, epoch_time = ?, block = ?", + (call_hash, address, epoch_time, block), + ) + conn.commit() + + @classmethod + def mark_as_executed(cls, conn: sqlite3.Connection, _: sqlite3.Cursor, idx: int): + conn.execute( + f"UPDATE {cls.name} SET executed = ? WHERE id = ?", + (1, idx), + ) + conn.commit() + + class DB: """ For ease of interaction with the SQLite database used for --reuse-last and --html outputs of tables + + Also for address book """ def __init__( self, - db_path: str = os.path.expanduser("~/.bittensor/bittensor.db"), + db_path: Optional[str] = None, row_factory=None, ): + if db_path is None: + if path_from_env := os.getenv("BTCLI_PROXIES_PATH"): + db_path = path_from_env + else: + db_path = os.path.join( + os.path.expanduser(defaults.config.base_path), "bittensor.db" + ) self.db_path = db_path self.conn: Optional[sqlite3.Connection] = None self.row_factory = row_factory @@ -830,10 +1129,15 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close() -def create_table(title: str, columns: list[tuple[str, str]], rows: list[list]) -> None: +def create_and_populate_table( + title: str, columns: list[tuple[str, str]], rows: list[list] +) -> None: """ Creates and populates the rows of a table in the SQLite database. + Warning: + Will overwrite the existing table. + :param title: title of the table :param columns: [(column name, column type), ...] :param rows: [[element, element, ...], ...] @@ -853,8 +1157,7 @@ def create_table(title: str, columns: list[tuple[str, str]], rows: list[list]) - conn.commit() columns_ = ", ".join([" ".join(x) for x in columns]) creation_query = f"CREATE TABLE IF NOT EXISTS {title} ({columns_})" - conn.commit() - cursor.execute(creation_query) + conn.execute(creation_query) conn.commit() query = f"INSERT INTO {title} ({', '.join([x[0] for x in columns])}) VALUES ({', '.join(['?'] * len(columns))})" cursor.executemany(query, rows) @@ -1026,6 +1329,17 @@ def render_tree( webbrowser.open(f"file://{output_file}") +def ensure_address_book_tables_exist(): + """ + Creates address book tables if they don't exist. + + Should be run at startup to ensure that the address book tables exist. + """ + with DB() as (conn, cursor): + for table in (AddressBook, ProxyAddressBook, ProxyAnnouncements): + table.create_if_not_exists(conn, cursor) + + def group_subnets(registrations): if not registrations: return "" @@ -1051,6 +1365,58 @@ def group_subnets(registrations): return ", ".join(ranges) +def parse_subnet_range(input_str: str, total_subnets: int) -> list[int]: + """ + Parse subnet range input like "1-24, 30-40, 5". + + Args: + input_str: Comma-separated list of subnets and ranges + Examples: "1-5", "1,2,3", "1-5, 10, 20-25" + total_subnets: Total number of subnets available + + Returns: + Sorted list of unique subnet IDs + + Raises: + ValueError: If input format is invalid + + Examples: + >>> parse_subnet_range("1-5, 10") + [1, 2, 3, 4, 5, 10] + >>> parse_subnet_range("5, 3, 1") + [1, 3, 5] + """ + subnets = set() + parts = [p.strip() for p in input_str.split(",") if p.strip()] + for part in parts: + if "-" in part: + try: + start, end = part.split("-", 1) + start_num = int(start.strip()) + end_num = int(end.strip()) + + if start_num > end_num: + raise ValueError(f"Invalid range '{part}': start must be ≤ end") + + if end_num - start_num > total_subnets: + raise ValueError( + f"Range '{part}' is not valid (total of {total_subnets} subnets)" + ) + + subnets.update(range(start_num, end_num + 1)) + except ValueError as e: + if "invalid literal" in str(e): + raise ValueError(f"Invalid range '{part}': must be 'start-end'") + raise + else: + try: + subnets.add(int(part)) + except ValueError: + raise ValueError(f"Invalid subnet ID '{part}': must be a number") + + return sorted(subnets) + + def validate_chain_endpoint(endpoint_url) -> tuple[bool, str]: parsed = urlparse(endpoint_url) if parsed.scheme not in ("ws", "wss"): diff --git a/bittensor_cli/src/commands/crowd/contribute.py b/bittensor_cli/src/commands/crowd/contribute.py index 480f6a7fa..fef4d7f65 100644 --- a/bittensor_cli/src/commands/crowd/contribute.py +++ b/bittensor_cli/src/commands/crowd/contribute.py @@ -53,6 +53,7 @@ def validate_for_contribution( async def contribute_to_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, + proxy: Optional[str], crowdloan_id: int, amount: Optional[float], prompt: bool, @@ -65,11 +66,13 @@ async def contribute_to_crowdloan( Args: subtensor: SubtensorInterface object for chain interaction wallet: Wallet object containing coldkey for contribution + proxy: Optional proxy to use for this extrinsic crowdloan_id: ID of the crowdloan to contribute to amount: Amount to contribute in TAO (None to prompt) prompt: Whether to prompt for confirmation wait_for_inclusion: Wait for transaction inclusion wait_for_finalization: Wait for transaction finalization + json_output: Whether to output JSON output or human-readable text Returns: tuple[bool, str]: Success status and message @@ -96,7 +99,7 @@ async def contribute_to_crowdloan( print_error(f"[red]{error_message}[/red]") return False, error_message - contributor_address = wallet.coldkeypub.ss58_address + contributor_address = proxy or wallet.coldkeypub.ss58_address current_contribution, user_balance, _ = await asyncio.gather( subtensor.get_crowdloan_contribution(crowdloan_id, contributor_address), subtensor.get_balance(contributor_address), @@ -159,8 +162,13 @@ async def contribute_to_crowdloan( "amount": contribution_amount.rao, }, ) - extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) - updated_balance = user_balance - actual_contribution - extrinsic_fee + extrinsic_fee = await subtensor.get_extrinsic_fee( + call, wallet.coldkeypub, proxy=proxy + ) + if proxy: + updated_balance = user_balance - actual_contribution + else: + updated_balance = user_balance - actual_contribution - extrinsic_fee table = Table( Column("[bold white]Field", style=COLORS.G.SUBHEAD), @@ -243,6 +251,7 @@ async def contribute_to_crowdloan( ) = await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) @@ -345,6 +354,7 @@ async def contribute_to_crowdloan( async def withdraw_from_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, + proxy: Optional[str], crowdloan_id: int, wait_for_inclusion: bool, wait_for_finalization: bool, @@ -360,10 +370,12 @@ async def withdraw_from_crowdloan( Args: subtensor: SubtensorInterface instance for blockchain interaction wallet: Wallet instance containing the user's keys + proxy: Optional proxy to use with this extrinsic submission. crowdloan_id: The ID of the crowdloan to withdraw from wait_for_inclusion: Whether to wait for transaction inclusion wait_for_finalization: Whether to wait for transaction finalization prompt: Whether to prompt for user confirmation + json_output: Whether to output the results as JSON or human-readable Returns: Tuple of (success, message) indicating the result @@ -390,11 +402,12 @@ async def withdraw_from_crowdloan( print_error(f"[red]{error_msg}[/red]") return False, "Cannot withdraw from finalized crowdloan." + contributor_address = proxy or wallet.coldkeypub.ss58_address user_contribution, user_balance = await asyncio.gather( subtensor.get_crowdloan_contribution( - crowdloan_id, wallet.coldkeypub.ss58_address + crowdloan_id, ), - subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_balance(contributor_address), ) if user_contribution == Balance.from_tao(0): @@ -429,7 +442,9 @@ async def withdraw_from_crowdloan( "crowdloan_id": crowdloan_id, }, ) - extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + extrinsic_fee = await subtensor.get_extrinsic_fee( + call, wallet.coldkeypub, proxy=proxy + ) await show_crowdloan_details( subtensor=subtensor, crowdloan_id=crowdloan_id, @@ -440,7 +455,10 @@ async def withdraw_from_crowdloan( ) if prompt: - new_balance = user_balance + withdrawable - extrinsic_fee + if proxy: + new_balance = user_balance + withdrawable + else: + new_balance = user_balance + withdrawable - extrinsic_fee new_raised = crowdloan.raised - withdrawable table = Table( Column("[bold white]Field", style=COLORS.G.SUBHEAD), @@ -518,6 +536,7 @@ async def withdraw_from_crowdloan( ) = await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) @@ -539,10 +558,8 @@ async def withdraw_from_crowdloan( return False, error_message or "Failed to withdraw from crowdloan." new_balance, updated_contribution, updated_crowdloan = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address), - subtensor.get_crowdloan_contribution( - crowdloan_id, wallet.coldkeypub.ss58_address - ), + subtensor.get_balance(contributor_address), + subtensor.get_crowdloan_contribution(crowdloan_id, contributor_address), subtensor.get_single_crowdloan(crowdloan_id), ) diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py index 2c2625b2f..cb1ab4558 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -5,6 +5,7 @@ from bittensor_wallet import Wallet from rich.prompt import Confirm, IntPrompt, Prompt, FloatPrompt from rich.table import Table, Column, box +from scalecodec import GenericCall from bittensor_cli.src import COLORS from bittensor_cli.src.commands.crowd.view import show_crowdloan_details @@ -25,6 +26,7 @@ async def create_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, + proxy: Optional[str], deposit_tao: Optional[int], min_contribution_tao: Optional[int], cap_tao: Optional[int], @@ -53,7 +55,7 @@ async def create_crowdloan( print_error(f"[red]{unlock_status.message}[/red]") return False, unlock_status.message - crowdloan_type = None + crowdloan_type: str if subnet_lease is not None: crowdloan_type = "subnet" if subnet_lease else "fundraising" elif prompt: @@ -125,7 +127,7 @@ async def create_crowdloan( else: print_error(f"[red]{error_msg}[/red]") return False, "Missing required options when prompts are disabled." - + duration = 0 deposit_value = deposit_tao while True: if deposit_value is None: @@ -210,8 +212,8 @@ async def create_crowdloan( break current_block = await subtensor.substrate.get_block_number(None) - call_to_attach = None - + call_to_attach: Optional[GenericCall] + lease_perpetual = None if crowdloan_type == "subnet": target_address = None @@ -266,7 +268,9 @@ async def create_crowdloan( call_to_attach = None - creator_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + creator_balance = await subtensor.get_balance( + proxy or wallet.coldkeypub.ss58_address + ) if deposit > creator_balance: print_error( f"[red]Insufficient balance to cover the deposit. " @@ -289,7 +293,9 @@ async def create_crowdloan( }, ) - extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + extrinsic_fee = await subtensor.get_extrinsic_fee( + call, wallet.coldkeypub, proxy=proxy + ) if prompt: duration_text = blocks_to_duration(duration) @@ -334,7 +340,9 @@ async def create_crowdloan( table.add_row("Duration", f"[bold]{duration}[/bold] blocks (~{duration_text})") table.add_row("Ends at block", f"[bold]{end_block}[/bold]") table.add_row( - "Estimated fee", f"[{COLORS.P.TAO}]{extrinsic_fee}[/{COLORS.P.TAO}]" + "Estimated fee", + f"[{COLORS.P.TAO}]{extrinsic_fee}[/{COLORS.P.TAO}]" + + (" (paid by real account)" if proxy else ""), ) console.print(table) @@ -352,6 +360,7 @@ async def create_crowdloan( success, error_message, extrinsic_receipt = await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) @@ -432,6 +441,7 @@ async def create_crowdloan( async def finalize_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, + proxy: Optional[str], crowdloan_id: int, wait_for_inclusion: bool, wait_for_finalization: bool, @@ -449,10 +459,12 @@ async def finalize_crowdloan( Args: subtensor: SubtensorInterface instance for blockchain interaction wallet: Wallet instance containing the user's keys + proxy: Optional proxy to use for this extrinsic submission crowdloan_id: The ID of the crowdloan to finalize wait_for_inclusion: Whether to wait for transaction inclusion wait_for_finalization: Whether to wait for transaction finalization prompt: Whether to prompt for user confirmation + json_output: Whether to output the crowdloan info as JSON or human-readable Returns: Tuple of (success, message) indicating the result @@ -512,7 +524,9 @@ async def finalize_crowdloan( "crowdloan_id": crowdloan_id, }, ) - extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + extrinsic_fee = await subtensor.get_extrinsic_fee( + call, wallet.coldkeypub, proxy=proxy + ) await show_crowdloan_details( subtensor=subtensor, @@ -558,7 +572,11 @@ async def finalize_crowdloan( else: table.add_row("Call to Execute", "[dim]None[/dim]") - table.add_row("Transaction Fee", str(extrinsic_fee)) + table.add_row( + "Transaction Fee", + f"[{COLORS.S.TAO}]{extrinsic_fee.tao}[/{COLORS.S.TAO}]" + + (" (paid by real account)" if proxy else ""), + ) table.add_section() table.add_row( @@ -601,6 +619,7 @@ async def finalize_crowdloan( wallet=wallet, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + proxy=proxy, ) if not success: diff --git a/bittensor_cli/src/commands/crowd/dissolve.py b/bittensor_cli/src/commands/crowd/dissolve.py index b7513fb19..ce3f6c145 100644 --- a/bittensor_cli/src/commands/crowd/dissolve.py +++ b/bittensor_cli/src/commands/crowd/dissolve.py @@ -1,5 +1,6 @@ import asyncio import json +from typing import Optional from bittensor_wallet import Wallet from rich.prompt import Confirm @@ -21,6 +22,7 @@ async def dissolve_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, + proxy: Optional[str], crowdloan_id: int, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, @@ -35,10 +37,12 @@ async def dissolve_crowdloan( Args: subtensor: SubtensorInterface object for chain interaction. wallet: Wallet object containing the creator's coldkey. + proxy: Optional proxy to use for this extrinsic submission crowdloan_id: ID of the crowdloan to dissolve. wait_for_inclusion: Wait for transaction inclusion. wait_for_finalization: Wait for transaction finalization. prompt: Whether to prompt for confirmation. + json_output: Whether to output the results as JSON or human-readable. Returns: tuple[bool, str]: Success status and message. @@ -172,6 +176,7 @@ async def dissolve_crowdloan( ) = await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) diff --git a/bittensor_cli/src/commands/crowd/refund.py b/bittensor_cli/src/commands/crowd/refund.py index d08f91291..be264b3de 100644 --- a/bittensor_cli/src/commands/crowd/refund.py +++ b/bittensor_cli/src/commands/crowd/refund.py @@ -1,5 +1,6 @@ import asyncio import json +from typing import Optional from bittensor_wallet import Wallet from rich.prompt import Confirm @@ -22,6 +23,7 @@ async def refund_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, + proxy: Optional[str], crowdloan_id: int, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, @@ -40,10 +42,12 @@ async def refund_crowdloan( Args: subtensor: SubtensorInterface object for chain interaction wallet: Wallet object containing coldkey (any wallet can call this) + proxy: Optional proxy to use for extrinsic submission crowdloan_id: ID of the crowdloan to refund wait_for_inclusion: Wait for transaction inclusion wait_for_finalization: Wait for transaction finalization prompt: Whether to prompt for confirmation + json_output: Whether to output as JSON or human-readable Returns: tuple[bool, str]: Success status and message @@ -183,6 +187,7 @@ async def refund_crowdloan( ) = await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) diff --git a/bittensor_cli/src/commands/crowd/update.py b/bittensor_cli/src/commands/crowd/update.py index 2b2ee04f1..39ac69c64 100644 --- a/bittensor_cli/src/commands/crowd/update.py +++ b/bittensor_cli/src/commands/crowd/update.py @@ -24,6 +24,7 @@ async def update_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, + proxy: Optional[str], crowdloan_id: int, min_contribution: Optional[Balance] = None, end: Optional[int] = None, @@ -38,6 +39,7 @@ async def update_crowdloan( Args: subtensor: SubtensorInterface object for chain interaction wallet: Wallet object containing coldkey (must be creator) + proxy: Optional proxy to use for this extrinsic submissions crowdloan_id: ID of the crowdloan to update min_contribution: New minimum contribution in TAO (None to prompt) end: New end block (None to prompt) @@ -45,6 +47,7 @@ async def update_crowdloan( wait_for_inclusion: Wait for transaction inclusion wait_for_finalization: Wait for transaction finalization prompt: Whether to prompt for values + json_output: Whether to output JSON or human-readable Returns: tuple[bool, str]: Success status and message @@ -368,6 +371,7 @@ async def update_crowdloan( ) = await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) diff --git a/bittensor_cli/src/commands/liquidity/liquidity.py b/bittensor_cli/src/commands/liquidity/liquidity.py index a262e8874..48383e605 100644 --- a/bittensor_cli/src/commands/liquidity/liquidity.py +++ b/bittensor_cli/src/commands/liquidity/liquidity.py @@ -33,6 +33,7 @@ async def add_liquidity_extrinsic( wallet: "Wallet", hotkey_ss58: str, netuid: int, + proxy: Optional[str], liquidity: Balance, price_low: Balance, price_high: Balance, @@ -47,6 +48,7 @@ async def add_liquidity_extrinsic( wallet: The wallet used to sign the extrinsic (must be unlocked). hotkey_ss58: the SS58 of the hotkey to use for this transaction. netuid: The UID of the target subnet for which the call is being initiated. + proxy: Optional proxy to use for this extrinsic submission. liquidity: The amount of liquidity to be added. price_low: The lower bound of the price tick range. price_high: The upper bound of the price tick range. @@ -54,9 +56,10 @@ async def add_liquidity_extrinsic( wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. + tuple: + bool: True if successful, False otherwise. + str: success message if successful, error message otherwise. + AsyncExtrinsicReceipt: extrinsic receipt if successful, None otherwise. Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. @@ -82,6 +85,7 @@ async def add_liquidity_extrinsic( return await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) @@ -92,6 +96,7 @@ async def modify_liquidity_extrinsic( wallet: "Wallet", hotkey_ss58: str, netuid: int, + proxy: Optional[str], position_id: int, liquidity_delta: Balance, wait_for_inclusion: bool = True, @@ -104,15 +109,17 @@ async def modify_liquidity_extrinsic( wallet: The wallet used to sign the extrinsic (must be unlocked). hotkey_ss58: the SS58 of the hotkey to use for this transaction. netuid: The UID of the target subnet for which the call is being initiated. + proxy: Optional proxy to use for this extrinsic submission. position_id: The id of the position record in the pool. liquidity_delta: The amount of liquidity to be added or removed (add if positive or remove if negative). wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. + tuple: + bool: True if successful, False otherwise. + str: success message if successful, error message otherwise. + AsyncExtrinsicReceipt: extrinsic receipt if successful, None otherwise. Note: Modifying is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. @@ -134,6 +141,7 @@ async def modify_liquidity_extrinsic( return await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) @@ -143,6 +151,7 @@ async def remove_liquidity_extrinsic( subtensor: "SubtensorInterface", wallet: "Wallet", hotkey_ss58: str, + proxy: Optional[str], netuid: int, position_id: int, wait_for_inclusion: bool = True, @@ -154,15 +163,17 @@ async def remove_liquidity_extrinsic( subtensor: The Subtensor client instance used for blockchain interaction. wallet: The wallet used to sign the extrinsic (must be unlocked). hotkey_ss58: the SS58 of the hotkey to use for this transaction. + proxy: Optional proxy to use for this extrinsic submission. netuid: The UID of the target subnet for which the call is being initiated. position_id: The id of the position record in the pool. wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. + tuple: + bool: True if successful, False otherwise. + str: success message if successful, error message otherwise. + AsyncExtrinsicReceipt: extrinsic receipt if successful, None otherwise. Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. @@ -183,6 +194,7 @@ async def remove_liquidity_extrinsic( return await subtensor.sign_and_send_extrinsic( call=call, wallet=wallet, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) @@ -234,6 +246,7 @@ async def add_liquidity( wallet: "Wallet", hotkey_ss58: str, netuid: Optional[int], + proxy: Optional[str], liquidity: Balance, price_low: Balance, price_high: Balance, @@ -267,17 +280,23 @@ async def add_liquidity( wallet=wallet, hotkey_ss58=hotkey_ss58, netuid=netuid, + proxy=proxy, liquidity=liquidity, price_low=price_low, price_high=price_high, ) - await print_extrinsic_id(ext_receipt) - ext_id = await ext_receipt.get_extrinsic_identifier() + if success: + await print_extrinsic_id(ext_receipt) + ext_id = await ext_receipt.get_extrinsic_identifier() + else: + ext_id = None if json_output: - json_console.print( - json.dumps( - {"success": success, "message": message, "extrinsic_identifier": ext_id} - ) + json_console.print_json( + data={ + "success": success, + "message": message, + "extrinsic_identifier": ext_id, + } ) else: if success: @@ -545,6 +564,7 @@ async def remove_liquidity( wallet: "Wallet", hotkey_ss58: str, netuid: int, + proxy: Optional[str], position_id: Optional[int] = None, prompt: Optional[bool] = None, all_liquidity_ids: Optional[bool] = None, @@ -579,12 +599,14 @@ async def remove_liquidity( if not Confirm.ask("Would you like to continue?"): return None + # TODO does this never break because of the nonce? results = await asyncio.gather( *[ remove_liquidity_extrinsic( subtensor=subtensor, wallet=wallet, hotkey_ss58=hotkey_ss58, + proxy=proxy, netuid=netuid, position_id=pos_id, ) @@ -615,6 +637,7 @@ async def modify_liquidity( wallet: "Wallet", hotkey_ss58: str, netuid: int, + proxy: Optional[str], position_id: int, liquidity_delta: Balance, prompt: Optional[bool] = None, @@ -646,6 +669,7 @@ async def modify_liquidity( wallet=wallet, hotkey_ss58=hotkey_ss58, netuid=netuid, + proxy=proxy, position_id=position_id, liquidity_delta=liquidity_delta, ) diff --git a/bittensor_cli/src/commands/proxy.py b/bittensor_cli/src/commands/proxy.py new file mode 100644 index 000000000..580636881 --- /dev/null +++ b/bittensor_cli/src/commands/proxy.py @@ -0,0 +1,634 @@ +from typing import TYPE_CHECKING, Optional +import sys + +from rich.prompt import Confirm, Prompt, FloatPrompt, IntPrompt +from scalecodec import GenericCall, ScaleBytes + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.utils import ( + print_extrinsic_id, + json_console, + console, + err_console, + unlock_key, + ProxyAddressBook, + is_valid_ss58_address_prompt, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + from bittensor_wallet.bittensor_wallet import Wallet + + +# TODO when 3.10 support is dropped in Oct 2026, remove this +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + + +class ProxyType(StrEnum): + Any = "Any" + Owner = "Owner" + NonCritical = "NonCritical" + NonTransfer = "NonTransfer" + Senate = "Senate" + NonFungible = "NonFungible" + Triumvirate = "Triumvirate" + Governance = "Governance" + Staking = "Staking" + Registration = "Registration" + Transfer = "Transfer" + SmallTransfer = "SmallTransfer" + RootWeights = "RootWeights" + ChildKeys = "ChildKeys" + SudoUncheckedSetCode = "SudoUncheckedSetCode" + SwapHotkey = "SwapHotkey" + SubnetLeaseBeneficiary = "SubnetLeaseBeneficiary" + RootClaim = "RootClaim" + + +# TODO add announce with also --reject and --remove + + +async def submit_proxy( + subtensor: "SubtensorInterface", + wallet: "Wallet", + call: GenericCall, + wait_for_inclusion: bool, + wait_for_finalization: bool, + period: int, + json_output: bool, + proxy: Optional[str] = None, + announce_only: bool = False, +) -> None: + """ + Submits the prepared call to the chain + + Returns: + None, prints out the result according to `json_output` flag. + + """ + success, msg, receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + era={"period": period}, + proxy=proxy, + announce_only=announce_only, + ) + if success: + await print_extrinsic_id(receipt) + if json_output: + json_console.print_json( + data={ + "success": success, + "message": msg, + "extrinsic_identifier": await receipt.get_extrinsic_identifier(), + } + ) + else: + console.print(":white_check_mark:[green]Success![/green]") + + else: + if json_output: + json_console.print_json( + data={ + "success": success, + "message": msg, + "extrinsic_identifier": None, + } + ) + else: + console.print(":white_check_mark:[green]Success![/green]") + + +async def create_proxy( + subtensor: "SubtensorInterface", + wallet: "Wallet", + proxy_type: ProxyType, + delay: int, + idx: int, + prompt: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + period: int, + json_output: bool, +) -> None: + """ + Executes the create pure proxy call on the chain + """ + if prompt: + if not Confirm.ask( + f"This will create a Pure Proxy of type {proxy_type.value}. Do you want to proceed?", + ): + return None + if delay > 0: + if not Confirm.ask( + f"By adding a non-zero delay ({delay}), all proxy calls must be announced " + f"{delay} blocks before they will be able to be made. Continue?" + ): + return None + if not (ulw := unlock_key(wallet, print_out=not json_output)).success: + if not json_output: + err_console.print(ulw.message) + else: + json_console.print_json( + data={ + "success": ulw.success, + "message": ulw.message, + "extrinsic_identifier": None, + } + ) + return None + call = await subtensor.substrate.compose_call( + call_module="Proxy", + call_function="create_pure", + call_params={"proxy_type": proxy_type.value, "delay": delay, "index": idx}, + ) + success, msg, receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + era={"period": period}, + nonce=await subtensor.substrate.get_account_next_index( + wallet.coldkeypub.ss58_address + ), + ) + if success: + await print_extrinsic_id(receipt) + created_pure = None + created_spawner = None + created_proxy_type = None + for event in await receipt.triggered_events: + if event["event_id"] == "PureCreated": + attrs = event["attributes"] + created_pure = attrs["pure"] + created_spawner = attrs["who"] + created_proxy_type = getattr(ProxyType, attrs["proxy_type"]) + arg_start = "`" if json_output else "[blue]" + arg_end = "`" if json_output else "[/blue]" + msg = ( + f"Created pure '{created_pure}' " + f"from spawner '{created_spawner}' " + f"with proxy type '{created_proxy_type.value}' " + f"with delay {delay}." + ) + console.print(msg) + if not prompt: + console.print( + f" You can add this to your config with {arg_start}" + f"btcli config add-proxy " + f"--name --address {created_pure} --proxy-type {created_proxy_type.value} " + f"--delay {delay} --spawner {created_spawner}" + f"{arg_end}" + ) + else: + if Confirm.ask("Would you like to add this to your address book?"): + proxy_name = Prompt.ask("Name this proxy") + note = Prompt.ask("[Optional] Add a note for this proxy", default="") + with ProxyAddressBook.get_db() as (conn, cursor): + ProxyAddressBook.add_entry( + conn, + cursor, + name=proxy_name, + ss58_address=created_pure, + delay=delay, + proxy_type=created_proxy_type.value, + note=note, + spawner=created_spawner, + ) + console.print( + f"Added to Proxy Address Book.\n" + f"Show this information with [{COLORS.G.ARG}]btcli config proxies[/{COLORS.G.ARG}]" + ) + return None + + if json_output: + json_console.print_json( + data={ + "success": success, + "message": msg, + "data": { + "pure": created_pure, + "spawner": created_spawner, + "proxy_type": created_proxy_type.value, + "delay": delay, + }, + "extrinsic_identifier": await receipt.get_extrinsic_identifier(), + } + ) + + else: + if json_output: + json_console.print_json( + data={ + "success": success, + "message": msg, + "data": None, + "extrinsic_identifier": None, + } + ) + else: + err_console.print(f":cross_mark:[red]Failed to create pure proxy: {msg}") + return None + + +async def remove_proxy( + subtensor: "SubtensorInterface", + wallet: "Wallet", + proxy_type: ProxyType, + delegate: str, + delay: int, + prompt: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + period: int, + json_output: bool, +) -> None: + """ + Executes the remove proxy call on the chain + """ + if prompt: + if not Confirm.ask( + f"This will remove a proxy of type {proxy_type.value} for delegate {delegate}." + f"Do you want to proceed?" + ): + return None + if not (ulw := unlock_key(wallet, print_out=not json_output)).success: + if not json_output: + err_console.print(ulw.message) + else: + json_console.print_json( + data={ + "success": ulw.success, + "message": ulw.message, + "extrinsic_identifier": None, + } + ) + return None + call = await subtensor.substrate.compose_call( + call_module="Proxy", + call_function="remove_proxy", + call_params={ + "proxy_type": proxy_type.value, + "delay": delay, + "delegate": delegate, + }, + ) + return await submit_proxy( + subtensor=subtensor, + wallet=wallet, + call=call, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + json_output=json_output, + ) + + +async def add_proxy( + subtensor: "SubtensorInterface", + wallet: "Wallet", + proxy_type: ProxyType, + delegate: str, + delay: int, + prompt: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + period: int, + json_output: bool, +): + """ + Executes the add proxy call on the chain + """ + if prompt: + if not Confirm.ask( + f"This will add a proxy of type {proxy_type.value} for delegate {delegate}." + f"Do you want to proceed?" + ): + return None + if delay > 0: + if not Confirm.ask( + f"By adding a non-zero delay ({delay}), all proxy calls must be announced " + f"{delay} blocks before they will be able to be made. Continue?" + ): + return None + if not (ulw := unlock_key(wallet, print_out=not json_output)).success: + if not json_output: + err_console.print(ulw.message) + else: + json_console.print_json( + data={ + "success": ulw.success, + "message": ulw.message, + "extrinsic_identifier": None, + } + ) + return None + call = await subtensor.substrate.compose_call( + call_module="Proxy", + call_function="add_proxy", + call_params={ + "proxy_type": proxy_type.value, + "delay": delay, + "delegate": delegate, + }, + ) + success, msg, receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + era={"period": period}, + ) + if success: + await print_extrinsic_id(receipt) + delegatee = None + delegator = None + created_proxy_type = None + for event in await receipt.triggered_events: + if event["event_id"] == "ProxyAdded": + attrs = event["attributes"] + delegatee = attrs["delegatee"] + delegator = attrs["delegator"] + created_proxy_type = getattr(ProxyType, attrs["proxy_type"]) + break + arg_start = "`" if json_output else "[blue]" + arg_end = "`" if json_output else "[/blue]" + msg = ( + f"Added proxy delegatee '{delegatee}' " + f"from delegator '{delegator}' " + f"with proxy type '{created_proxy_type.value}' " + f"with delay {delay}." + ) + console.print(msg) + if not prompt: + console.print( + f" You can add this to your config with {arg_start}" + f"btcli config add-proxy " + f"--name --address {delegatee} --proxy-type {created_proxy_type.value} --delegator " + f"{delegator} --delay {delay}" + f"{arg_end}" + ) + else: + if Confirm.ask("Would you like to add this to your address book?"): + proxy_name = Prompt.ask("Name this proxy") + note = Prompt.ask("[Optional] Add a note for this proxy", default="") + with ProxyAddressBook.get_db() as (conn, cursor): + ProxyAddressBook.add_entry( + conn, + cursor, + name=proxy_name, + ss58_address=delegator, + delay=delay, + proxy_type=created_proxy_type.value, + note=note, + spawner=delegatee, + ) + console.print( + f"Added to Proxy Address Book.\n" + f"Show this information with [{COLORS.G.ARG}]btcli config proxies[/{COLORS.G.ARG}]" + ) + + if json_output: + json_console.print_json( + data={ + "success": success, + "message": msg, + "data": { + "delegatee": delegatee, + "delegator": delegator, + "proxy_type": created_proxy_type.value, + "delay": delay, + }, + "extrinsic_identifier": await receipt.get_extrinsic_identifier(), + } + ) + + else: + if json_output: + json_console.print_json( + data={ + "success": success, + "message": msg, + "data": None, + "extrinsic_identifier": None, + } + ) + else: + err_console.print(f":cross_mark:[red]Failed to add proxy: {msg}") + return None + + +async def kill_proxy( + subtensor: "SubtensorInterface", + wallet: "Wallet", + proxy_type: ProxyType, + height: int, + ext_index: int, + spawner: Optional[str], + idx: int, + proxy: Optional[str], + announce_only: bool, + prompt: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + period: int, + json_output: bool, +) -> None: + """ + Executes the pure proxy kill call on the chain + """ + if prompt: + confirmation = Prompt.ask( + f"This will kill a Pure Proxy account of type {proxy_type.value}.\n" + f"[red]All access to this account will be lost. Any funds held in it will be inaccessible.[/red]" + f"To proceed, enter [red]KILL[/red]" + ) + if confirmation != "KILL": + err_console.print("Invalid input. Exiting.") + return None + if not (ulw := unlock_key(wallet, print_out=not json_output)).success: + if not json_output: + err_console.print(ulw.message) + else: + json_console.print_json( + data={ + "success": ulw.success, + "message": ulw.message, + "extrinsic_identifier": None, + } + ) + return None + spawner = spawner or wallet.coldkeypub.ss58_address + call = await subtensor.substrate.compose_call( + call_module="Proxy", + call_function="kill_pure", + call_params={ + "proxy_type": proxy_type.value, + "index": idx, + "height": height, + "ext_index": ext_index, + "spawner": spawner, + }, + ) + return await submit_proxy( + subtensor=subtensor, + wallet=wallet, + call=call, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + json_output=json_output, + proxy=proxy, + announce_only=announce_only, + ) + + +async def execute_announced( + subtensor: "SubtensorInterface", + wallet: "Wallet", + delegate: str, + real: str, + period: int, + call_hex: Optional[str], + delay: int = 0, + created_block: Optional[int] = None, + prompt: bool = True, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = False, + json_output: bool = False, +) -> bool: + """ + Executes the previously-announced call on the chain. + + Returns: + True if the submission was successful, False otherwise. + + """ + if prompt and created_block is not None: + current_block = await subtensor.substrate.get_block_number() + if current_block - delay > created_block: + if not Confirm.ask( + f"The delay for this account is set to {delay} blocks, but the call was created" + f" at block {created_block}. It is currently only {current_block}. The call will likely fail." + f" Do you want to proceed?" + ): + return False + + if call_hex is None: + if not prompt: + err_console.print( + f":cross_mark:[red]You have not provided a call, and are using" + f" [{COLORS.G.ARG}]--no-prompt[/{COLORS.G.ARG}], so we are unable to request" + f"the information to craft this call." + ) + return False + else: + call_args = {} + failure_ = f"Instead create the call using btcli commands with [{COLORS.G.ARG}]--announce-only[/{COLORS.G.ARG}]" + block_hash = await subtensor.substrate.get_chain_head() + fns = await subtensor.substrate.get_metadata_call_functions( + block_hash=block_hash + ) + module = Prompt.ask( + "Enter the module name for the call", + choices=list(fns.keys()), + show_choices=True, + ) + call_fn = Prompt.ask( + "Enter the call function for the call", + choices=list(fns[module].keys()), + show_choices=True, + ) + for arg in fns[module][call_fn].keys(): + type_name = fns[module][call_fn][arg]["typeName"] + if type_name == "AccountIdLookupOf": + value = is_valid_ss58_address_prompt( + f"Enter the SS58 Address for {arg}" + ) + elif type_name == "T::Balance": + value = FloatPrompt.ask(f"Enter the amount of Tao for {arg}") + value = Balance.from_tao(value) + elif "RuntimeCall" in type_name: + err_console.print( + f":cross_mark:[red]Unable to craft a Call Type for arg {arg}. {failure_}" + ) + return False + elif type_name == "NetUid": + value = IntPrompt.ask(f"Enter the netuid for {arg}") + elif type_name in ("u16", "u64"): + value = IntPrompt.ask(f"Enter the int value for {arg}") + elif type_name == "bool": + value = Prompt.ask( + f"Enter the bool value for {arg}", + choices=["True", "False"], + show_choices=True, + ) + if value == "True": + value = True + else: + value = False + else: + err_console.print( + f":cross_mark:[red]Unrecognized type name {type_name}. {failure_}" + ) + return False + call_args[arg] = value + inner_call = await subtensor.substrate.compose_call( + module, + call_fn, + call_params=call_args, + block_hash=block_hash, + ) + else: + runtime = await subtensor.substrate.init_runtime(block_id=created_block) + inner_call = GenericCall( + data=ScaleBytes(data=bytearray.fromhex(call_hex)), metadata=runtime.metadata + ) + inner_call.process() + + announced_call = await subtensor.substrate.compose_call( + "Proxy", + "proxy_announced", + { + "delegate": delegate, + "real": real, + "call": inner_call, + "force_proxy_type": None, + }, + ) + success, msg, receipt = await subtensor.sign_and_send_extrinsic( + call=announced_call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + era={"period": period}, + ) + if success is True: + if json_output: + json_console.print_json( + data={ + "success": True, + "message": msg, + "extrinsic_identifier": await receipt.get_extrinsic_identifier(), + } + ) + else: + console.print(":white_check_mark:[green]Success![/green]") + await print_extrinsic_id(receipt) + else: + if json_output: + json_console.print_json( + data={"success": False, "message": msg, "extrinsic_identifier": None} + ) + else: + err_console.print(f":cross_mark:[red]Failed[/red]. {msg} ") + return success diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 5046981da..126854aff 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -8,13 +8,15 @@ from rich.table import Table from rich.prompt import Confirm, Prompt -from async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.extrinsics.mev_shield import ( + extract_mev_shield_id, + wait_for_extrinsic_by_hash, +) from bittensor_cli.src.bittensor.utils import ( console, err_console, - format_error_message, get_hotkey_wallets_for_wallet, is_valid_ss58_address, print_error, @@ -46,6 +48,8 @@ async def stake_add( allow_partial_stake: bool, json_output: bool, era: int, + mev_protection: bool, + proxy: Optional[str], ): """ Args: @@ -63,6 +67,8 @@ async def stake_add( allow_partial_stake: whether to allow partial stake json_output: whether to output stake info in JSON format era: Blocks for which the transaction should be valid. + proxy: Optional proxy to use for staking. + mev_protection: If true, will encrypt the extrinsic behind the mev protection shield. Returns: bool: True if stake operation is successful, False otherwise @@ -105,7 +111,7 @@ async def get_stake_extrinsic_fee( call_function=call_fn, call_params=call_params, ) - return await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + return await subtensor.get_extrinsic_fee(call, wallet.coldkeypub, proxy=proxy) async def safe_stake_extrinsic( netuid_: int, @@ -113,15 +119,15 @@ async def safe_stake_extrinsic( current_stake: Balance, hotkey_ss58_: str, price_limit: Balance, - status=None, + status_=None, ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - err_out = partial(print_error, status=status) + err_out = partial(print_error, status=status_) failure_prelude = ( f":cross_mark: [red]Failed[/red] to stake {amount_} on Netuid {netuid_}" ) current_balance, next_nonce, call = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address), - subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), + subtensor.get_balance(coldkey_ss58), + subtensor.substrate.get_account_next_index(coldkey_ss58), subtensor.substrate.compose_call( call_module="SubtensorModule", call_function="add_stake_limit", @@ -134,43 +140,52 @@ async def safe_stake_extrinsic( }, ), ) - extrinsic = await subtensor.substrate.create_signed_extrinsic( + success_, err_msg, response = await subtensor.sign_and_send_extrinsic( call=call, - keypair=wallet.coldkey, + wallet=wallet, nonce=next_nonce, era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, ) - 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): + if not success_: + if "Custom error: 8" in err_msg: err_msg = ( f"{failure_prelude}: Price exceeded tolerance limit. " f"Transaction rejected because partial staking is disabled. " f"Either increase price tolerance or enable partial staking." ) - print_error("\n" + err_msg, status=status) + print_error("\n" + err_msg, status=status_) else: - err_msg = f"{failure_prelude} with error: {format_error_message(e)}" + err_msg = f"{failure_prelude} with error: {err_msg}" err_out("\n" + err_msg) return False, err_msg, None - if not await response.is_success: - err_msg = f"{failure_prelude} with error: {format_error_message(await response.error_message)}" - err_out("\n" + err_msg) - return False, err_msg, None else: + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status_, + ) + if not mev_success: + status_.stop() + err_msg = f"{failure_prelude}: {mev_error}" + err_out("\n" + err_msg) + return False, err_msg, None if json_output: # the rest of this checking is not necessary if using json_output return True, "", response await print_extrinsic_id(response) 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_balance(coldkey_ss58, block_hash), subtensor.get_stake( hotkey_ss58=hotkey_ss58_, - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, netuid=netuid_, block_hash=block_hash, ), @@ -205,12 +220,13 @@ async def safe_stake_extrinsic( return True, "", response async def stake_extrinsic( - netuid_i, amount_, current, staking_address_ss58, status=None + netuid_i, amount_, current, staking_address_ss58, status_=None ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - err_out = partial(print_error, status=status) + err_out = partial(print_error, status=status_) + block_hash = await subtensor.substrate.get_chain_head() current_balance, next_nonce, call = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address), - subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), + subtensor.get_balance(coldkey_ss58, block_hash=block_hash), + subtensor.substrate.get_account_next_index(coldkey_ss58), subtensor.substrate.compose_call( call_module="SubtensorModule", call_function="add_stake", @@ -219,65 +235,78 @@ async def stake_extrinsic( "netuid": netuid_i, "amount_staked": amount_.rao, }, + block_hash=block_hash, ), ) failure_prelude = ( f":cross_mark: [red]Failed[/red] to stake {amount} on Netuid {netuid_i}" ) - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, nonce=next_nonce, era={"period": era} + success_, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + nonce=next_nonce, + era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, ) - try: - response = await subtensor.substrate.submit_extrinsic( - extrinsic, wait_for_inclusion=True, wait_for_finalization=False - ) - except SubstrateRequestException as e: - err_msg = f"{failure_prelude} with error: {format_error_message(e)}" + if not success_: + err_msg = f"{failure_prelude} with error: {err_msg}" err_out("\n" + err_msg) return False, err_msg, None else: - if not await response.is_success: - err_msg = f"{failure_prelude} with error: {format_error_message(await response.error_message)}" - err_out("\n" + err_msg) - return False, err_msg, None - else: - if json_output: - # the rest of this is not necessary if using json_output - return True, "", response - await print_extrinsic_id(response) - new_block_hash = await subtensor.substrate.get_chain_head() - new_balance, new_stake = await asyncio.gather( - subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=new_block_hash - ), - subtensor.get_stake( - hotkey_ss58=staking_address_ss58, - coldkey_ss58=wallet.coldkeypub.ss58_address, - netuid=netuid_i, - block_hash=new_block_hash, - ), - ) - console.print( - f":white_heavy_check_mark: " - f"[dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" - ) - console.print( - f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" - ) - console.print( - f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" - f"{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" + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status_, ) + if not mev_success: + status_.stop() + err_msg = f"{failure_prelude}: {mev_error}" + err_out("\n" + err_msg) + return False, err_msg, None + if json_output: + # the rest of this is not necessary if using json_output return True, "", response + await print_extrinsic_id(response) + new_block_hash = await subtensor.substrate.get_chain_head() + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance( + wallet.coldkeypub.ss58_address, block_hash=new_block_hash + ), + subtensor.get_stake( + hotkey_ss58=staking_address_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid_i, + block_hash=new_block_hash, + ), + ) + console.print( + f":white_heavy_check_mark: " + f"[dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" + ) + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + console.print( + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"{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" + ) + return True, "", response netuids = ( netuids if netuids is not None else await subtensor.get_all_subnet_netuids() ) + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address hotkeys_to_stake_to = _get_hotkeys_to_stake_to( wallet=wallet, @@ -291,10 +320,10 @@ async def stake_extrinsic( _all_subnets, _stake_info, current_wallet_balance = await asyncio.gather( subtensor.all_subnets(block_hash=chain_head), subtensor.get_stake_for_coldkey( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, block_hash=chain_head, ), - subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash=chain_head), + subtensor.get_balance(coldkey_ss58, block_hash=chain_head), ) all_subnets = {di.netuid: di for di in _all_subnets} @@ -403,10 +432,13 @@ async def stake_extrinsic( ) row_extension = [] # TODO this should be asyncio gathered before the for loop + amount_minus_fee = ( + (amount_to_stake - extrinsic_fee) if not proxy else amount_to_stake + ) sim_swap = await subtensor.sim_swap( origin_netuid=0, destination_netuid=netuid, - amount=(amount_to_stake - extrinsic_fee).rao, + amount=amount_minus_fee.rao, ) received_amount = sim_swap.alpha_amount # Add rows for the table @@ -435,47 +467,53 @@ async def stake_extrinsic( if not unlock_key(wallet).success: return - 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[(ni, staking_address)] = stake_extrinsic( - netuid_i=ni, - amount_=am, - current=curr, - staking_address_ss58=staking_address, - ) - else: - stake_coroutines[(ni, staking_address)] = safe_stake_extrinsic( - netuid_=ni, - amount_=am, - current_stake=curr, - hotkey_ss58_=staking_address, - price_limit=price_with_tolerance, - ) - else: - stake_coroutines = { - (ni, staking_address): 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 - } successes = defaultdict(dict) error_messages = defaultdict(dict) extrinsic_ids = defaultdict(dict) - with console.status(f"\n:satellite: Staking on netuid(s): {netuids} ..."): + with console.status(f"\n:satellite: Staking on netuid(s): {netuids} ...") as status: + 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[(ni, staking_address)] = stake_extrinsic( + netuid_i=ni, + amount_=am, + current=curr, + staking_address_ss58=staking_address, + status_=status, + ) + else: + stake_coroutines[(ni, staking_address)] = safe_stake_extrinsic( + netuid_=ni, + amount_=am, + current_stake=curr, + hotkey_ss58_=staking_address, + price_limit=price_with_tolerance, + status_=status, + ) + else: + stake_coroutines = { + (ni, staking_address): stake_extrinsic( + netuid_i=ni, + amount_=am, + current=curr, + staking_address_ss58=staking_address, + status_=status, + ) + for i, (ni, am, curr) in enumerate( + zip(netuids, amounts_to_stake, current_stake_balances) + ) + for _, staking_address in hotkeys_to_stake_to + } # We can gather them all at once but balance reporting will be in race-condition. for (ni, staking_address), coroutine in stake_coroutines.items(): success, er_msg, ext_receipt = await coroutine diff --git a/bittensor_cli/src/commands/stake/auto_staking.py b/bittensor_cli/src/commands/stake/auto_staking.py index 6e8bf3632..d917cc439 100644 --- a/bittensor_cli/src/commands/stake/auto_staking.py +++ b/bittensor_cli/src/commands/stake/auto_staking.py @@ -174,6 +174,7 @@ async def set_auto_stake_destination( subtensor: "SubtensorInterface", netuid: int, hotkey_ss58: str, + proxy: Optional[str] = None, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, prompt_user: bool = True, @@ -269,6 +270,7 @@ async def set_auto_stake_destination( wallet, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + proxy=proxy, ) ext_id = await ext_receipt.get_extrinsic_identifier() if success else None diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index d50ecc65a..eda0b53b4 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -56,6 +56,7 @@ async def set_children_extrinsic( wallet: Wallet, hotkey: str, netuid: int, + proxy: Optional[str], children_with_proportions: list[tuple[float, str]], wait_for_inclusion: bool = True, wait_for_finalization: bool = False, @@ -122,7 +123,7 @@ async def set_children_extrinsic( }, ) success, error_message, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization + call, wallet, wait_for_inclusion, wait_for_finalization, proxy=proxy ) if not wait_for_finalization and not wait_for_inclusion: @@ -151,6 +152,7 @@ async def set_childkey_take_extrinsic( hotkey: str, netuid: int, take: float, + proxy: Optional[str] = None, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, prompt: bool = True, @@ -163,6 +165,7 @@ async def set_childkey_take_extrinsic( :param: hotkey: Child hotkey. :param: take: Childkey Take value. :param: netuid: Unique identifier of for the subnet. + :param: proxy: Optional proxy to use to make this extrinsic submission. :param: wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns `False` if the extrinsic fails to enter the block within the timeout. :param: wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning ` @@ -206,7 +209,7 @@ async def set_childkey_take_extrinsic( error_message, ext_receipt, ) = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization + call, wallet, wait_for_inclusion, wait_for_finalization, proxy=proxy ) if not wait_for_finalization and not wait_for_inclusion: @@ -223,17 +226,9 @@ async def set_childkey_take_extrinsic( if wait_for_finalization: modifier = "finalized" console.print(":white_heavy_check_mark: [green]Finalized[/green]") - # bittensor.logging.success( - # prefix="Setting childkey take", - # suffix="Finalized: " + str(success), - # ) return True, f"Successfully {modifier} childkey take", ext_id else: console.print(f":cross_mark: [red]Failed[/red]: {error_message}") - # bittensor.logging.warning( - # prefix="Setting childkey take", - # suffix="Failed: " + str(error_message), - # ) return False, error_message, None except SubstrateRequestException as e: @@ -509,8 +504,10 @@ async def set_children( wait_for_finalization: bool = True, prompt: bool = True, json_output: bool = False, + proxy: Optional[str] = None, ): """Set children hotkeys.""" + # TODO holy shit I hate this. It needs to be rewritten. # Validate children SS58 addresses # TODO check to see if this should be allowed to be specified by user instead of pulling from wallet hotkey = get_hotkey_pub_ss58(wallet) @@ -536,6 +533,7 @@ async def set_children( wallet=wallet, netuid=netuid, hotkey=hotkey, + proxy=proxy, children_with_proportions=children_with_proportions, prompt=prompt, wait_for_inclusion=wait_for_inclusion, @@ -579,6 +577,7 @@ async def set_children( wallet=wallet, netuid=netuid_, hotkey=hotkey, + proxy=proxy, children_with_proportions=children_with_proportions, prompt=prompt, wait_for_inclusion=True, @@ -609,6 +608,7 @@ async def revoke_children( wallet: Wallet, subtensor: "SubtensorInterface", netuid: Optional[int] = None, + proxy: Optional[str] = None, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, prompt: bool = True, @@ -625,6 +625,7 @@ async def revoke_children( netuid=netuid, hotkey=get_hotkey_pub_ss58(wallet), children_with_proportions=[], + proxy=proxy, prompt=prompt, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -665,6 +666,7 @@ async def revoke_children( netuid=netuid, # TODO should this be able to allow netuid = None ? hotkey=get_hotkey_pub_ss58(wallet), children_with_proportions=[], + proxy=proxy, prompt=prompt, wait_for_inclusion=True, wait_for_finalization=False, @@ -701,6 +703,7 @@ async def childkey_take( take: Optional[float], hotkey: Optional[str] = None, netuid: Optional[int] = None, + proxy: Optional[str] = None, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, prompt: bool = True, @@ -761,28 +764,29 @@ async def set_chk_take_subnet( subnet: int, chk_take: float ) -> tuple[bool, Optional[str]]: """Set the childkey take for a single subnet""" - success, message, ext_id = await set_childkey_take_extrinsic( + success_, message, ext_id_ = await set_childkey_take_extrinsic( subtensor=subtensor, wallet=wallet, netuid=subnet, hotkey=get_hotkey_pub_ss58(wallet), take=chk_take, + proxy=proxy, prompt=prompt, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) # Result - if success: + if success_: console.print(":white_heavy_check_mark: [green]Set childkey take.[/green]") console.print( f"The childkey take for {get_hotkey_pub_ss58(wallet)} is now set to {take * 100:.2f}%." ) - return True, ext_id + return True, ext_id_ else: console.print( f":cross_mark:[red] Unable to set childkey take.[/red] {message}" ) - return False, ext_id + return False, ext_id_ # Print childkey take for other user and return (dont offer to change take rate) wallet_hk = get_hotkey_pub_ss58(wallet) @@ -847,6 +851,7 @@ async def set_chk_take_subnet( netuid=netuid_, hotkey=wallet_hk, take=take, + proxy=proxy, prompt=prompt, wait_for_inclusion=True, wait_for_finalization=False, diff --git a/bittensor_cli/src/commands/stake/claim.py b/bittensor_cli/src/commands/stake/claim.py index 67147a82c..2648ad926 100644 --- a/bittensor_cli/src/commands/stake/claim.py +++ b/bittensor_cli/src/commands/stake/claim.py @@ -1,9 +1,11 @@ import asyncio import json +from enum import Enum from typing import TYPE_CHECKING, Optional from bittensor_wallet import Wallet from rich.prompt import Confirm, Prompt +from rich.panel import Panel from rich.table import Table, Column from rich import box @@ -16,16 +18,25 @@ print_extrinsic_id, json_console, millify_tao, + group_subnets, + parse_subnet_range, ) if TYPE_CHECKING: from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +class ClaimType(Enum): + Keep = "Keep" + Swap = "Swap" + + async def set_claim_type( wallet: Wallet, subtensor: "SubtensorInterface", - claim_type: Optional[str] = None, + claim_type: Optional[ClaimType], + proxy: Optional[str], + netuids: Optional[str] = None, prompt: bool = True, json_output: bool = False, ) -> tuple[bool, str, Optional[str]]: @@ -35,11 +46,14 @@ async def set_claim_type( Root claim types control how staking emissions are handled on the ROOT network (subnet 0): - "Swap": Future Root Alpha Emissions are swapped to TAO at claim time and added to root stake - "Keep": Future Root Alpha Emissions are kept as Alpha tokens + - "KeepSubnets": Specific subnets kept as Alpha, rest swapped to TAO Args: wallet: Bittensor wallet object subtensor: SubtensorInterface object - claim_type: Optional claim type ("Keep" or "Swap"). If None, user will be prompted. + claim_type: Claim type ("Keep" or "Swap"). If omitted, user will be prompted. + proxy: Optional proxy to use with this extrinsic submission. + netuids: Optional string of subnet IDs (e.g., "1-5,10,20-30"). Will be parsed internally. prompt: Whether to prompt for user confirmation json_output: Whether to output JSON @@ -50,45 +64,94 @@ async def set_claim_type( - Optional[str]: Extrinsic identifier if successful """ - current_type = await subtensor.get_coldkey_claim_type( - coldkey_ss58=wallet.coldkeypub.ss58_address + if claim_type is not None: + claim_type = claim_type.value + + current_claim_info, all_netuids = await asyncio.gather( + subtensor.get_coldkey_claim_type(coldkey_ss58=wallet.coldkeypub.ss58_address), + subtensor.get_all_subnet_netuids(), ) + all_subnets = sorted([n for n in all_netuids if n != 0]) + + selected_netuids = None + if netuids is not None: + try: + selected_netuids = parse_subnet_range( + netuids, total_subnets=len(all_subnets) + ) + except ValueError as e: + msg = f"Invalid netuid format: {e}" + err_console.print(f"[red]{msg}[/red]") + if json_output: + json_console.print(json.dumps({"success": False, "message": msg})) + return False, msg, None claim_table = Table( + Column("[bold white]Coldkey", style=COLORS.GENERAL.COLDKEY, justify="left"), Column( - "[bold white]Coldkey", - style=COLORS.GENERAL.COLDKEY, - justify="left", - ), - Column( - "[bold white]Root Claim Type", - style=COLORS.GENERAL.SUBHEADING, - justify="center", + "[bold white]Current Type", style=COLORS.GENERAL.SUBHEADING, justify="left" ), show_header=True, - show_footer=False, - show_edge=True, border_style="bright_black", box=box.SIMPLE, - pad_edge=False, - width=None, - title=f"\n[{COLORS.GENERAL.HEADER}]Current root claim type:[/{COLORS.GENERAL.HEADER}]", + title=f"\n[{COLORS.GENERAL.HEADER}]Current Root Claim Type[/{COLORS.GENERAL.HEADER}]", ) claim_table.add_row( - wallet.coldkeypub.ss58_address, f"[yellow]{current_type}[/yellow]" + wallet.coldkeypub.ss58_address, + _format_claim_type_display(current_claim_info, all_subnets), ) console.print(claim_table) - new_type = ( - claim_type - if claim_type - else Prompt.ask( - "Select new root claim type", choices=["Swap", "Keep"], default=current_type - ) - ) - if new_type == current_type: - msg = f"Root claim type is already set to '{current_type}'. No change needed." - console.print(f"[yellow]{msg}[/yellow]") + # Full wizard + if claim_type is None and selected_netuids is None: + new_claim_info = await _ask_for_claim_types(wallet, subtensor, all_subnets) + if new_claim_info is None: + msg = "Operation cancelled." + console.print(f"[yellow]{msg}[/yellow]") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "message": msg, + "extrinsic_identifier": None, + } + ) + ) + return False, msg, None + + # Keep netuids passed thru the cli and assume Keep type + elif claim_type is None and selected_netuids is not None: + new_claim_info = {"type": "KeepSubnets", "subnets": selected_netuids} + + else: + # Netuids passed with Keep type + if selected_netuids is not None and claim_type == "Keep": + new_claim_info = {"type": "KeepSubnets", "subnets": selected_netuids} + + # Netuids passed with Swap type + elif selected_netuids is not None and claim_type == "Swap": + keep_subnets = [n for n in all_subnets if n not in selected_netuids] + invalid = [n for n in selected_netuids if n not in all_subnets] + if invalid: + msg = f"Invalid subnets (not available): {group_subnets(invalid)}" + err_console.print(msg) + if json_output: + json_console.print(json.dumps({"success": False, "message": msg})) + return False, msg, None + + if not keep_subnets: + new_claim_info = {"type": "Swap"} + elif set(keep_subnets) == set(all_subnets): + new_claim_info = {"type": "Keep"} + else: + new_claim_info = {"type": "KeepSubnets", "subnets": keep_subnets} + else: + new_claim_info = {"type": claim_type} + + if _claim_types_equal(current_claim_info, new_claim_info): + msg = f"Claim type already set to {_format_claim_type_display(new_claim_info)}. \nNo change needed." + console.print(msg) if json_output: json_console.print( json.dumps( @@ -96,8 +159,6 @@ async def set_claim_type( "success": True, "message": msg, "extrinsic_identifier": None, - "old_type": current_type, - "new_type": current_type, } ) ) @@ -105,67 +166,41 @@ async def set_claim_type( if prompt: console.print( - f"\n[bold]Changing root claim type from '{current_type}' -> '{new_type}'[/bold]\n" - ) - - if new_type == "Swap": - console.print( - "[yellow]Note:[/yellow] With 'Swap', future root alpha emissions will be swapped to TAO and added to root stake." - ) - else: - console.print( - "[yellow]Note:[/yellow] With 'Keep', future root alpha emissions will be kept as Alpha tokens." + Panel( + f"[{COLORS.GENERAL.HEADER}]Confirm Claim Type Change[/{COLORS.GENERAL.HEADER}]\n\n" + f"FROM: {_format_claim_type_display(current_claim_info, all_subnets)}\n\n" + f"TO: {_format_claim_type_display(new_claim_info, all_subnets)}" ) + ) - if not Confirm.ask("\nDo you want to proceed?"): + if not Confirm.ask("\nProceed with this change?"): msg = "Operation cancelled." console.print(f"[yellow]{msg}[/yellow]") if json_output: - json_console.print( - json.dumps( - { - "success": False, - "message": msg, - "extrinsic_identifier": None, - "old_type": current_type, - "new_type": new_type, - } - ) - ) + json_console.print(json.dumps({"success": False, "message": msg})) return False, msg, None if not (unlock := unlock_key(wallet)).success: msg = f"Failed to unlock wallet: {unlock.message}" err_console.print(f":cross_mark: [red]{msg}[/red]") if json_output: - json_console.print( - json.dumps( - { - "success": False, - "message": msg, - "extrinsic_identifier": None, - "old_type": current_type, - "new_type": new_type, - } - ) - ) + json_console.print(json.dumps({"success": False, "message": msg})) return False, msg, None - with console.status( - f":satellite: Setting root claim type to '{new_type}'...", spinner="earth" - ): + with console.status(":satellite: Setting root claim type...", spinner="earth"): + claim_type_param = _prepare_claim_type_args(new_claim_info) call = await subtensor.substrate.compose_call( call_module="SubtensorModule", call_function="set_root_claim_type", - call_params={"new_root_claim_type": new_type}, + call_params={"new_root_claim_type": claim_type_param}, ) success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet + call, wallet, proxy=proxy ) if success: ext_id = await ext_receipt.get_extrinsic_identifier() - msg = f"Successfully set root claim type to '{new_type}'" + msg = "Successfully changed claim type" console.print(f":white_heavy_check_mark: [green]{msg}[/green]") await print_extrinsic_id(ext_receipt) if json_output: @@ -175,28 +210,15 @@ async def set_claim_type( "success": True, "message": msg, "extrinsic_identifier": ext_id, - "old_type": current_type, - "new_type": new_type, } ) ) return True, msg, ext_id - else: - msg = f"Failed to set root claim type: {err_msg}" + msg = f"Failed to set claim type: {err_msg}" err_console.print(f":cross_mark: [red]{msg}[/red]") if json_output: - json_console.print( - json.dumps( - { - "success": False, - "message": msg, - "extrinsic_identifier": None, - "old_type": current_type, - "new_type": new_type, - } - ) - ) + json_console.print(json.dumps({"success": False, "message": msg})) return False, msg, None @@ -204,6 +226,7 @@ async def process_pending_claims( wallet: Wallet, subtensor: "SubtensorInterface", netuids: Optional[list[int]] = None, + proxy: Optional[str] = None, prompt: bool = True, json_output: bool = False, verbose: bool = False, @@ -305,8 +328,13 @@ async def process_pending_claims( call_function="claim_root", call_params={"subnets": selected_netuids}, ) - extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) - console.print(f"\n[dim]Estimated extrinsic fee: {extrinsic_fee.tao:.9f} τ[/dim]") + extrinsic_fee = await subtensor.get_extrinsic_fee( + call, wallet.coldkeypub, proxy=proxy + ) + console.print( + f"\n[dim]Estimated extrinsic fee: {extrinsic_fee.tao:.9f} τ" + + (" (paid by real account)" if proxy else "") + ) if prompt: if not Confirm.ask("Do you want to proceed?"): @@ -346,7 +374,7 @@ async def process_pending_claims( spinner="earth", ): success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet + call, wallet, proxy=proxy ) if success: ext_id = await ext_receipt.get_extrinsic_identifier() @@ -479,3 +507,239 @@ def _print_claimable_table( first_row = False console.print(table) + + +async def _ask_for_claim_types( + wallet: Wallet, + subtensor: "SubtensorInterface", + all_subnets: list, +) -> Optional[dict]: + """ + Interactive prompts for claim type selection. + + Flow: + 1. Ask "Keep or Swap?" + 2. Ask "All subnets?" + - If yes → return simple type (Keep or Swap) + - If no → enter subnet selection + + Returns: + dict: Selected claim type, or None if cancelled + """ + + console.print("\n") + console.print( + Panel( + f"[{COLORS.GENERAL.HEADER}]Root Claim Type Selection[/{COLORS.GENERAL.HEADER}]\n\n" + "Configure how your root network emissions are claimed.\n\n" + "[yellow]Options:[/yellow]\n" + " • [green]Swap[/green] - Convert emissions to TAO\n" + " • [green]Keep[/green] - Keep emissions as Alpha\n" + " • [green]Keep Specific[/green] - Keep selected subnets, swap others\n", + ) + ) + + primary_choice = Prompt.ask( + "\nSelect new root claim type", + choices=["keep", "swap", "cancel"], + default="cancel", + ) + if primary_choice == "cancel": + return None + + apply_to_all = Confirm.ask( + f"\nSet {primary_choice.capitalize()} to ALL subnets?", default=True + ) + + if apply_to_all: + return {"type": primary_choice.capitalize()} + + if primary_choice == "keep": + console.print( + "\nYou can select which subnets to KEEP as Alpha (others will be swapped to TAO).\n" + ) + else: + console.print( + "\nYou can select which subnets to SWAP to TAO (others will be kept as Alpha).\n" + ) + + return await _prompt_claim_netuids( + wallet, subtensor, all_subnets, mode=primary_choice + ) + + +async def _prompt_claim_netuids( + wallet: Wallet, + subtensor: "SubtensorInterface", + all_subnets: list, + mode: str = "keep", +) -> Optional[dict]: + """ + Interactive subnet selection. + + Args: + mode: "keep" to select subnets to keep as Alpha, "swap" to select subnets to swap to TAO + + Returns: + dict: KeepSubnets claim type or None if cancelled + """ + + if not all_subnets: + console.print("[yellow]No subnets available.[/yellow]") + return {"type": "Swap"} + + if mode == "keep": + action = "KEEP as Alpha" + else: + action = "SWAP to TAO" + + console.print( + Panel( + f"[{COLORS.GENERAL.HEADER}]Subnet Selection[/{COLORS.GENERAL.HEADER}]\n\n" + f"[bold]Available subnets:[/bold] {group_subnets(sorted(all_subnets))}\n" + f"[dim]Total: {len(all_subnets)} subnets[/dim]\n\n" + "[yellow]Input examples:[/yellow]\n" + " • [cyan]1-10[/cyan] - Range from 1 to 10\n" + " • [cyan]1, 5, 10[/cyan] - Specific subnets\n" + " • [cyan]1-10, 20-30, 50[/cyan] - Mixed" + ) + ) + + while True: + subnet_input = Prompt.ask( + f"\nEnter subnets to {action} [dim]{group_subnets(sorted(all_subnets))}", + default="", + ) + + if not subnet_input.strip(): + err_console.print("[red]No subnets entered. Please try again.[/red]") + continue + + try: + selected = parse_subnet_range(subnet_input, total_subnets=len(all_subnets)) + invalid = [s for s in selected if s not in all_subnets] + if invalid: + err_console.print( + f"[red]Invalid subnets (not available): {group_subnets(invalid)}[/red]" + ) + err_console.print("[yellow]Please try again.[/yellow]") + continue + + if mode == "keep": + keep_subnets = selected + else: + keep_subnets = [n for n in all_subnets if n not in selected] + + if _preview_subnet_selection(keep_subnets, all_subnets): + if not keep_subnets: + return {"type": "Swap"} + elif set(keep_subnets) == set(all_subnets): + return {"type": "Keep"} + else: + return {"type": "KeepSubnets", "subnets": keep_subnets} + else: + console.print( + "[yellow]Selection cancelled. Starting over...[/yellow]\n" + ) + return await _prompt_claim_netuids( + wallet, subtensor, all_subnets, mode=mode + ) + + except ValueError as e: + err_console.print( + f"Invalid subnet selection: {e}\n[yellow]Please try again." + ) + + +def _preview_subnet_selection(keep_subnets: list[int], all_subnets: list[int]) -> bool: + """Show preview and ask for confirmation.""" + + swap_subnets = [n for n in all_subnets if n not in keep_subnets] + preview_content = ( + f"[{COLORS.GENERAL.HEADER}]Preview Your Selection[/{COLORS.GENERAL.HEADER}]\n\n" + ) + + if keep_subnets: + preview_content += ( + f"[green]✓ Keep as Alpha:[/green] {group_subnets(keep_subnets)}\n" + f"[dim] ({len(keep_subnets)} subnet{'s' if len(keep_subnets) != 1 else ''})[/dim]" + ) + else: + preview_content += "[dim]No subnets kept as Alpha[/dim]" + + if swap_subnets: + preview_content += ( + f"\n\n[yellow]⟳ Swap to TAO:[/yellow] {group_subnets(swap_subnets)}\n" + f"[dim] ({len(swap_subnets)} subnet{'s' if len(swap_subnets) != 1 else ''})[/dim]" + ) + else: + preview_content += "\n\n[dim]No subnets swapped to TAO[/dim]" + + console.print(Panel(preview_content)) + + return Confirm.ask("\nIs this correct?", default=True) + + +def _format_claim_type_display( + claim_info: dict, all_subnets: Optional[list[int]] = None +) -> str: + """ + Format claim type for human-readable display. + + Args: + claim_info: Claim type information dict + all_subnets: Optional list of all available subnets (for showing swap info) + """ + + claim_type = claim_info["type"] + if claim_type == "Swap": + return "[yellow]Swap All[/yellow]" + + elif claim_type == "Keep": + return "[dark_sea_green3]Keep All[/dark_sea_green3]" + + elif claim_type == "KeepSubnets": + subnets = claim_info["subnets"] + subnet_display = group_subnets(subnets) + + result = ( + f"[cyan]Keep Specific[/cyan]\n[green] ✓ Keep:[/green] {subnet_display}" + ) + if all_subnets: + swap_subnets = [n for n in all_subnets if n not in subnets] + if swap_subnets: + swap_display = group_subnets(swap_subnets) + result += f"\n[yellow] ⟳ Swap:[/yellow] {swap_display}" + + return result + else: + return "[red]Unknown[/red]" + + +def _claim_types_equal(claim1: dict, claim2: dict) -> bool: + """Check if two claim type configs are equivalent.""" + + if claim1["type"] != claim2["type"]: + return False + + if claim1["type"] == "KeepSubnets": + subnets1 = sorted(claim1.get("subnets", [])) + subnets2 = sorted(claim2.get("subnets", [])) + return subnets1 == subnets2 + + return True + + +def _prepare_claim_type_args(claim_info: dict) -> dict: + """Convert claim type arguments for chain call""" + + claim_type = claim_info["type"] + if claim_type == "Swap": + return {"Swap": None} + elif claim_type == "Keep": + return {"Keep": None} + elif claim_type == "KeepSubnets": + subnets = claim_info["subnets"] + return {"KeepSubnets": {"subnets": subnets}} + else: + raise ValueError(f"Unknown claim type: {claim_type}") diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index b2407bab7..d4a087970 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -50,9 +50,9 @@ async def get_stake_data(block_hash_: str = None): subtensor.all_subnets(block_hash=block_hash_), ) - claimable_amounts = {} + claimable_amounts_ = {} if sub_stakes_: - claimable_amounts = await subtensor.get_claimable_stakes_for_coldkey( + claimable_amounts_ = await subtensor.get_claimable_stakes_for_coldkey( coldkey_ss58=coldkey_address, stakes_info=sub_stakes_, block_hash=block_hash_, @@ -63,7 +63,7 @@ async def get_stake_data(block_hash_: str = None): sub_stakes_, registered_delegate_info_, dynamic_info__, - claimable_amounts, + claimable_amounts_, ) def define_table( @@ -628,7 +628,7 @@ def format_cell( total_tao_value = ( f"τ {millify_tao(all_hks_tao_value.tao + balance.tao)}" if not verbose - else all_hks_tao_value + else all_hks_tao_value + balance ) total_swapped_tao_value = ( f"τ {millify_tao(all_hks_swapped_tao_value.tao)}" @@ -648,8 +648,8 @@ def format_cell( f"[{COLOR_PALETTE.G.BALANCE}]{total_tao_value}[/{COLOR_PALETTE.G.BALANCE}]\n" ) dict_output["free_balance"] = balance.tao - dict_output["total_tao_value"] = all_hks_tao_value.tao - # dict_output["total_swapped_tao_value"] = all_hks_swapped_tao_value.tao + dict_output["total_tao_value"] = all_hks_tao_value.tao + balance.tao + dict_output["total_swapped_tao_value"] = all_hks_swapped_tao_value.tao if json_output: json_console.print(json.dumps(dict_output)) if not sub_stakes: diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 99a0b79ac..8f9255f70 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -1,6 +1,6 @@ import asyncio -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from bittensor_wallet import Wallet from rich.table import Table @@ -8,11 +8,14 @@ from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.extrinsics.mev_shield import ( + extract_mev_shield_id, + wait_for_extrinsic_by_hash, +) from bittensor_cli.src.bittensor.utils import ( console, err_console, print_error, - format_error_message, group_subnets, get_subnet_name, unlock_key, @@ -36,14 +39,15 @@ async def display_stake_movement_cross_subnets( amount_to_move: Balance, stake_fee: Balance, extrinsic_fee: Balance, + proxy: Optional[str] = None, ) -> tuple[Balance, str]: """Calculate and display stake movement information""" if origin_netuid == destination_netuid: subnet = await subtensor.subnet(origin_netuid) - received_amount_tao = ( - subnet.alpha_to_tao(amount_to_move - stake_fee) - extrinsic_fee - ) + received_amount_tao = subnet.alpha_to_tao(amount_to_move - stake_fee) + if not proxy: + received_amount_tao -= extrinsic_fee received_amount = subnet.tao_to_alpha(received_amount_tao) if received_amount < Balance.from_tao(0).set_unit(destination_netuid): @@ -458,6 +462,8 @@ async def move_stake( era: int, interactive_selection: bool = False, prompt: bool = True, + proxy: Optional[str] = None, + mev_protection: bool = True, ) -> tuple[bool, str]: if interactive_selection: try: @@ -472,6 +478,7 @@ async def move_stake( # Get the wallet stake balances. block_hash = await subtensor.substrate.get_chain_head() + # TODO should this use `proxy if proxy else wallet.coldkeypub.ss58_address`? origin_stake_balance, destination_stake_balance = await asyncio.gather( subtensor.get_stake( coldkey_ss58=wallet.coldkeypub.ss58_address, @@ -490,23 +497,23 @@ async def move_stake( if origin_stake_balance.tao == 0: print_error( f"Your balance is " - f"[{COLOR_PALETTE['POOLS']['TAO']}]0[/{COLOR_PALETTE['POOLS']['TAO']}] " + f"[{COLOR_PALETTE.POOLS.TAO}]0[/{COLOR_PALETTE.POOLS.TAO}] " f"in Netuid: " - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{origin_netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"[{COLOR_PALETTE.G.SUBHEAD}]{origin_netuid}[/{COLOR_PALETTE.G.SUBHEAD}]" ) return False, "" console.print( f"\nOrigin Netuid: " - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{origin_netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}], " + f"[{COLOR_PALETTE.G.SUBHEAD}]{origin_netuid}[/{COLOR_PALETTE.G.SUBHEAD}], " f"Origin stake: " - f"[{COLOR_PALETTE['POOLS']['TAO']}]{origin_stake_balance}[/{COLOR_PALETTE['POOLS']['TAO']}]" + f"[{COLOR_PALETTE.POOLS.TAO}]{origin_stake_balance}[/{COLOR_PALETTE.POOLS.TAO}]" ) console.print( f"Destination netuid: " - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{destination_netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}], " + f"[{COLOR_PALETTE.G.SUBHEAD}]{destination_netuid}[/{COLOR_PALETTE.G.SUBHEAD}], " f"Destination stake: " - f"[{COLOR_PALETTE['POOLS']['TAO']}]{destination_stake_balance}[/{COLOR_PALETTE['POOLS']['TAO']}]\n" + f"[{COLOR_PALETTE.POOLS.TAO}]{destination_stake_balance}[/{COLOR_PALETTE.POOLS.TAO}]\n" ) # Determine the amount we are moving. @@ -524,10 +531,8 @@ async def move_stake( if amount_to_move_as_balance > origin_stake_balance: err_console.print( f"[red]Not enough stake[/red]:\n" - f" Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f"{origin_stake_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f" < Moving amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f"{amount_to_move_as_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f" Stake balance: [{COLOR_PALETTE.S.AMOUNT}]{origin_stake_balance}[/{COLOR_PALETTE.S.AMOUNT}]" + f" < Moving amount: [{COLOR_PALETTE.S.AMOUNT}]{amount_to_move_as_balance}[/{COLOR_PALETTE.S.AMOUNT}]" ) return False, "" @@ -542,13 +547,15 @@ async def move_stake( "alpha_amount": amount_to_move_as_balance.rao, }, ) - sim_swap, extrinsic_fee = await asyncio.gather( + sim_swap, extrinsic_fee, next_nonce = await asyncio.gather( subtensor.sim_swap( origin_netuid=origin_netuid, destination_netuid=destination_netuid, amount=amount_to_move_as_balance.rao, ), - subtensor.get_extrinsic_fee(call, wallet.coldkeypub), + subtensor.get_extrinsic_fee(call, wallet.coldkeypub, proxy=proxy), + # TODO verify if this should be proxy or signer + subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), ) # Display stake movement details @@ -565,6 +572,7 @@ async def move_stake( if origin_netuid != 0 else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, + proxy=proxy, ) except ValueError: return False, "" @@ -578,27 +586,37 @@ async def move_stake( f"\n:satellite: Moving [blue]{amount_to_move_as_balance}[/blue] from [blue]{origin_hotkey}[/blue] on netuid: " f"[blue]{origin_netuid}[/blue] \nto " f"[blue]{destination_hotkey}[/blue] on netuid: [blue]{destination_netuid}[/blue] ..." - ): - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, era={"period": era} + ) as status: + success_, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, + nonce=next_nonce, ) - response = await subtensor.substrate.submit_extrinsic( - extrinsic, wait_for_inclusion=True, wait_for_finalization=False - ) - ext_id = await response.get_extrinsic_identifier() - if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") - return True, ext_id - else: - if not await response.is_success: - err_console.print( - f"\n:cross_mark: [red]Failed[/red] with error:" - f" {format_error_message(await response.error_message)}" + ext_id = await response.get_extrinsic_identifier() if response else "" + if success_: + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status, ) - return False, "" + if not mev_success: + status.stop() + err_console.print(f"\n:cross_mark: [red]Failed[/red]: {mev_error}") + return False, "" + await print_extrinsic_id(response) + if not prompt: + console.print(":white_heavy_check_mark: [green]Sent[/green]") + return True, ext_id else: - await print_extrinsic_id(response) console.print( ":white_heavy_check_mark: [dark_sea_green3]Stake moved.[/dark_sea_green3]" ) @@ -630,6 +648,9 @@ async def move_stake( f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_destination_stake_balance}" ) return True, ext_id + else: + err_console.print(f"\n:cross_mark: [red]Failed[/red] with error: {err_msg}") + return False, "" async def transfer_stake( @@ -644,22 +665,30 @@ async def transfer_stake( interactive_selection: bool = False, stake_all: bool = False, prompt: bool = True, + proxy: Optional[str] = None, + mev_protection: bool = True, ) -> tuple[bool, str]: """Transfers stake from one network to another. Args: - wallet (Wallet): Bittensor wallet object. - subtensor (SubtensorInterface): Subtensor interface instance. - amount (float): Amount to transfer. - origin_hotkey (str): The hotkey SS58 to transfer the stake from. - origin_netuid (int): The netuid to transfer stake from. - dest_netuid (int): The netuid to transfer stake to. - dest_coldkey_ss58 (str): The destination coldkey to transfer stake to. - interactive_selection (bool): If true, prompts for selection of origin and destination subnets. - prompt (bool): If true, prompts for confirmation before executing transfer. + wallet: Bittensor wallet object. + subtensor: Subtensor interface instance. + amount: Amount to transfer. + origin_hotkey: The hotkey SS58 to transfer the stake from. + origin_netuid: The netuid to transfer stake from. + dest_netuid: The netuid to transfer stake to. + dest_coldkey_ss58: The destination coldkey to transfer stake to. + interactive_selection: If true, prompts for selection of origin and destination subnets. + prompt: If true, prompts for confirmation before executing transfer. + era: number of blocks for which the extrinsic should be valid + stake_all: If true, transfer all stakes. + proxy: Optional proxy to use for this extrinsic + mev_protection: If true, will encrypt the extrinsic behind the mev protection shield. Returns: - bool: True if transfer was successful, False otherwise. + tuple: + bool: True if transfer was successful, False otherwise. + str: error message """ if interactive_selection: selection = await stake_move_transfer_selection(subtensor, wallet) @@ -685,6 +714,7 @@ async def transfer_stake( # Get current stake balances with console.status(f"Retrieving stake data from {subtensor.network}..."): + # TODO should use proxy for these checks? current_stake = await subtensor.get_stake( coldkey_ss58=wallet.coldkeypub.ss58_address, hotkey_ss58=origin_hotkey, @@ -731,13 +761,16 @@ async def transfer_stake( "alpha_amount": amount_to_transfer.rao, }, ) - sim_swap, extrinsic_fee = await asyncio.gather( + sim_swap, extrinsic_fee, next_nonce = await asyncio.gather( subtensor.sim_swap( origin_netuid=origin_netuid, destination_netuid=dest_netuid, amount=amount_to_transfer.rao, ), - subtensor.get_extrinsic_fee(call, wallet.coldkeypub), + subtensor.get_extrinsic_fee(call, wallet.coldkeypub, proxy=proxy), + subtensor.substrate.get_account_next_index( + proxy or wallet.coldkeypub.ss58_address + ), ) # Display stake movement details @@ -754,6 +787,7 @@ async def transfer_stake( if origin_netuid != 0 else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, + proxy=proxy, ) except ValueError: return False, "" @@ -765,50 +799,64 @@ async def transfer_stake( if not unlock_key(wallet).success: return False, "" - with console.status("\n:satellite: Transferring stake ..."): - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, era={"period": era} + with console.status("\n:satellite: Transferring stake ...") as status: + success_, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, + nonce=next_nonce, ) - response = await subtensor.substrate.submit_extrinsic( - extrinsic, wait_for_inclusion=True, wait_for_finalization=False - ) - ext_id = await response.get_extrinsic_identifier() - - if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") - return True, ext_id + if success_: + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status, + ) + if not mev_success: + status.stop() + err_console.print(f"\n:cross_mark: [red]Failed[/red]: {mev_error}") + return False, "" + await print_extrinsic_id(response) + ext_id = await response.get_extrinsic_identifier() + if not prompt: + console.print(":white_heavy_check_mark: [green]Sent[/green]") + return True, ext_id + else: + # Get and display new stake balances + new_stake, new_dest_stake = await asyncio.gather( + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=origin_hotkey, + netuid=origin_netuid, + ), + subtensor.get_stake( + coldkey_ss58=dest_coldkey_ss58, + hotkey_ss58=origin_hotkey, + netuid=dest_netuid, + ), + ) - if not await response.is_success: - err_console.print( - f":cross_mark: [red]Failed[/red] with error: " - f"{format_error_message(await response.error_message)}" - ) - return False, "" - await print_extrinsic_id(response) - # Get and display new stake balances - new_stake, new_dest_stake = await asyncio.gather( - subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=origin_hotkey, - netuid=origin_netuid, - ), - subtensor.get_stake( - coldkey_ss58=dest_coldkey_ss58, - hotkey_ss58=origin_hotkey, - netuid=dest_netuid, - ), - ) + console.print( + f"Origin Stake:\n [blue]{current_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE.S.AMOUNT}]{new_stake}" + ) + console.print( + f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE.S.AMOUNT}]{new_dest_stake}" + ) + return True, ext_id - console.print( - f"Origin Stake:\n [blue]{current_stake}[/blue] :arrow_right: " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" - ) - console.print( - f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_dest_stake}" - ) - return True, ext_id + else: + err_console.print(f":cross_mark: [red]Failed[/red] with error: {err_msg}") + return False, "" async def swap_stake( @@ -819,23 +867,29 @@ async def swap_stake( amount: float, swap_all: bool = False, era: int = 3, + proxy: Optional[str] = None, interactive_selection: bool = False, prompt: bool = True, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, + mev_protection: bool = True, ) -> tuple[bool, str]: """Swaps stake between subnets while keeping the same coldkey-hotkey pair ownership. Args: - wallet (Wallet): The wallet to swap stake from. - subtensor (SubtensorInterface): Subtensor interface instance. - origin_netuid (int): The netuid from which stake is removed. - destination_netuid (int): The netuid to which stake is added. - amount (float): The amount to swap. - interactive_selection (bool): If true, prompts for selection of origin and destination subnets. - prompt (bool): If true, prompts for confirmation before executing swap. - wait_for_inclusion (bool): If true, waits for the transaction to be included in a block. - wait_for_finalization (bool): If true, waits for the transaction to be finalized. + wallet: The wallet to swap stake from. + subtensor: Subtensor interface instance. + origin_netuid: The netuid from which stake is removed. + destination_netuid: The netuid to which stake is added. + amount: The amount to swap. + swap_all: Whether to swap all stakes. + era: The period (number of blocks) that the extrinsic is valid for + proxy: Optional proxy to use for this extrinsic submission + interactive_selection: If true, prompts for selection of origin and destination subnets. + prompt: If true, prompts for confirmation before executing swap. + wait_for_inclusion: If true, waits for the transaction to be included in a block. + wait_for_finalization: If true, waits for the transaction to be finalized. + mev_protection: If true, will encrypt the extrinsic behind the mev protection shield. Returns: (success, extrinsic_identifier): @@ -903,13 +957,16 @@ async def swap_stake( "alpha_amount": amount_to_swap.rao, }, ) - sim_swap, extrinsic_fee = await asyncio.gather( + sim_swap, extrinsic_fee, next_nonce = await asyncio.gather( subtensor.sim_swap( origin_netuid=origin_netuid, destination_netuid=destination_netuid, amount=amount_to_swap.rao, ), - subtensor.get_extrinsic_fee(call, wallet.coldkeypub), + subtensor.get_extrinsic_fee(call, wallet.coldkeypub, proxy=proxy), + subtensor.substrate.get_account_next_index( + proxy or wallet.coldkeypub.ss58_address + ), ) # Display stake movement details @@ -926,6 +983,7 @@ async def swap_stake( if origin_netuid != 0 else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, + proxy=proxy, ) except ValueError: return False, "" @@ -940,49 +998,64 @@ async def swap_stake( with console.status( f"\n:satellite: Swapping stake from netuid [blue]{origin_netuid}[/blue] " f"to netuid [blue]{destination_netuid}[/blue]..." - ): - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, era={"period": era} - ) - - response = await subtensor.substrate.submit_extrinsic( - extrinsic, - wait_for_inclusion=wait_for_inclusion, + ) as status: + success_, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + era={"period": era}, + proxy=proxy, wait_for_finalization=wait_for_finalization, + wait_for_inclusion=wait_for_inclusion, + mev_protection=mev_protection, + nonce=next_nonce, ) - ext_id = await response.get_extrinsic_identifier() - if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") - return True, ext_id + ext_id = await response.get_extrinsic_identifier() + + if success_: + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status, + ) + if not mev_success: + status.stop() + err_console.print(f"\n:cross_mark: [red]Failed[/red]: {mev_error}") + return False, "" + await print_extrinsic_id(response) + if not prompt: + console.print(":white_heavy_check_mark: [green]Sent[/green]") + return True, await response.get_extrinsic_identifier() + else: + # Get and display new stake balances + new_stake, new_dest_stake = await asyncio.gather( + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=origin_netuid, + ), + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=destination_netuid, + ), + ) - if not await response.is_success: - err_console.print( - f":cross_mark: [red]Failed[/red] with error: " - f"{format_error_message(await response.error_message)}" - ) - return False, "" - await print_extrinsic_id(response) - # Get and display new stake balances - new_stake, new_dest_stake = await asyncio.gather( - subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=hotkey_ss58, - netuid=origin_netuid, - ), - subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, - hotkey_ss58=hotkey_ss58, - netuid=destination_netuid, - ), - ) + console.print( + f"Origin Stake:\n [blue]{current_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE.S.AMOUNT}]{new_stake}" + ) + console.print( + f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " + f"[{COLOR_PALETTE.S.AMOUNT}]{new_dest_stake}" + ) + return True, ext_id - console.print( - f"Origin Stake:\n [blue]{current_stake}[/blue] :arrow_right: " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" - ) - console.print( - f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_dest_stake}" - ) - return True, ext_id + else: + err_console.print(f":cross_mark: [red]Failed[/red] with error: {err_msg}") + return False, "" diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 5d125cc16..85ce18ff5 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -9,8 +9,11 @@ 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.extrinsics.mev_shield import ( + extract_mev_shield_id, + wait_for_extrinsic_by_hash, +) from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.utils import ( console, @@ -48,9 +51,11 @@ async def unstake( allow_partial_stake: bool, json_output: bool, era: int, + proxy: Optional[str], + mev_protection: bool, ): """Unstake from hotkey(s).""" - + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address with console.status( f"Retrieving subnet data & identities from {subtensor.network}...", spinner="earth", @@ -65,9 +70,7 @@ async def unstake( subtensor.all_subnets(block_hash=chain_head), subtensor.fetch_coldkey_hotkey_identities(block_hash=chain_head), subtensor.get_delegate_identities(block_hash=chain_head), - subtensor.get_stake_for_coldkey( - wallet.coldkeypub.ss58_address, block_hash=chain_head - ), + subtensor.get_stake_for_coldkey(coldkey_ss58, block_hash=chain_head), ) all_sn_dynamic_info = {info.netuid: info for info in all_sn_dynamic_info_} @@ -223,6 +226,7 @@ async def unstake( netuid=netuid, price_limit=price_limit, allow_partial_stake=allow_partial_stake, + proxy=proxy, ) else: extrinsic_fee = await _get_extrinsic_fee( @@ -232,11 +236,14 @@ async def unstake( hotkey_ss58=staking_address_ss58, netuid=netuid, amount=amount_to_unstake_as_balance, + proxy=proxy, ) sim_swap = await subtensor.sim_swap( netuid, 0, amount_to_unstake_as_balance.rao ) - received_amount = sim_swap.tao_amount - extrinsic_fee + received_amount = sim_swap.tao_amount + if not proxy: + received_amount -= extrinsic_fee except ValueError: continue total_received_amount += received_amount @@ -297,7 +304,7 @@ async def unstake( table = _create_unstake_table( wallet_name=wallet.name, - wallet_coldkey_ss58=wallet.coldkeypub.ss58_address, + wallet_coldkey_ss58=coldkey_ss58, network=subtensor.network, total_received_amount=total_received_amount, safe_staking=safe_staking, @@ -326,6 +333,8 @@ async def unstake( "hotkey_ss58": op["hotkey_ss58"], "status": status, "era": era, + "proxy": proxy, + "mev_protection": mev_protection, } if safe_staking and op["netuid"] != 0: @@ -370,10 +379,13 @@ async def unstake_all( era: int = 3, prompt: bool = True, json_output: bool = False, + proxy: Optional[str] = None, + mev_protection: bool = True, ) -> None: """Unstakes all stakes from all hotkeys in all subnets.""" include_hotkeys = include_hotkeys or [] exclude_hotkeys = exclude_hotkeys or [] + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address with console.status( f"Retrieving stake information & identities from {subtensor.network}...", spinner="earth", @@ -385,11 +397,11 @@ async def unstake_all( all_sn_dynamic_info_, current_wallet_balance, ) = await asyncio.gather( - subtensor.get_stake_for_coldkey(wallet.coldkeypub.ss58_address), + subtensor.get_stake_for_coldkey(coldkey_ss58), subtensor.fetch_coldkey_hotkey_identities(), subtensor.get_delegate_identities(), subtensor.all_subnets(), - subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_balance(coldkey_ss58), ) if all_hotkeys: @@ -431,10 +443,10 @@ async def unstake_all( ) 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" + f"\n[{COLOR_PALETTE.G.HEADER}]{table_title}[/{COLOR_PALETTE.G.HEADER}]\n" + f"Wallet: [{COLOR_PALETTE.G.COLDKEY}]{wallet.name}[/{COLOR_PALETTE.G.COLDKEY}], " + f"Coldkey ss58: [{COLOR_PALETTE.G.CK}]{coldkey_ss58}[/{COLOR_PALETTE.G.CK}]\n" + f"Network: [{COLOR_PALETTE.G.HEADER}]{subtensor.network}[/{COLOR_PALETTE.G.HEADER}]\n" ), show_footer=True, show_edge=False, @@ -500,9 +512,12 @@ async def unstake_all( wallet, subtensor, hotkey_ss58=stake.hotkey_ss58, + proxy=proxy, ) sim_swap = await subtensor.sim_swap(stake.netuid, 0, stake_amount.rao) - received_amount = sim_swap.tao_amount - extrinsic_fee + received_amount = sim_swap.tao_amount + if not proxy: + received_amount -= extrinsic_fee if received_amount < Balance.from_tao(0): print_error("Not enough Alpha to pay the transaction fee.") @@ -546,6 +561,8 @@ async def unstake_all( unstake_all_alpha=unstake_all_alpha, status=status, era=era, + proxy=proxy, + mev_protection=mev_protection, ) ext_id = await ext_receipt.get_extrinsic_identifier() if success else None successes[hotkey_ss58] = { @@ -566,6 +583,8 @@ async def _unstake_extrinsic( hotkey_ss58: str, status=None, era: int = 3, + proxy: Optional[str] = None, + mev_protection: bool = True, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute a standard unstake extrinsic. @@ -578,19 +597,23 @@ async def _unstake_extrinsic( subtensor: Subtensor interface status: Optional status for console updates era: blocks for which the transaction is valid + proxy: Optional proxy to use for this extrinsic submission + """ err_out = partial(print_error, status=status) failure_prelude = ( f":cross_mark: [red]Failed[/red] to unstake {amount} on Netuid {netuid}" ) + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if status: status.update( f"\n:satellite: Unstaking {amount} from {hotkey_ss58} on netuid: {netuid} ..." ) - current_balance, call = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address), + current_balance, next_nonce, call = await asyncio.gather( + subtensor.get_balance(coldkey_ss58), + subtensor.substrate.get_account_next_index(coldkey_ss58), subtensor.substrate.compose_call( call_module="SubtensorModule", call_function="remove_stake", @@ -601,28 +624,38 @@ async def _unstake_extrinsic( }, ), ) - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, era={"period": era} - ) - try: - response = await subtensor.substrate.submit_extrinsic( - extrinsic, wait_for_inclusion=True, wait_for_finalization=False - ) - if not await response.is_success: - err_out( - f"{failure_prelude} with error: " - f"{format_error_message(await response.error_message)}" + success, err_msg, response = await subtensor.sign_and_send_extrinsic( + # TODO I think this should handle announce-only + call=call, + wallet=wallet, + era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, + nonce=next_nonce, + ) + if success: + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status, ) - return False, None - # Fetch latest balance and stake + if not mev_success: + status.stop() + err_console.print(f"\n:cross_mark: [red]Failed[/red]: {mev_error}") + return False, None await print_extrinsic_id(response) 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_balance(coldkey_ss58, block_hash), subtensor.get_stake( hotkey_ss58=hotkey_ss58, - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, netuid=netuid, block_hash=block_hash, ), @@ -630,16 +663,18 @@ async def _unstake_extrinsic( 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}" + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE.S.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}" + f"Subnet: [{COLOR_PALETTE.G.SUBHEAD}]{netuid}[/{COLOR_PALETTE.G.SUBHEAD}]" + f" Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_stake}" ) return True, response - - except Exception as e: - err_out(f"{failure_prelude} with error: {str(e)}") + else: + err_out( + f"{failure_prelude} with error: " + f"{format_error_message(await response.error_message)}" + ) return False, None @@ -653,6 +688,8 @@ async def _safe_unstake_extrinsic( allow_partial_stake: bool, status=None, era: int = 3, + proxy: Optional[str] = None, + mev_protection: bool = True, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute a safe unstake extrinsic with price limit. @@ -665,11 +702,14 @@ async def _safe_unstake_extrinsic( subtensor: Subtensor interface allow_partial_stake: Whether to allow partial unstaking status: Optional status for console updates + proxy: Optional proxy to use for unstake extrinsic + """ err_out = partial(print_error, status=status) failure_prelude = ( f":cross_mark: [red]Failed[/red] to unstake {amount} on Netuid {netuid}" ) + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if status: status.update( @@ -679,11 +719,11 @@ async def _safe_unstake_extrinsic( block_hash = await subtensor.substrate.get_chain_head() current_balance, next_nonce, current_stake, call = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), - subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), + subtensor.get_balance(coldkey_ss58, block_hash), + subtensor.substrate.get_account_next_index(coldkey_ss58), subtensor.get_stake( hotkey_ss58=hotkey_ss58, - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, netuid=netuid, block_hash=block_hash, ), @@ -700,63 +740,72 @@ async def _safe_unstake_extrinsic( block_hash=block_hash, ), ) - - extrinsic = await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, nonce=next_nonce, era={"period": era} + success, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + nonce=next_nonce, + era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, ) - - 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.", + if success: + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, status=status, ) - else: - err_out(f"\n{failure_prelude} with error: {format_error_message(e)}") - return False, None + if not mev_success: + status.stop() + err_console.print(f"\n:cross_mark: [red]Failed[/red]: {mev_error}") + return False, None + await print_extrinsic_id(response) + block_hash = await subtensor.substrate.get_chain_head() + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance(coldkey_ss58, block_hash), + subtensor.get_stake( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=coldkey_ss58, + netuid=netuid, + block_hash=block_hash, + ), + ) - if not await response.is_success: - err_out( - f"\n{failure_prelude} with error: {format_error_message(await response.error_message)}" + console.print(":white_heavy_check_mark: [green]Finalized[/green]") + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_balance}" ) - return False, None - await print_extrinsic_id(response) - 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.S.AMOUNT}]{amount_unstaked.set_unit(netuid=netuid)}[/{COLOR_PALETTE.S.AMOUNT}] " + f"instead of " + f"[blue]{amount}[/blue]" + ) - 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]" + f"Subnet: [{COLOR_PALETTE.G.SUBHEAD}]{netuid}[/{COLOR_PALETTE.G.SUBHEAD}] " + f"Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_stake}" ) - - 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}" - ) - return True, response + return True, response + elif "Custom error: 8" in err_msg: + 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, + ) + else: + err_out( + f"\n{failure_prelude} with error: {format_error_message(await response.error_message)}" + ) + return False, None async def _unstake_all_extrinsic( @@ -767,6 +816,8 @@ async def _unstake_all_extrinsic( unstake_all_alpha: bool, status=None, era: int = 3, + proxy: Optional[str] = None, + mev_protection: bool = True, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute an unstake all extrinsic. @@ -782,6 +833,7 @@ async def _unstake_all_extrinsic( failure_prelude = ( f":cross_mark: [red]Failed[/red] to unstake all from {hotkey_name}" ) + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if status: status.update( @@ -793,44 +845,58 @@ async def _unstake_all_extrinsic( previous_root_stake, current_balance = await asyncio.gather( subtensor.get_stake( hotkey_ss58=hotkey_ss58, - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, netuid=0, block_hash=block_hash, ), - subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=block_hash - ), + subtensor.get_balance(coldkey_ss58, block_hash=block_hash), ) else: current_balance = await subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=block_hash + coldkey_ss58, block_hash=block_hash ) previous_root_stake = None call_function = "unstake_all_alpha" if unstake_all_alpha else "unstake_all" - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function=call_function, - call_params={"hotkey": hotkey_ss58}, + call, next_nonce = await asyncio.gather( + subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function=call_function, + call_params={"hotkey": hotkey_ss58}, + ), + subtensor.substrate.get_account_next_index(coldkey_ss58), ) - try: - response = await subtensor.substrate.submit_extrinsic( - extrinsic=await subtensor.substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey, era={"period": era} - ), - wait_for_inclusion=True, - wait_for_finalization=False, + success_, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + era={"period": era}, + nonce=next_nonce, + proxy=proxy, + mev_protection=mev_protection, ) - if not await response.is_success: - err_out( - f"{failure_prelude} with error: " - f"{format_error_message(await response.error_message)}" - ) + if not success_: + err_out(f"{failure_prelude} with error: {err_msg}") return False, None - else: - await print_extrinsic_id(response) + + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status, + ) + if not mev_success: + status.stop() + err_msg = f"{failure_prelude}: {mev_error}" + err_out("\n" + err_msg) + return False, None + + await print_extrinsic_id(response) # Fetch latest balance and stake block_hash = await subtensor.substrate.get_chain_head() @@ -838,35 +904,33 @@ async def _unstake_all_extrinsic( new_root_stake, new_balance = await asyncio.gather( subtensor.get_stake( hotkey_ss58=hotkey_ss58, - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, netuid=0, block_hash=block_hash, ), - subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=block_hash - ), + subtensor.get_balance(coldkey_ss58, block_hash=block_hash), ) else: new_balance = await subtensor.get_balance( - wallet.coldkeypub.ss58_address, block_hash=block_hash + coldkey_ss58, block_hash=block_hash ) new_root_stake = None + msg_modifier = "Alpha " if unstake_all_alpha else "" success_message = ( - ":white_heavy_check_mark: [green]Finalized: Successfully unstaked all stakes[/green]" - if not unstake_all_alpha - else ":white_heavy_check_mark: [green]Finalized: Successfully unstaked all Alpha stakes[/green]" + f":white_heavy_check_mark: [green]Included:" + f" Successfully unstaked all {msg_modifier}stakes[/green]" ) console.print(f"{success_message} from {hotkey_name}") console.print( - f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_balance}" ) if unstake_all_alpha: console.print( f"Root Stake for {hotkey_name}:\n " f"[blue]{previous_root_stake}[/blue] :arrow_right: " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_root_stake}" + f"[{COLOR_PALETTE.S.AMOUNT}]{new_root_stake}" ) return True, response @@ -884,6 +948,7 @@ async def _get_extrinsic_fee( amount: Optional[Balance] = None, price_limit: Optional[Balance] = None, allow_partial_stake: bool = False, + proxy: Optional[str] = None, ) -> Balance: """ Retrieves the extrinsic fee for a given unstaking call. @@ -929,7 +994,7 @@ async def _get_extrinsic_fee( call_function=call_fn, call_params=call_params, ) - return await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + return await subtensor.get_extrinsic_fee(call, wallet.coldkeypub, proxy=proxy) # Helpers @@ -980,7 +1045,7 @@ async def _unstake_selection( # 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", + title=f"\n[{COLOR_PALETTE.G.HEADER}]Hotkeys with Stakes{subnet_filter}\n", show_footer=True, show_edge=False, header_style="bold white", @@ -991,9 +1056,9 @@ async def _unstake_selection( 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"]) + table.add_column("Identity", style=COLOR_PALETTE.G.SUBHEAD) + table.add_column("Netuids", style=COLOR_PALETTE.G.NETUID) + table.add_column("Hotkey Address", style=COLOR_PALETTE.G.HK) for hotkey_info in hotkeys_info: index = str(hotkey_info["index"]) @@ -1066,7 +1131,8 @@ async def _unstake_selection( 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." + f"The following netuids are invalid or not available: " + f"{', '.join(map(str, invalid_netuids))}. Please try again." ) else: selected_netuids = netuid_list @@ -1259,10 +1325,10 @@ def _create_unstake_table( 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" + f"\n[{COLOR_PALETTE.G.HEADER}]Unstaking to: \n" + f"Wallet: [{COLOR_PALETTE.G.CK}]{wallet_name}[/{COLOR_PALETTE.G.CK}], " + f"Coldkey ss58: [{COLOR_PALETTE.G.CK}]{wallet_coldkey_ss58}[/{COLOR_PALETTE.G.CK}]\n" + f"Network: {network}[/{COLOR_PALETTE.G.HEADER}]\n" ) table = Table( title=title, @@ -1338,10 +1404,11 @@ def _print_table_and_slippage( 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']}]," + f"[{COLOR_PALETTE.S.SLIPPAGE_TEXT}]{'-' * console.width}\n" + f"[bold]WARNING:[/bold] The slippage on one of your operations is high: " + f"[{COLOR_PALETTE.S.SLIPPAGE_PERCENT}]{max_float_slippage} %[/{COLOR_PALETTE.S.SLIPPAGE_PERCENT}]," " this may result in a loss of funds.\n" - f"-------------------------------------------------------------------------------------------------------------------\n" + f"{'-' * console.width}\n" ) base_description = """ [bold white]Description[/bold white]: diff --git a/bittensor_cli/src/commands/stake/wizard.py b/bittensor_cli/src/commands/stake/wizard.py new file mode 100644 index 000000000..f1886f65e --- /dev/null +++ b/bittensor_cli/src/commands/stake/wizard.py @@ -0,0 +1,323 @@ +""" +Wizard command for guiding users through stake movement operations. + +This module provides an interactive wizard that helps users understand and select +the appropriate stake movement command (move, transfer, or swap) based on their needs. +""" + +import asyncio +from typing import TYPE_CHECKING, Optional + +from bittensor_wallet import Wallet +from rich.prompt import Prompt +from rich.table import Table +from rich.panel import Panel + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.utils import ( + console, + print_error, + is_valid_ss58_address, + get_hotkey_pub_ss58, + group_subnets, + get_hotkey_wallets_for_wallet, +) +from bittensor_cli.src.commands.stake.move import ( + stake_move_transfer_selection, + stake_swap_selection, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def stake_movement_wizard( + subtensor: "SubtensorInterface", + wallet: Wallet, +) -> Optional[dict]: + """ + Interactive wizard that guides users through stake movement operations. + + This wizard helps users understand the differences between: + - move: Move stake between hotkeys (same coldkey) + - transfer: Transfer stake between coldkeys (same hotkey) + - swap: Swap stake between subnets (same coldkey-hotkey pair) + + Args: + subtensor: SubtensorInterface object + wallet: Wallet object + + Returns: + dict: Contains the operation type and parameters needed to execute the operation + """ + + # Display welcome message and explanation + console.print("\n") + console.print( + Panel( + "[bold cyan]Stake Movement Wizard[/bold cyan]\n\n" + "This wizard will help you choose the right stake movement operation.\n" + "There are three types of stake movements:\n\n" + "[bold]1. Move[/bold] - Move stake between [blue]hotkeys[/blue] while keeping the same [blue]coldkey[/blue]\n" + " Example: Moving stake from hotkey A to hotkey B (both owned by your coldkey)\n\n" + "[bold]2. Transfer[/bold] - Transfer stake between [blue]coldkeys[/blue] while keeping the same [blue]hotkey[/blue]\n" + " Example: Transferring stake ownership from your coldkey to another coldkey (same hotkey)\n\n" + "[bold]3. Swap[/bold] - Swap stake between [blue]subnets[/blue] while keeping the same [blue]coldkey-hotkey pair[/blue]\n" + " Example: Moving stake from subnet 1 to subnet 2 (same wallet and hotkey)\n", + title="Welcome", + border_style="cyan", + ) + ) + + # Ask user what they want to do + operation_choice = Prompt.ask( + "\n[bold]What would you like to do?[/bold]", + choices=["1", "2", "3", "move", "transfer", "swap", "q"], + default="q", + ) + + if operation_choice.lower() == "q": + console.print("[yellow]Wizard cancelled.[/yellow]") + return None + + # Normalize choice + if operation_choice in ["1", "move"]: + operation = "move" + operation_name = "Move" + description = "Move stake between hotkeys (same coldkey)" + elif operation_choice in ["2", "transfer"]: + operation = "transfer" + operation_name = "Transfer" + description = "Transfer stake between coldkeys (same hotkey)" + elif operation_choice in ["3", "swap"]: + operation = "swap" + operation_name = "Swap" + description = "Swap stake between subnets (same coldkey-hotkey pair)" + else: + print_error("Invalid choice") + return None + + console.print(f"\n[bold green]Selected: {operation_name}[/bold green]") + console.print(f"[dim]{description}[/dim]\n") + + # Get stakes for the wallet + with console.status("Retrieving stake information..."): + stakes, ck_hk_identities, old_identities = await asyncio.gather( + subtensor.get_stake_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address + ), + subtensor.fetch_coldkey_hotkey_identities(), + subtensor.get_delegate_identities(), + ) + + # Filter stakes with actual amounts + available_stakes = [s for s in stakes if s.stake.tao > 0] + + if not available_stakes: + print_error("You have no stakes available to move.") + return None + + # Display available stakes + _display_available_stakes(available_stakes, ck_hk_identities, old_identities) + + # Guide user through the specific operation + if operation == "move": + return await _guide_move_operation( + subtensor, wallet, available_stakes, ck_hk_identities, old_identities + ) + elif operation == "transfer": + return await _guide_transfer_operation( + subtensor, wallet, available_stakes, ck_hk_identities, old_identities + ) + elif operation == "swap": + return await _guide_swap_operation(subtensor, wallet, available_stakes) + else: + raise ValueError(f"Unknown operation: {operation}") + + +def _display_available_stakes( + stakes: list, + ck_hk_identities: dict, + old_identities: dict, +): + """Display a table of available stakes.""" + # Group stakes by hotkey + hotkey_stakes = {} + for stake in stakes: + hotkey = stake.hotkey_ss58 + if hotkey not in hotkey_stakes: + hotkey_stakes[hotkey] = {} + hotkey_stakes[hotkey][stake.netuid] = stake.stake + + # Get identities + def get_identity(hotkey_ss58_: str) -> str: + if hk_identity := ck_hk_identities["hotkeys"].get(hotkey_ss58_): + return hk_identity.get("identity", {}).get("name", "") or hk_identity.get( + "display", "~" + ) + elif old_identity := old_identities.get(hotkey_ss58_): + return old_identity.display + return "~" + + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Your Available Stakes[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n", + show_edge=False, + header_style="bold white", + border_style="bright_black", + title_justify="center", + ) + + table.add_column("Hotkey Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]) + table.add_column("Hotkey Address", style=COLOR_PALETTE["GENERAL"]["HOTKEY"]) + table.add_column("Netuids", style=COLOR_PALETTE["GENERAL"]["NETUID"]) + table.add_column("Total Stake", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"]) + + for hotkey_ss58, netuid_stakes in hotkey_stakes.items(): + identity = get_identity(hotkey_ss58) + netuids = sorted(netuid_stakes.keys()) + total_stake = sum( + netuid_stakes.values(), start=stakes[0].stake.__class__.from_tao(0) + ) + + table.add_row( + identity, + f"{hotkey_ss58[:8]}...{hotkey_ss58[-8:]}", + group_subnets(netuids), + str(total_stake), + ) + + console.print(table) + + +async def _guide_move_operation( + subtensor: "SubtensorInterface", + wallet: Wallet, + available_stakes: list, + ck_hk_identities: dict, + old_identities: dict, +) -> dict: + """Guide user through move operation.""" + console.print( + "\n[bold cyan]Move Operation[/bold cyan]\n" + "You will move stake from one hotkey to another hotkey.\n" + "Both hotkeys must be owned by the same coldkey (your wallet).\n" + ) + + try: + selection = await stake_move_transfer_selection(subtensor, wallet) + + # Get available hotkeys for destination + all_hotkeys = get_hotkey_wallets_for_wallet(wallet=wallet) + available_hotkeys = [ + (hk.hotkey_str, get_hotkey_pub_ss58(hk)) for hk in all_hotkeys + ] + + # Ask for destination hotkey + console.print("\n[bold]Destination Hotkey[/bold]") + if available_hotkeys: + console.print("\nAvailable hotkeys in your wallet:") + for idx, (name, ss58) in enumerate(available_hotkeys): + console.print(f" {idx}: {name} ({ss58[:8]}...{ss58[-8:]})") + + dest_choice = Prompt.ask( + "\nEnter the [blue]index[/blue] of the destination hotkey, or [blue]SS58 address[/blue]", + ) + + try: + dest_idx = int(dest_choice) + if 0 <= dest_idx < len(available_hotkeys): + dest_hotkey = available_hotkeys[dest_idx][1] + else: + raise ValueError("Invalid index") + except ValueError: + # Assume it's an SS58 address + if is_valid_ss58_address(dest_choice): + dest_hotkey = dest_choice + else: + print_error( + "Invalid hotkey selection. Please provide a valid index or SS58 address." + ) + raise ValueError("Invalid destination hotkey") + else: + dest_hotkey = Prompt.ask( + "Enter the [blue]destination hotkey[/blue] SS58 address" + ) + if not is_valid_ss58_address(dest_hotkey): + print_error("Invalid SS58 address") + raise ValueError("Invalid destination hotkey") + + return { + "operation": "move", + "origin_hotkey": selection["origin_hotkey"], + "origin_netuid": selection["origin_netuid"], + "destination_netuid": selection["destination_netuid"], + "destination_hotkey": dest_hotkey, + "amount": selection["amount"], + "stake_all": selection["stake_all"], + } + except ValueError: + raise + + +async def _guide_transfer_operation( + subtensor: "SubtensorInterface", + wallet: Wallet, + available_stakes: list, + ck_hk_identities: dict, + old_identities: dict, +) -> dict: + """Guide user through transfer operation.""" + console.print( + "\n[bold cyan]Transfer Operation[/bold cyan]\n" + "You will transfer stake ownership from one coldkey to another coldkey.\n" + "The hotkey remains the same, but ownership changes.\n" + "[yellow]Warning:[/yellow] Make sure the destination coldkey is not a validator hotkey.\n" + ) + + try: + selection = await stake_move_transfer_selection(subtensor, wallet) + + # Ask for destination coldkey + console.print("\n[bold]Destination Coldkey[/bold]") + dest_coldkey = Prompt.ask( + "Enter the [blue]destination coldkey[/blue] SS58 address or wallet name" + ) + + # Note: The CLI will handle wallet name resolution if it's not an SS58 address + + return { + "operation": "transfer", + "origin_hotkey": selection["origin_hotkey"], + "origin_netuid": selection["origin_netuid"], + "destination_netuid": selection["destination_netuid"], + "destination_coldkey": dest_coldkey, + "amount": selection["amount"], + "stake_all": selection["stake_all"], + } + except ValueError: + raise + + +async def _guide_swap_operation( + subtensor: "SubtensorInterface", + wallet: Wallet, + available_stakes: list, +) -> dict: + """Guide user through swap operation.""" + console.print( + "\n[bold cyan]Swap Operation[/bold cyan]\n" + "You will swap stake between subnets.\n" + "The same coldkey-hotkey pair is used, but stake moves between subnets.\n" + ) + + try: + selection = await stake_swap_selection(subtensor, wallet) + + return { + "operation": "swap", + "origin_netuid": selection["origin_netuid"], + "destination_netuid": selection["destination_netuid"], + "amount": selection["amount"], + } + except ValueError: + raise diff --git a/bittensor_cli/src/commands/subnets/mechanisms.py b/bittensor_cli/src/commands/subnets/mechanisms.py index 2ad5d72db..dfa7c165b 100644 --- a/bittensor_cli/src/commands/subnets/mechanisms.py +++ b/bittensor_cli/src/commands/subnets/mechanisms.py @@ -182,6 +182,7 @@ async def set_emission_split( wallet: Wallet, netuid: int, new_emission_split: Optional[str], + proxy: Optional[str], wait_for_inclusion: bool, wait_for_finalization: bool, prompt: bool, @@ -356,6 +357,7 @@ async def set_emission_split( subtensor=subtensor, netuid=netuid, split=normalized_weights, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, json_output=json_output, @@ -406,6 +408,7 @@ async def set_mechanism_count( netuid: int, mechanism_count: int, previous_count: int, + proxy: Optional[str], wait_for_inclusion: bool, wait_for_finalization: bool, json_output: bool, @@ -436,6 +439,7 @@ async def set_mechanism_count( wallet=wallet, netuid=netuid, mech_count=mechanism_count, + proxy=proxy, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) @@ -461,6 +465,7 @@ async def set_mechanism_emission( subtensor: "SubtensorInterface", netuid: int, split: list[int], + proxy: Optional[str], wait_for_inclusion: bool, wait_for_finalization: bool, json_output: bool, @@ -480,6 +485,7 @@ async def set_mechanism_emission( split=split, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + proxy=proxy, ) ext_id = await ext_receipt.get_extrinsic_identifier() if success else None diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index c2346880b..509e36957 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -3,6 +3,7 @@ import sqlite3 from typing import TYPE_CHECKING, Optional, cast +from async_substrate_interface import AsyncExtrinsicReceipt from bittensor_wallet import Wallet from rich.prompt import Confirm, Prompt from rich.console import Group @@ -10,23 +11,26 @@ from rich.table import Column, Table from rich import box -from bittensor_cli.src import COLOR_PALETTE, Constants +from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.extrinsics.registration import ( register_extrinsic, burned_register_extrinsic, ) from bittensor_cli.src.bittensor.extrinsics.root import root_register_extrinsic +from bittensor_cli.src.bittensor.extrinsics.mev_shield import ( + extract_mev_shield_id, + wait_for_extrinsic_by_hash, +) from rich.live import Live from bittensor_cli.src.bittensor.minigraph import MiniGraph from bittensor_cli.src.commands.wallets import set_id, get_id from bittensor_cli.src.bittensor.utils import ( console, - create_table, + create_and_populate_table, err_console, print_verbose, print_error, - format_error_message, get_metadata_table, millify_tao, render_table, @@ -49,13 +53,73 @@ # helpers and extrinsics +def format_claim_type_for_root(claim_info: dict, total_subnets: int) -> str: + """ + Format claim type for root network metagraph. + + Args: + claim_info: Claim type dict {"type": "...", "subnets": [...]} + total_subnets: Total number of subnets in network (excluding netuid 0) + + Returns: + Formatted string showing keep/swap counts + + Examples: + {"type": "Keep"} → "Keep all" + {"type": "Swap"} → "Swap all" + {"type": "KeepSubnets", "subnets": [1,2,3]} → "Keep (3), Swap (54)" + """ + claim_type = claim_info.get("type", "Swap") + + if claim_type == "Keep": + return "Keep all" + elif claim_type == "Swap": + return "Swap all" + else: + keep_subnets = claim_info.get("subnets", []) + keep_count = len(keep_subnets) + swap_count = total_subnets - keep_count + return f"Keep ({keep_count}), Swap ({swap_count})" + + +def format_claim_type_for_subnet(claim_info: dict, current_netuid: int) -> str: + """ + Format claim type for specific subnet metagraph. + Shows whether THIS subnet's emissions are kept or swapped. + + Args: + claim_info: Claim type dict {"type": "...", "subnets": [...]} + current_netuid: The netuid being viewed + + Returns: + "Keep" if this subnet is kept, "Swap" if swapped + + Examples: + {"type": "Keep"}, netuid=5 → "Keep" + {"type": "Swap"}, netuid=5 → "Swap" + {"type": "KeepSubnets", "subnets": [1,5,10]}, netuid=5 → "Keep" + {"type": "KeepSubnets", "subnets": [1,5,10]}, netuid=3 → "Swap" + """ + claim_type = claim_info.get("type", "Swap") + + if claim_type == "Keep": + return "Keep" + elif claim_type == "Swap": + return "Swap" + else: + keep_subnets = claim_info.get("subnets", []) + return "Keep" if current_netuid in keep_subnets else "Swap" + + async def register_subnetwork_extrinsic( subtensor: "SubtensorInterface", wallet: Wallet, subnet_identity: dict, + proxy: Optional[str], wait_for_inclusion: bool = False, wait_for_finalization: bool = True, prompt: bool = False, + mev_protection: bool = True, ) -> tuple[bool, Optional[int], Optional[str]]: """Registers a new subnetwork. @@ -75,8 +139,9 @@ async def register_subnetwork_extrinsic( extrinsic_identifier: Optional extrinsic identifier, if the extrinsic was included. """ + # TODO why doesn't this have an era? async def _find_event_attributes_in_extrinsic_receipt( - response_, event_name: str + response_: AsyncExtrinsicReceipt, event_name: str ) -> list: """ Searches for the attributes of a specified event within an extrinsic receipt. @@ -93,18 +158,19 @@ async def _find_event_attributes_in_extrinsic_receipt( if event_details["event_id"] == event_name: # Once found, you can access the attributes of the event_name return event_details["attributes"] - return [-1] + return [] print_verbose("Fetching balance") - your_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + + your_balance = await subtensor.get_balance(proxy or wallet.coldkeypub.ss58_address) print_verbose("Fetching burn_cost") sn_burn_cost = await burn_cost(subtensor) if sn_burn_cost > your_balance: err_console.print( - f"Your balance of: [{COLOR_PALETTE['POOLS']['TAO']}]{your_balance}[{COLOR_PALETTE['POOLS']['TAO']}]" + f"Your balance of: [{COLOR_PALETTE.POOLS.TAO}]{your_balance}[{COLOR_PALETTE.POOLS.TAO}]" f" is not enough to burn " - f"[{COLOR_PALETTE['POOLS']['TAO']}]{sn_burn_cost}[{COLOR_PALETTE['POOLS']['TAO']}] " + f"[{COLOR_PALETTE.POOLS.TAO}]{sn_burn_cost}[{COLOR_PALETTE.POOLS.TAO}] " f"to register a subnet." ) return False, None, None @@ -166,44 +232,71 @@ async def _find_event_attributes_in_extrinsic_receipt( if not unlock_key(wallet).success: return False, None, None - with console.status(":satellite: Registering subnet...", spinner="earth"): + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address + + with console.status(":satellite: Registering subnet...", spinner="earth") as status: substrate = subtensor.substrate - # create extrinsic call - call = await substrate.compose_call( - call_module="SubtensorModule", - call_function=call_function, - call_params=call_params, - ) - extrinsic = await substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey + call, next_nonce = await asyncio.gather( + substrate.compose_call( + call_module="SubtensorModule", + call_function=call_function, + call_params=call_params, + ), + substrate.get_account_next_index(coldkey_ss58), ) - response = await substrate.submit_extrinsic( - extrinsic, + success, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + proxy=proxy, + nonce=next_nonce, + mev_protection=mev_protection, ) # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: return True, None, None - if not await response.is_success: - err_console.print( - f":cross_mark: [red]Failed[/red]: {format_error_message(await response.error_message)}" - ) - await asyncio.sleep(0.5) + if not success: + err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") return False, None, None - - # Successful registration, final check for membership else: + # Check for MEV shield execution + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(response) + mev_success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=response.block_hash, + status=status, + ) + if not mev_success: + status.stop() + err_console.print( + f":cross_mark: [red]Failed[/red]: MEV execution failed: {mev_error}" + ) + return False, None, None + + # Successful registration, final check for membership + attributes = await _find_event_attributes_in_extrinsic_receipt( response, "NetworkAdded" ) await print_extrinsic_id(response) ext_id = await response.get_extrinsic_identifier() - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" - ) + if not attributes: + console.print( + ":exclamation: [yellow]A possible error has occurred[/yellow]. The extrinsic reports success, but " + "we are unable to locate the 'NetworkAdded' event inside the extrinsic's events." + "" + ) + else: + console.print( + f":white_heavy_check_mark: [dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" + ) return True, int(attributes[0]), ext_id @@ -1077,7 +1170,9 @@ async def show_root(): ) coldkey_ss58 = root_state.coldkeys[idx] - claim_type = root_claim_types.get(coldkey_ss58, "Swap") + claim_type_info = root_claim_types.get(coldkey_ss58, {"type": "Swap"}) + total_subnets = len([n for n in all_subnets if n != 0]) + claim_type = format_claim_type_for_root(claim_type_info, total_subnets) sorted_rows.append( ( @@ -1151,7 +1246,8 @@ async def show_root(): - Emission: The emission accrued to this hotkey across all subnets every block measured in TAO. - Hotkey: The hotkey ss58 address. - Coldkey: The coldkey ss58 address. - - Root Claim: The root claim type for this coldkey. 'Swap' converts Alpha to TAO every epoch. 'Keep' keeps Alpha emissions. + - Root Claim: The root claim type for this coldkey. 'Swap' converts Alpha to TAO every epoch. 'Keep' keeps Alpha emissions. + 'Keep (count)' indicates how many subnets this coldkey is keeping Alpha emissions for. """ ) if delegate_selection: @@ -1326,10 +1422,12 @@ async def show_subnet( # Get claim type for this coldkey if applicable TAO stake coldkey_ss58 = metagraph_info.coldkeys[idx] + claim_type_info = {"type": "Swap"} # Default + claim_type = "-" + if tao_stake.tao > 0: - claim_type = root_claim_types.get(coldkey_ss58, "Swap") - else: - claim_type = "-" + claim_type_info = root_claim_types.get(coldkey_ss58, {"type": "Swap"}) + claim_type = format_claim_type_for_subnet(claim_type_info, netuid_) rows.append( ( @@ -1370,7 +1468,12 @@ async def show_subnet( "hotkey": metagraph_info.hotkeys[idx], "coldkey": metagraph_info.coldkeys[idx], "identity": uid_identity, - "claim_type": claim_type, + "claim_type": claim_type_info.get("type") + if tao_stake.tao > 0 + else None, + "claim_type_subnets": claim_type_info.get("subnets") + if claim_type_info.get("type") == "KeepSubnets" + else None, } ) @@ -1620,14 +1723,21 @@ async def create( wallet: Wallet, subtensor: "SubtensorInterface", subnet_identity: dict, + proxy: Optional[str], json_output: bool, prompt: bool, + mev_protection: bool = True, ): """Register a subnetwork""" - + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address # Call register command. success, netuid, ext_id = await register_subnetwork_extrinsic( - subtensor, wallet, subnet_identity, prompt=prompt + subtensor=subtensor, + wallet=wallet, + subnet_identity=subnet_identity, + prompt=prompt, + proxy=proxy, + mev_protection=mev_protection, ) if json_output: # technically, netuid can be `None`, but only if not wait for finalization/inclusion. However, as of present @@ -1646,7 +1756,7 @@ async def create( if do_set_identity: current_identity = await get_id( - subtensor, wallet.coldkeypub.ss58_address, "Current on-chain identity" + subtensor, coldkey_ss58, "Current on-chain identity" ) if prompt: if not Confirm.ask( @@ -1668,16 +1778,16 @@ async def create( ) await set_id( - wallet, - subtensor, - identity["name"], - identity["url"], - identity["image"], - identity["discord"], - identity["description"], - identity["additional"], - identity["github_repo"], - prompt, + wallet=wallet, + subtensor=subtensor, + name=identity["name"], + web_url=identity["url"], + image_url=identity["image"], + discord=identity["discord"], + description=identity["description"], + additional=identity["additional"], + github_repo=identity["github_repo"], + proxy=proxy, ) @@ -1718,9 +1828,10 @@ async def register( era: Optional[int], json_output: bool, prompt: bool, + proxy: Optional[str] = None, ): """Register neuron by recycling some TAO.""" - + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address # Verify subnet exists print_verbose("Checking subnet status") block_hash = await subtensor.substrate.get_chain_head() @@ -1742,7 +1853,7 @@ async def register( subtensor.get_hyperparameter( param_name="Burn", netuid=netuid, block_hash=block_hash ), - subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash=block_hash), + subtensor.get_balance(coldkey_ss58, block_hash=block_hash), ) current_recycle = ( Balance.from_rao(int(current_recycle_)) if current_recycle_ else Balance(0) @@ -1762,8 +1873,11 @@ async def register( # TODO make this a reusable function, also used in subnets list # Show creation table. table = Table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Register to [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]netuid: {netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" - f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + title=( + f"\n[{COLOR_PALETTE.G.HEADER}]" + f"Register to [{COLOR_PALETTE.G.SUBHEAD}]netuid: {netuid}[/{COLOR_PALETTE.G.SUBHEAD}]" + f"\nNetwork: [{COLOR_PALETTE.G.SUBHEAD}]{subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n" + ), show_footer=True, show_edge=False, header_style="bold white", @@ -1805,7 +1919,7 @@ async def register( f"{Balance.get_unit(netuid)}", f"τ {current_recycle.tao:.4f}", f"{get_hotkey_pub_ss58(wallet)}", - f"{wallet.coldkeypub.ss58_address}", + f"{coldkey_ss58}", ) console.print(table) if not ( @@ -1820,7 +1934,9 @@ async def register( return if netuid == 0: - success, msg, ext_id = await root_register_extrinsic(subtensor, wallet=wallet) + success, msg, ext_id = await root_register_extrinsic( + subtensor, wallet=wallet, proxy=proxy + ) else: success, msg, ext_id = await burned_register_extrinsic( subtensor, @@ -1828,6 +1944,7 @@ async def register( netuid=netuid, old_balance=balance, era=era, + proxy=proxy, ) if json_output: json_console.print( @@ -1982,7 +2099,7 @@ async def metagraph_cmd( } if not no_cache: update_metadata_table("metagraph", metadata_info) - create_table( + create_and_populate_table( "metagraph", columns=[ ("UID", "INTEGER"), @@ -2358,6 +2475,7 @@ async def set_identity( netuid: int, subnet_identity: dict, prompt: bool = False, + proxy: Optional[str] = None, ) -> tuple[bool, Optional[str]]: """Set identity information for a subnet""" @@ -2418,7 +2536,7 @@ async def set_identity( spinner="earth", ): success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet + call, wallet, proxy=proxy ) if not success: @@ -2561,10 +2679,11 @@ async def start_subnet( wallet: "Wallet", subtensor: "SubtensorInterface", netuid: int, + proxy: Optional[str], prompt: bool = False, ) -> bool: """Start a subnet's emission schedule""" - + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if not await subtensor.subnet_exists(netuid): print_error(f"Subnet {netuid} does not exist.") return False @@ -2574,7 +2693,8 @@ async def start_subnet( storage_function="SubnetOwner", params=[netuid], ) - if subnet_owner != wallet.coldkeypub.ss58_address: + # TODO should this check against proxy as well? + if subnet_owner != coldkey_ss58: print_error(":cross_mark: This wallet doesn't own the specified subnet.") return False @@ -2595,26 +2715,21 @@ async def start_subnet( call_function="start_call", call_params={"netuid": netuid}, ) - - signed_ext = await subtensor.substrate.create_signed_extrinsic( + success, error_msg, response = await subtensor.sign_and_send_extrinsic( call=start_call, - keypair=wallet.coldkey, - ) - - response = await subtensor.substrate.submit_extrinsic( - extrinsic=signed_ext, + wallet=wallet, wait_for_inclusion=True, wait_for_finalization=True, + proxy=proxy, ) - if await response.is_success: + if success: await print_extrinsic_id(response) console.print( f":white_heavy_check_mark: [green]Successfully started subnet {netuid}'s emission schedule.[/green]" ) return True else: - error_msg = format_error_message(await response.error_message) if "FirstEmissionBlockNumberAlreadySet" in error_msg: console.print( f"[dark_sea_green3]Subnet {netuid} already has an emission schedule.[/dark_sea_green3]" @@ -2631,6 +2746,8 @@ async def set_symbol( subtensor: "SubtensorInterface", netuid: int, symbol: str, + proxy: Optional[str], + period: int, prompt: bool = False, json_output: bool = False, ) -> bool: @@ -2671,16 +2788,11 @@ async def set_symbol( call_params={"netuid": netuid, "symbol": symbol.encode("utf-8")}, ) - signed_ext = await subtensor.substrate.create_signed_extrinsic( - call=start_call, - keypair=wallet.coldkey, + success, err_msg, response = await subtensor.sign_and_send_extrinsic( + call=start_call, wallet=wallet, proxy=proxy, era={"period": period} ) - response = await subtensor.substrate.submit_extrinsic( - extrinsic=signed_ext, - wait_for_inclusion=True, - ) - if await response.is_success: + if success: ext_id = await response.get_extrinsic_identifier() await print_extrinsic_id(response) message = f"Successfully updated SN{netuid}'s symbol to {symbol}." @@ -2696,11 +2808,14 @@ async def set_symbol( console.print(f":white_heavy_check_mark:[dark_sea_green3] {message}\n") return True else: - err = format_error_message(await response.error_message) if json_output: json_console.print_json( - data={"success": False, "message": err, "extrinsic_identifier": None} + data={ + "success": False, + "message": err_msg, + "extrinsic_identifier": None, + } ) else: - err_console.print(f":cross_mark: [red]Failed[/red]: {err}") + err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") return False diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 76cc0addd..9992fe77e 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -176,6 +176,7 @@ async def set_mechanism_count_extrinsic( subtensor: "SubtensorInterface", wallet: "Wallet", netuid: int, + proxy: Optional[str], mech_count: int, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, @@ -204,6 +205,7 @@ async def set_mechanism_count_extrinsic( wallet, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + proxy=proxy, ) if not success: @@ -216,6 +218,7 @@ async def set_mechanism_emission_extrinsic( subtensor: "SubtensorInterface", wallet: "Wallet", netuid: int, + proxy: Optional[str], split: list[int], wait_for_inclusion: bool = True, wait_for_finalization: bool = True, @@ -242,6 +245,7 @@ async def set_mechanism_emission_extrinsic( wallet, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, + proxy=proxy, ) if not success: @@ -254,6 +258,7 @@ async def set_hyperparameter_extrinsic( subtensor: "SubtensorInterface", wallet: "Wallet", netuid: int, + proxy: Optional[str], parameter: str, value: Optional[Union[str, float, list[float]]], wait_for_inclusion: bool = False, @@ -265,6 +270,7 @@ async def set_hyperparameter_extrinsic( :param subtensor: initialized SubtensorInterface object :param wallet: bittensor wallet object. :param netuid: Subnetwork `uid`. + :param proxy: Optional proxy to use for this extrinsic submission. :param parameter: Hyperparameter name. :param value: New hyperparameter value. :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns @@ -280,17 +286,12 @@ async def set_hyperparameter_extrinsic( extrinsic_identifier: optional extrinsic identifier if the extrinsic was included """ print_verbose("Confirming subnet owner") + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address subnet_owner = await subtensor.query( module="SubtensorModule", storage_function="SubnetOwner", params=[netuid], ) - if subnet_owner != wallet.coldkeypub.ss58_address: - err_msg = ( - ":cross_mark: [red]This wallet doesn't own the specified subnet.[/red]" - ) - err_console.print(err_msg) - return False, err_msg, None if not (ulw := unlock_key(wallet)).success: return False, ulw.message, None @@ -374,8 +375,18 @@ async def set_hyperparameter_extrinsic( call_params={"call": call_}, ) else: + if subnet_owner != coldkey_ss58: + err_msg = ":cross_mark: [red]This wallet doesn't own the specified subnet.[/red]" + err_console.print(err_msg) + return False, err_msg, None call = call_ else: + if subnet_owner != coldkey_ss58: + err_msg = ( + ":cross_mark: [red]This wallet doesn't own the specified subnet.[/red]" + ) + err_console.print(err_msg) + return False, err_msg, None call = call_ with console.status( f":satellite: Setting hyperparameter [{COLOR_PALETTE.G.SUBHEAD}]{parameter}[/{COLOR_PALETTE.G.SUBHEAD}]" @@ -384,7 +395,7 @@ async def set_hyperparameter_extrinsic( spinner="earth", ): success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization + call, wallet, wait_for_inclusion, wait_for_finalization, proxy=proxy ) if not success: err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") @@ -565,6 +576,7 @@ async def _is_senate_member(subtensor: "SubtensorInterface", hotkey_ss58: str) - async def vote_senate_extrinsic( subtensor: "SubtensorInterface", wallet: Wallet, + proxy: Optional[str], proposal_hash: str, proposal_idx: int, vote: bool, @@ -576,6 +588,7 @@ async def vote_senate_extrinsic( :param subtensor: The SubtensorInterface object to use for the query :param wallet: Bittensor wallet object, with coldkey and hotkey unlocked. + :param proxy: Optional proxy address to use for the extrinsic submission :param proposal_hash: The hash of the proposal for which voting data is requested. :param proposal_idx: The index of the proposal to vote. :param vote: Whether to vote aye or nay. @@ -606,11 +619,10 @@ async def vote_senate_extrinsic( }, ) success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet, wait_for_inclusion, wait_for_finalization + call, wallet, wait_for_inclusion, wait_for_finalization, proxy=proxy ) if not success: err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - await asyncio.sleep(0.5) return False # Successful vote, final check for data else: @@ -638,6 +650,7 @@ async def set_take_extrinsic( wallet: Wallet, delegate_ss58: str, take: float = 0.0, + proxy: Optional[str] = None, ) -> tuple[bool, Optional[str]]: """ Set delegate hotkey take @@ -646,6 +659,7 @@ async def set_take_extrinsic( :param wallet: The wallet containing the hotkey to be nominated. :param delegate_ss58: Hotkey :param take: Delegate take on subnet ID + :param proxy: Optional proxy address to use for the extrinsic submission :return: `True` if the process is successful, `False` otherwise. @@ -682,7 +696,7 @@ async def set_take_extrinsic( }, ) success, err, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet + call, wallet, proxy=proxy ) else: @@ -702,7 +716,7 @@ async def set_take_extrinsic( }, ) success, err, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet + call, wallet, proxy=proxy ) if not success: @@ -724,6 +738,7 @@ async def sudo_set_hyperparameter( wallet: Wallet, subtensor: "SubtensorInterface", netuid: int, + proxy: Optional[str], param_name: str, param_value: Optional[str], prompt: bool, @@ -741,7 +756,7 @@ async def sudo_set_hyperparameter( if json_output: prompt = False success, err_msg, ext_id = await set_hyperparameter_extrinsic( - subtensor, wallet, netuid, param_name, value, prompt=prompt + subtensor, wallet, netuid, proxy, param_name, value, prompt=prompt ) if json_output: return success, err_msg, ext_id @@ -955,6 +970,7 @@ async def proposals( async def senate_vote( wallet: Wallet, subtensor: "SubtensorInterface", + proxy: Optional[str], proposal_hash: str, vote: bool, prompt: bool, @@ -991,6 +1007,7 @@ async def senate_vote( success = await vote_senate_extrinsic( subtensor=subtensor, wallet=wallet, + proxy=proxy, proposal_hash=proposal_hash, proposal_idx=vote_data.index, vote=vote, @@ -1015,7 +1032,7 @@ async def display_current_take(subtensor: "SubtensorInterface", wallet: Wallet) async def set_take( - wallet: Wallet, subtensor: "SubtensorInterface", take: float + wallet: Wallet, subtensor: "SubtensorInterface", take: float, proxy: Optional[str] ) -> tuple[bool, Optional[str]]: """Set delegate take.""" @@ -1042,6 +1059,7 @@ async def _do_set_take() -> tuple[bool, Optional[str]]: wallet=wallet, delegate_ss58=hotkey_ss58, take=take, + proxy=proxy, ) success, ext_id = result @@ -1069,6 +1087,7 @@ async def trim( wallet: Wallet, subtensor: "SubtensorInterface", netuid: int, + proxy: Optional[str], max_n: int, period: int, prompt: bool, @@ -1083,6 +1102,7 @@ async def trim( storage_function="SubnetOwner", params=[netuid], ) + # TODO should this check proxy also? if subnet_owner != wallet.coldkeypub.ss58_address: err_msg = "This wallet doesn't own the specified subnet." if json_output: @@ -1102,7 +1122,7 @@ async def trim( call_params={"netuid": netuid, "max_n": max_n}, ) success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call=call, wallet=wallet, era={"period": period} + call=call, wallet=wallet, era={"period": period}, proxy=proxy ) if not success: if json_output: diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index a0cb24ceb..8813e6839 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -84,6 +84,7 @@ async def associate_hotkey( hotkey_ss58: str, hotkey_display: str, prompt: bool = False, + proxy: Optional[str] = None, ): """Associates a hotkey with a wallet""" @@ -129,6 +130,7 @@ async def associate_hotkey( wallet, wait_for_inclusion=True, wait_for_finalization=False, + proxy=proxy, ) if not success: @@ -1533,6 +1535,8 @@ async def transfer( era: int, prompt: bool, json_output: bool, + proxy: Optional[str] = None, + announce_only: bool = False, ): """Transfer token of amount to destination.""" result, ext_receipt = await transfer_extrinsic( @@ -1544,6 +1548,8 @@ async def transfer( allow_death=allow_death, era=era, prompt=prompt, + proxy=proxy, + announce_only=announce_only, ) ext_id = (await ext_receipt.get_extrinsic_identifier()) if result else None if json_output: @@ -1737,6 +1743,7 @@ async def swap_hotkey( new_wallet: Wallet, subtensor: SubtensorInterface, netuid: Optional[int], + proxy: Optional[str], prompt: bool, json_output: bool, ): @@ -1747,6 +1754,7 @@ async def swap_hotkey( new_wallet, netuid=netuid, prompt=prompt, + proxy=proxy, ) if result: ext_id = await ext_receipt.get_extrinsic_identifier() @@ -1796,8 +1804,8 @@ async def set_id( description: str, additional: str, github_repo: str, - prompt: bool, json_output: bool = False, + proxy: Optional[str] = None, ) -> bool: """Create a new or update existing identity on-chain.""" output_dict = {"success": False, "identity": None, "error": ""} @@ -1824,7 +1832,7 @@ async def set_id( " :satellite: [dark_sea_green3]Updating identity on-chain...", spinner="earth" ): success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( - call, wallet + call, wallet, proxy=proxy ) if not success: @@ -2040,6 +2048,7 @@ async def schedule_coldkey_swap( subtensor: SubtensorInterface, new_coldkey_ss58: str, force_swap: bool = False, + proxy: Optional[str] = None, ) -> bool: """Schedules a coldkey swap operation to be executed at a future block. @@ -2097,6 +2106,7 @@ async def schedule_coldkey_swap( wallet, wait_for_inclusion=True, wait_for_finalization=True, + proxy=proxy, ) block_post_call = await subtensor.substrate.get_block_number() diff --git a/bittensor_cli/src/commands/weights.py b/bittensor_cli/src/commands/weights.py index 4bccb28a7..068edf6ca 100644 --- a/bittensor_cli/src/commands/weights.py +++ b/bittensor_cli/src/commands/weights.py @@ -36,6 +36,7 @@ def __init__( subtensor: "SubtensorInterface", wallet: Wallet, netuid: int, + proxy: Optional[str], uids: NDArray, weights: NDArray, salt: list[int], @@ -47,6 +48,7 @@ def __init__( self.subtensor = subtensor self.wallet = wallet self.netuid = netuid + self.proxy = proxy self.uids = uids self.weights = weights self.salt = salt @@ -222,19 +224,12 @@ async def reveal(self, weight_uids, weight_vals) -> tuple[bool, str, Optional[st console.print( ":white_heavy_check_mark: [green]Weights hash revealed on chain[/green]" ) - # bittensor.logging.success(prefix="Weights hash revealed", suffix=str(msg)) - return ( True, "Successfully revealed previously committed weights hash.", ext_id, ) else: - # bittensor.logging.error( - # msg=msg, - # prefix=f"Failed to reveal previously committed weights hash for salt: {salt}", - # suffix="Failed: ", - # ) return False, "Failed to reveal weights.", None async def _set_weights_without_commit_reveal( @@ -254,29 +249,25 @@ async def _do_set_weights() -> tuple[bool, str, Optional[str]]: }, ) # Period dictates how long the extrinsic will stay as part of waiting pool - extrinsic = await self.subtensor.substrate.create_signed_extrinsic( + success, err_msg, response = await self.subtensor.sign_and_send_extrinsic( call=call, - keypair=self.wallet.hotkey, + sign_with="hotkey", + wallet=self.wallet, era={"period": 5}, + wait_for_finalization=True, + wait_for_inclusion=True, + proxy=self.proxy, ) - try: - response = await self.subtensor.substrate.submit_extrinsic( - extrinsic, - wait_for_inclusion=self.wait_for_inclusion, - wait_for_finalization=self.wait_for_finalization, - ) - except SubstrateRequestException as e: - return False, format_error_message(e), None # We only wait here if we expect finalization. if not self.wait_for_finalization and not self.wait_for_inclusion: return True, "Not waiting for finalization or inclusion.", None - if await response.is_success: + if success: ext_id_ = await response.get_extrinsic_identifier() await print_extrinsic_id(response) return True, "Successfully set weights.", ext_id_ else: - return False, format_error_message(await response.error_message), None + return False, err_msg, None with console.status( f":satellite: Setting weights on [white]{self.subtensor.network}[/white] ..." @@ -311,43 +302,27 @@ async def reveal_weights_extrinsic( "version_key": self.version_key, }, ) - extrinsic = await self.subtensor.substrate.create_signed_extrinsic( + success, error_message, response = await self.subtensor.sign_and_send_extrinsic( call=call, - keypair=self.wallet.hotkey, + wallet=self.wallet, + sign_with="hotkey", + wait_for_inclusion=self.wait_for_inclusion, + wait_for_finalization=self.wait_for_finalization, + proxy=self.proxy, ) - try: - response = await self.subtensor.substrate.submit_extrinsic( - extrinsic, - wait_for_inclusion=self.wait_for_inclusion, - wait_for_finalization=self.wait_for_finalization, - ) - except SubstrateRequestException as e: - return False, format_error_message(e), None if not self.wait_for_finalization and not self.wait_for_inclusion: - success, error_message, ext_id = True, "", None - - else: - if await response.is_success: - success, error_message, ext_id = ( - True, - "", - await response.get_extrinsic_identifier(), - ) - await print_extrinsic_id(response) - else: - success, error_message, ext_id = ( - False, - format_error_message(await response.error_message), - None, - ) + return True, "", None if success: - # bittensor.logging.info("Successfully revealed weights.") - return True, "Successfully revealed weights.", ext_id + await print_extrinsic_id(response) + return ( + True, + "Successfully revealed weights.", + await response.get_extrinsic_identifier(), + ) else: - # bittensor.logging.error(f"Failed to reveal weights: {error_message}") - return False, error_message, ext_id + return False, error_message, None async def do_commit_weights( self, commit_hash @@ -360,25 +335,24 @@ async def do_commit_weights( "commit_hash": commit_hash, }, ) - extrinsic = await self.subtensor.substrate.create_signed_extrinsic( + success, err_msg, response = await self.subtensor.sign_and_send_extrinsic( call=call, - keypair=self.wallet.hotkey, - ) - response = await self.subtensor.substrate.submit_extrinsic( - extrinsic, + wallet=self.wallet, + sign_with="hotkey", wait_for_inclusion=self.wait_for_inclusion, wait_for_finalization=self.wait_for_finalization, + proxy=self.proxy, ) if not self.wait_for_finalization and not self.wait_for_inclusion: return True, None, None - if await response.is_success: + if success: ext_id = await response.get_extrinsic_identifier() await print_extrinsic_id(response) return True, None, ext_id else: - return False, await response.error_message, None + return False, err_msg, None # commands @@ -388,6 +362,7 @@ async def reveal_weights( subtensor: "SubtensorInterface", wallet: Wallet, netuid: int, + proxy: Optional[str], uids: list[int], weights: list[float], salt: list[int], @@ -413,7 +388,15 @@ async def reveal_weights( ) # Call the reveal function in the module set_weights from extrinsics package extrinsic = SetWeightsExtrinsic( - subtensor, wallet, netuid, uids_, weights_, list(salt_), version, prompt=prompt + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + uids=uids_, + weights=weights_, + salt=list(salt_), + version_key=version, + prompt=prompt, + proxy=proxy, ) success, message, ext_id = await extrinsic.reveal(weight_uids, weight_vals) if json_output: @@ -434,6 +417,7 @@ async def commit_weights( wallet: Wallet, netuid: int, uids: list[int], + proxy: Optional[str], weights: list[float], salt: list[int], version: int, @@ -454,7 +438,15 @@ async def commit_weights( dtype=np.int64, ) extrinsic = SetWeightsExtrinsic( - subtensor, wallet, netuid, uids_, weights_, list(salt_), version, prompt=prompt + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + uids=uids_, + weights=weights_, + salt=list(salt_), + version_key=version, + prompt=prompt, + proxy=proxy, ) success, message, ext_id = await extrinsic.set_weights_extrinsic() if json_output: diff --git a/pyproject.toml b/pyproject.toml index 9eefb8d4d..e79d2cdd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.15.3" +version = "9.16.0" description = "Bittensor CLI" readme = "README.md" authors = [ @@ -30,9 +30,10 @@ classifiers = [ ] dependencies = [ "wheel", - "async-substrate-interface>=1.5.2", + "async-substrate-interface>=1.5.14", "aiohttp~=3.13", "backoff~=2.2.1", + "bittensor-drand>=1.2.0", "GitPython>=3.0.0", "netaddr~=1.3.0", "numpy>=2.0.1,<3.0.0", diff --git a/tests/e2e_tests/test_hyperparams_setting.py b/tests/e2e_tests/test_hyperparams_setting.py index c336f6615..c21008bb5 100644 --- a/tests/e2e_tests/test_hyperparams_setting.py +++ b/tests/e2e_tests/test_hyperparams_setting.py @@ -1,6 +1,6 @@ import asyncio import json - +import pytest from bittensor_cli.src import HYPERPARAMS, RootSudoOnly from .utils import turn_off_hyperparam_freeze_window @@ -13,6 +13,7 @@ """ +@pytest.mark.parametrize("local_chain", [False], indirect=True) def test_hyperparams_setting(local_chain, wallet_setup): netuid = 2 wallet_path_alice = "//Alice" diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py index 7a210f0a1..e97e1b6b4 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -1,9 +1,7 @@ import asyncio import json -import re import time -from bittensor_cli.src.bittensor.balances import Balance from .utils import turn_off_hyperparam_freeze_window """ @@ -30,7 +28,6 @@ def test_liquidity(local_chain, wallet_setup): print( "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." ) - time.sleep(10) # Register a subnet with sudo as Alice result = exec_command_alice( @@ -63,6 +60,7 @@ def test_liquidity(local_chain, wallet_setup): "https://testsubnet.com/logo.png", "--no-prompt", "--json-output", + "--no-mev-protection", ], ) result_output = json.loads(result.stdout) @@ -92,6 +90,7 @@ def test_liquidity(local_chain, wallet_setup): assert result_output["success"] is False assert f"Subnet with netuid: {netuid} is not active" in result_output["err_msg"] assert result_output["positions"] == [] + time.sleep(40) # start emissions schedule start_subnet_emissions = exec_command_alice( @@ -137,6 +136,7 @@ def test_liquidity(local_chain, wallet_setup): "--no-prompt", "--era", "144", + "--no-mev-protection", ], ) assert "✅ Finalized" in stake_to_enable_v3.stdout, stake_to_enable_v3.stderr diff --git a/tests/e2e_tests/test_proxy.py b/tests/e2e_tests/test_proxy.py new file mode 100644 index 000000000..e5e76724e --- /dev/null +++ b/tests/e2e_tests/test_proxy.py @@ -0,0 +1,686 @@ +import json +import os +import time + +from bittensor_cli.src.bittensor.utils import ProxyAnnouncements + +""" +Verify commands: + +* btcli proxy create +* btcli proxy add +* btcli proxy remove +* btcli proxy kill +* btcli proxy execute +""" + + +def test_proxy_create(local_chain, wallet_setup): + """ + Tests the pure proxy logic (create/kill) + + Steps: + 1. Creates pure proxy (with delay) + 2. Fund pure proxy + 3. Verifies pure proxy balance + 4. Ensures unannounced call fails (bc of delay at creation) + 4. Makes announcement of pure proxy's intent to transfer to Bob + 5. Executes previous announcement of transfer to Bob + 6. Ensures Bob has received the funds + 7. Makes announcement of pure proxy's intent to kill + 8. Kills pure proxy + + """ + testing_db_loc = "/tmp/btcli-test.db" + os.environ["BTCLI_PROXIES_PATH"] = testing_db_loc + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + # Create wallets for Alice and Bob + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( + wallet_path_bob + ) + proxy_type = "Any" + delay = 1 + + try: + # create a pure proxy + create_result = exec_command_alice( + command="proxy", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy-type", + proxy_type, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + create_result_output = json.loads(create_result.stdout) + assert create_result_output["success"] is True + assert create_result_output["message"] is not None + assert create_result_output["extrinsic_identifier"] is not None + created_extrinsic_id = create_result_output["extrinsic_identifier"].split("-") + created_block = int(created_extrinsic_id[0]) + created_extrinsic_idx = int(created_extrinsic_id[1]) + created_pure = create_result_output["data"]["pure"] + spawner = create_result_output["data"]["spawner"] + created_proxy_type = create_result_output["data"]["proxy_type"] + created_delay = create_result_output["data"]["delay"] + assert isinstance(created_pure, str) + assert isinstance(spawner, str) + assert spawner == wallet_alice.coldkeypub.ss58_address + assert created_proxy_type == proxy_type + assert created_delay == delay + print("Passed pure creation.") + + # transfer some funds from alice to the pure proxy + amount_to_transfer = 1_000 + transfer_result = exec_command_alice( + command="wallet", + sub_command="transfer", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--dest", + created_pure, + "--amount", + str(amount_to_transfer), + "--no-prompt", + "--json-output", + ], + ) + transfer_result_output = json.loads(transfer_result.stdout) + assert transfer_result_output["success"] is True + + # ensure the proxy has the transferred funds + balance_result = exec_command_alice( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--ss58", + created_pure, + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["Provided Address 1"]["coldkey"] + == created_pure + ) + assert balance_result_output["balances"]["Provided Address 1"]["free"] == float( + amount_to_transfer + ) + + # transfer some of the pure proxy's funds to bob, but don't announce it + amount_to_transfer_proxy = 100 + transfer_result_proxy = exec_command_alice( + command="wallet", + sub_command="transfer", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + created_pure, + "--dest", + keypair_bob.ss58_address, + "--amount", + str(amount_to_transfer_proxy), + "--no-prompt", + "--json-output", + ], + ) + transfer_result_proxy_output = json.loads(transfer_result_proxy.stdout) + # should fail, because it wasn't announced + assert transfer_result_proxy_output["success"] is False + + # announce the same extrinsic + transfer_result_proxy = exec_command_alice( + command="wallet", + sub_command="transfer", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + created_pure, + "--dest", + keypair_bob.ss58_address, + "--amount", + str(amount_to_transfer_proxy), + "--no-prompt", + "--json-output", + "--announce-only", + ], + ) + print(transfer_result_proxy.stdout, transfer_result_proxy.stderr) + transfer_result_proxy_output = json.loads(transfer_result_proxy.stdout) + assert transfer_result_proxy_output["success"] is True + with ProxyAnnouncements.get_db() as (conn, cursor): + rows = ProxyAnnouncements.read_rows(conn, cursor, include_header=False) + latest_announcement = next( + iter(sorted(rows, key=lambda row: row[2], reverse=True)) + ) # sort by epoch time + ( + idx, + address, + epoch_time, + block, + call_hash, + call, + call_serialized, + executed_int, + ) = latest_announcement + assert address == created_pure + assert executed_int == 0 + + # wait for delay (probably already happened if fastblocks is on) + time.sleep(3) + + # get Bob's initial balance + balance_result = exec_command_bob( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["default"]["coldkey"] + == wallet_bob.coldkeypub.ss58_address + ) + bob_init_balance = balance_result_output["balances"]["default"]["free"] + + announce_execution_result = exec_command_alice( + command="proxy", + sub_command="execute", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + created_pure, + "--call-hash", + call_hash, + "--no-prompt", + "--json-output", + ], + ) + announce_execution_result_output = json.loads(announce_execution_result.stdout) + assert announce_execution_result_output["success"] is True + assert announce_execution_result_output["message"] == "" + + # ensure bob has the transferred funds + balance_result = exec_command_bob( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["default"]["coldkey"] + == wallet_bob.coldkeypub.ss58_address + ) + assert ( + balance_result_output["balances"]["default"]["free"] + == float(amount_to_transfer_proxy) + bob_init_balance + ) + print("Passed transfer with announcement") + + # announce kill of the created pure proxy + announce_kill_result = exec_command_alice( + command="proxy", + sub_command="kill", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--height", + str(created_block), + "--ext-index", + str(created_extrinsic_idx), + "--spawner", + spawner, + "--proxy-type", + created_proxy_type, + "--proxy", + created_pure, + "--json-output", + "--no-prompt", + "--announce-only", + ], + ) + print(announce_kill_result.stdout, announce_kill_result.stderr) + kill_result_output = json.loads(announce_kill_result.stdout) + assert kill_result_output["success"] is True + assert kill_result_output["message"] == "" + assert isinstance(kill_result_output["extrinsic_identifier"], str) + print("Passed kill announcement") + + with ProxyAnnouncements.get_db() as (conn, cursor): + rows = ProxyAnnouncements.read_rows(conn, cursor, include_header=False) + latest_announcement = next( + iter(sorted(rows, key=lambda row: row[2], reverse=True)) + ) # sort by epoch time + ( + idx, + address, + epoch_time, + block, + call_hash, + call, + call_serialized, + executed_int, + ) = latest_announcement + assert address == created_pure + assert executed_int == 0 + # wait for delay (probably already happened if fastblocks is on) + time.sleep(3) + + kill_announce_execution_result = exec_command_alice( + command="proxy", + sub_command="execute", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + created_pure, + "--call-hash", + call_hash, + "--no-prompt", + "--json-output", + ], + ) + kill_announce_execution_result_output = json.loads( + kill_announce_execution_result.stdout + ) + assert kill_announce_execution_result_output["success"] is True + assert kill_announce_execution_result_output["message"] == "" + finally: + os.environ["BTCLI_PROXIES_PATH"] = "" + if os.path.exists(testing_db_loc): + os.remove(testing_db_loc) + + +def test_add_proxy(local_chain, wallet_setup): + """ + Tests the non-pure (delegated) proxy logic (add/remove) + + Steps: + 1. Add Dave as a proxy of Alice (with delay) + 2. Attempt proxy transfer without announcement (it should fail) + 3. Make proxy transfer to Bob + 4. Ensure Bob got the funds, the funds were deducted from Alice, and that Dave paid the ext fee + 5. Remove Dave as a proxy of Alice + """ + testing_db_loc = "/tmp/btcli-test.db" + os.environ["BTCLI_PROXIES_PATH"] = testing_db_loc + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + wallet_path_dave = "//Dave" + + # Create wallets for Alice and Bob + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( + wallet_path_bob + ) + keypair_dave, wallet_dave, wallet_path_dave, exec_command_dave = wallet_setup( + wallet_path_dave + ) + proxy_type = "Any" + delay = 1 + + try: + # add Dave as a proxy of Alice + add_result = exec_command_alice( + command="proxy", + sub_command="add", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--delegate", + wallet_dave.coldkeypub.ss58_address, + "--proxy-type", + proxy_type, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + add_result_output = json.loads(add_result.stdout) + assert add_result_output["success"] is True + assert "Added proxy delegatee" in add_result_output["message"] + assert ( + add_result_output["data"]["delegatee"] + == wallet_dave.coldkeypub.ss58_address + ) + assert ( + add_result_output["data"]["delegator"] + == wallet_alice.coldkeypub.ss58_address + ) + assert add_result_output["data"]["proxy_type"] == proxy_type + assert add_result_output["data"]["delay"] == delay + print("Proxy Add successful") + + # Check dave's init balance + dave_balance_result = exec_command_dave( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_dave, + "--wallet-name", + "default", + "--json-output", + "--chain", + "ws://127.0.0.1:9945", + ], + ) + dave_balance_output = json.loads(dave_balance_result.stdout) + assert ( + dave_balance_output["balances"]["default"]["coldkey"] + == wallet_dave.coldkeypub.ss58_address + ) + dave_init_balance = dave_balance_output["balances"]["default"]["free"] + + # Check Bob's init balance + balance_result = exec_command_bob( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["default"]["coldkey"] + == wallet_bob.coldkeypub.ss58_address + ) + bob_init_balance = balance_result_output["balances"]["default"]["free"] + + # check alice's init balance + balance_result = exec_command_alice( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["default"]["coldkey"] + == wallet_alice.coldkeypub.ss58_address + ) + alice_init_balance = balance_result_output["balances"]["default"]["free"] + + # transfer some of alice's funds to bob through the proxy, but don't announce it + amount_to_transfer_proxy = 100 + transfer_result_proxy = exec_command_dave( + command="wallet", + sub_command="transfer", + extra_args=[ + "--wallet-path", + wallet_path_dave, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + wallet_alice.coldkeypub.ss58_address, + "--dest", + keypair_bob.ss58_address, + "--amount", + str(amount_to_transfer_proxy), + "--no-prompt", + "--json-output", + ], + ) + transfer_result_proxy_output = json.loads(transfer_result_proxy.stdout) + # should fail, because it wasn't announced + assert transfer_result_proxy_output["success"] is False + + # announce the same extrinsic + transfer_result_proxy = exec_command_dave( + command="wallet", + sub_command="transfer", + extra_args=[ + "--wallet-path", + wallet_path_dave, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + wallet_alice.coldkeypub.ss58_address, + "--dest", + keypair_bob.ss58_address, + "--amount", + str(amount_to_transfer_proxy), + "--no-prompt", + "--json-output", + "--announce-only", + ], + ) + print(transfer_result_proxy.stdout, transfer_result_proxy.stderr) + transfer_result_proxy_output = json.loads(transfer_result_proxy.stdout) + assert transfer_result_proxy_output["success"] is True + with ProxyAnnouncements.get_db() as (conn, cursor): + rows = ProxyAnnouncements.read_rows(conn, cursor, include_header=False) + latest_announcement = next( + iter(sorted(rows, key=lambda row: row[2], reverse=True)) + ) # sort by epoch time + ( + idx, + address, + epoch_time, + block, + call_hash, + call, + call_serialized, + executed_int, + ) = latest_announcement + assert address == wallet_alice.coldkeypub.ss58_address + assert executed_int == 0 + + # wait for delay (probably already happened if fastblocks is on) + time.sleep(3) + + announce_execution_result = exec_command_dave( + command="proxy", + sub_command="execute", + extra_args=[ + "--wallet-path", + wallet_path_dave, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + wallet_alice.coldkeypub.ss58_address, + "--call-hash", + call_hash, + "--no-prompt", + "--json-output", + ], + ) + announce_execution_result_output = json.loads(announce_execution_result.stdout) + assert announce_execution_result_output["success"] is True + assert announce_execution_result_output["message"] == "" + + # ensure bob has the transferred funds + balance_result = exec_command_bob( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["default"]["coldkey"] + == wallet_bob.coldkeypub.ss58_address + ) + assert ( + balance_result_output["balances"]["default"]["free"] + == float(amount_to_transfer_proxy) + bob_init_balance + ) + + # ensure the amount was subtracted from alice's balance, not dave's + balance_result = exec_command_alice( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["default"]["coldkey"] + == wallet_alice.coldkeypub.ss58_address + ) + assert balance_result_output["balances"]["default"][ + "free" + ] == alice_init_balance - float(amount_to_transfer_proxy) + + # ensure dave paid the extrinsic fee + balance_result = exec_command_dave( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_dave, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + balance_result_output = json.loads(balance_result.stdout) + assert ( + balance_result_output["balances"]["default"]["coldkey"] + == wallet_dave.coldkeypub.ss58_address + ) + assert balance_result_output["balances"]["default"]["free"] < dave_init_balance + + print("Passed transfer with announcement") + + # remove the proxy + remove_result = exec_command_alice( + command="proxy", + sub_command="remove", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--delegate", + wallet_dave.coldkeypub.ss58_address, + "--proxy-type", + proxy_type, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + remove_result_output = json.loads(remove_result.stdout) + assert remove_result_output["success"] is True + assert remove_result_output["message"] == "" + assert isinstance(remove_result_output["extrinsic_identifier"], str) + print("Passed proxy removal") + finally: + os.environ["BTCLI_PROXIES_PATH"] = "" + if os.path.exists(testing_db_loc): + os.remove(testing_db_loc) diff --git a/tests/e2e_tests/test_set_identity.py b/tests/e2e_tests/test_set_identity.py index 9c008cdcd..345927785 100644 --- a/tests/e2e_tests/test_set_identity.py +++ b/tests/e2e_tests/test_set_identity.py @@ -23,6 +23,7 @@ def test_set_id(local_chain, wallet_setup): keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( wallet_path_alice ) + # Register a subnet with sudo as Alice result = exec_command_alice( command="subnets", @@ -54,6 +55,7 @@ def test_set_id(local_chain, wallet_setup): "https://testsubnet.com/logo.png", "--no-prompt", "--json-output", + "--no-mev-protection", ], ) result_output = json.loads(result.stdout) diff --git a/tests/e2e_tests/test_stake_movement.py b/tests/e2e_tests/test_stake_movement.py new file mode 100644 index 000000000..eb9a22b0e --- /dev/null +++ b/tests/e2e_tests/test_stake_movement.py @@ -0,0 +1,511 @@ +import asyncio +import json +import pytest + +from .utils import find_stake_entries, set_storage_extrinsic + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_stake_movement(local_chain, wallet_setup): + """ + Exercise stake move, transfer, and swap flows across subnets using Alice and Bob. + + Steps: + 0. Initial setup: Make alice own SN 0, create SN2, SN3, SN4, start emissions on all subnets. + 1. Activation: Register Bob on subnets 2 and 3; add initial stake for V3 activation. + 2. Move: Move stake from Alice's hotkey on netuid 2 to Bob's hotkey on netuid 3. + 3. Transfer: Transfer all root (netuid 0) stake from Alice's coldkey to Bob's coldkey. + 4. Swap: Swap Alice's stake from netuid 4 to the root netuid. + + Note: + - All movement commands executed with mev shield + - Stake commands executed without shield to speed up tests + - Shield for stake commands is already covered in its own test + """ + print("Testing stake movement commands 🧪") + + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( + wallet_path_bob + ) + + # Force Alice to own SN0 by setting storage + sn0_owner_storage_items = [ + ( + bytes.fromhex( + "658faa385070e074c85bf6b568cf055536e3e82152c8758267395fe524fbbd160000" + ), + bytes.fromhex( + "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" + ), + ) + ] + asyncio.run( + set_storage_extrinsic( + local_chain, + wallet=wallet_alice, + items=sn0_owner_storage_items, + ) + ) + + # Create SN2, SN3, SN4 for move/transfer/swap checks + subnets_to_create = [2, 3, 4] + for netuid in subnets_to_create: + create_subnet_result = exec_command_alice( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--subnet-name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", + "--no-prompt", + "--json-output", + ], + ) + create_subnet_payload = json.loads(create_subnet_result.stdout) + assert create_subnet_payload["success"] is True + assert create_subnet_payload["netuid"] == netuid + + # Start emission schedule for subnets (including root netuid 0) + for netuid in [0] + subnets_to_create: + start_emission_result = exec_command_alice( + command="subnets", + sub_command="start", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-name", + wallet_alice.name, + "--no-prompt", + "--chain", + "ws://127.0.0.1:9945", + "--wallet-path", + wallet_path_alice, + ], + ) + assert ( + f"Successfully started subnet {netuid}'s emission schedule." + in start_emission_result.stdout + ) + + # Alice is already registered - register Bob on the two non-root subnets + for netuid in [2, 3]: + register_bob_result = exec_command_bob( + command="subnets", + sub_command="register", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + wallet_path_bob, + "--wallet-name", + wallet_bob.name, + "--hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Registered" in register_bob_result.stdout, register_bob_result.stderr + assert "Your extrinsic has been included" in register_bob_result.stdout, ( + register_bob_result.stdout + ) + + # Add initial stake to enable V3 (1 TAO) on all created subnets + for netuid in [2, 3, 4]: + add_initial_stake_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "1", + "--unsafe", + "--no-prompt", + "--era", + "144", + "--no-mev-protection", + ], + ) + assert "✅ Finalized" in add_initial_stake_result.stdout, ( + add_initial_stake_result.stderr + ) + + ############################ + # TEST 1: Move stake command + # Move stake between hotkeys while keeping the same coldkey + ############################ + + # Add 25 TAO stake for move test for Alice + add_move_stake_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + "2", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "25", + "--no-prompt", + "--era", + "144", + "--unsafe", + "--no-mev-protection", + ], + ) + assert "✅ Finalized" in add_move_stake_result.stdout, add_move_stake_result.stderr + + # List Alice's stakes prior to the move + alice_stake_before_move = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + + # Check Alice's stakes before move to ensure sufficient stake on netuid 2 + alice_stake_list_before_move = json.loads(alice_stake_before_move.stdout) + alice_stakes_before_move = find_stake_entries( + alice_stake_list_before_move, + netuid=2, + hotkey_ss58=wallet_alice.hotkey.ss58_address, + ) + for stake in alice_stakes_before_move: + assert stake["stake_value"] >= int(20) + + # Move stake from Alice's hotkey on netuid 2 -> Bob's hotkey on netuid 3 + move_amount = 20 + move_result = exec_command_alice( + command="stake", + sub_command="move", + extra_args=[ + "--origin-netuid", + "2", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--dest-netuid", + "3", + "--dest", + wallet_bob.hotkey.ss58_address, + "--amount", + move_amount, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Sent" in move_result.stdout + + # Check Alice's stakes after move + alice_stake_after_move = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + # Assert stake was moved from Alice's hotkey on netuid 2 -> Bob's hotkey on netuid 3 + alice_stake_list_after_move = json.loads(alice_stake_after_move.stdout) + bob_stakes_after_move = find_stake_entries( + alice_stake_list_after_move, + netuid=3, + hotkey_ss58=wallet_bob.hotkey.ss58_address, + ) + for stake in bob_stakes_after_move: + assert stake["stake_value"] >= move_amount + + ################################ + # TEST 2: Transfer stake command + # Transfer stake between coldkeys while keeping the same hotkey + ################################ + + transfer_amount = 20 + transfer_fund_root_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + "0", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + transfer_amount, + "--no-prompt", + "--era", + "144", + "--unsafe", + "--no-mev-protection", + ], + ) + assert "✅ Finalized" in transfer_fund_root_result.stdout, ( + transfer_fund_root_result.stderr + ) + + # Ensure Bob doesn't have any stake in root netuid before transfer + bob_stake_list_before_transfer = exec_command_bob( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--wallet-name", + wallet_bob.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + assert bob_stake_list_before_transfer.stdout == "" + + # Transfer stake from Alice's coldkey on netuid 0 -> Bob's coldkey on netuid 0 + transfer_result = exec_command_alice( + command="stake", + sub_command="transfer", + extra_args=[ + "--origin-netuid", + "0", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--dest-netuid", + "0", + "--dest", + wallet_bob.coldkeypub.ss58_address, + "--all", + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Sent" in transfer_result.stdout + + # Check Bob's stakes after transfer + bob_stake_list_after_transfer = exec_command_bob( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--wallet-name", + wallet_bob.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + bob_stake_list_after_transfer = json.loads(bob_stake_list_after_transfer.stdout) + bob_stakes_after_transfer = find_stake_entries( + bob_stake_list_after_transfer, + netuid=0, + ) + for stake in bob_stakes_after_transfer: + assert stake["stake_value"] >= transfer_amount + + # Check Alice's stakes after transfer + alice_stake_list_after_transfer = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + + alice_stake_list_after_transfer = json.loads(alice_stake_list_after_transfer.stdout) + alice_stakes_after_transfer = find_stake_entries( + alice_stake_list_after_transfer, + netuid=0, + ) + if alice_stakes_after_transfer: + pytest.fail("Stake found in root netuid after transfer") + + ################################ + # TEST 3: Swap stake command + # Swap stake between subnets while keeping the same coldkey-hotkey pair + ################################ + + swap_seed_stake_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + "4", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "25", + "--no-prompt", + "--era", + "144", + "--unsafe", + "--no-mev-protection", + ], + ) + assert "✅ Finalized" in swap_seed_stake_result.stdout, ( + swap_seed_stake_result.stderr + ) + + # Ensure stake was added to Alice's hotkey on netuid 4 + alice_stake_list_before_swap_cmd = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + + alice_stake_list_before_swap = json.loads(alice_stake_list_before_swap_cmd.stdout) + alice_stakes_before_swap = find_stake_entries( + alice_stake_list_before_swap, + netuid=4, + ) + if not alice_stakes_before_swap: + pytest.fail("Stake not found in netuid 4 before swap") + + # Swap stake from Alice's hotkey on netuid 4 -> Bob's hotkey on netuid 0 + swap_result = exec_command_alice( + command="stake", + sub_command="swap", + extra_args=[ + "--origin-netuid", + "4", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--dest-netuid", + "0", + "--all", + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Sent" in swap_result.stdout, swap_result.stderr + + # Check Alice's stakes after swap + alice_stake_list_after_swap_cmd = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + + alice_stake_list_after_swap = json.loads(alice_stake_list_after_swap_cmd.stdout) + alice_stakes_after_swap = find_stake_entries( + alice_stake_list_after_swap, + netuid=4, + ) + if alice_stakes_after_swap: + pytest.fail("Stake found in netuid 4 after swap") + + print("Passed stake movement commands") diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 6bcaa60cc..4f5346207 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -1,6 +1,7 @@ import asyncio import json import re +import pytest from typing import Union from bittensor_cli.src.bittensor.balances import Balance @@ -24,6 +25,7 @@ """ +@pytest.mark.parametrize("local_chain", [False], indirect=True) def test_staking(local_chain, wallet_setup): """ Test staking & sudo commands and inspect their output @@ -402,7 +404,7 @@ def test_staking(local_chain, wallet_setup): for line in show_stake_adding_single.stdout.splitlines() ] stake_added = cleaned_stake[8].split("│")[3].strip().split()[0] - assert Balance.from_tao(float(stake_added)) >= Balance.from_tao(90) + assert Balance.from_tao(float(stake_added)) >= Balance.from_tao(87) show_stake_json = exec_command_alice( command="stake", @@ -419,9 +421,9 @@ def test_staking(local_chain, wallet_setup): ) show_stake_json_output = json.loads(show_stake_json.stdout) alice_stake = show_stake_json_output["stake_info"][keypair_alice.ss58_address][0] - assert Balance.from_tao(alice_stake["stake_value"]) > Balance.from_tao(90.0) + assert Balance.from_tao(alice_stake["stake_value"]) >= Balance.from_tao(87.0) - # Execute remove_stake command and remove all alpha stakes from Alice + # Execute remove_stake command and remove all alpha stakes from Alice's wallet remove_stake = exec_command_alice( command="stake", sub_command="remove", @@ -472,7 +474,7 @@ def test_staking(local_chain, wallet_setup): "--partial", "--no-prompt", "--era", - "144", + "32", "--json-output", ], ) diff --git a/tests/e2e_tests/test_unstaking.py b/tests/e2e_tests/test_unstaking.py index f3173b5a7..8f17341a0 100644 --- a/tests/e2e_tests/test_unstaking.py +++ b/tests/e2e_tests/test_unstaking.py @@ -1,12 +1,13 @@ import asyncio import json import re - +import pytest from bittensor_cli.src.bittensor.balances import Balance from .utils import set_storage_extrinsic +@pytest.mark.parametrize("local_chain", [False], indirect=True) def test_unstaking(local_chain, wallet_setup): """ Test various unstaking scenarios including partial unstake, unstake all alpha, and unstake all. @@ -387,9 +388,7 @@ def test_unstaking(local_chain, wallet_setup): ], ) - assert ( - "✅ Finalized: Successfully unstaked all Alpha stakes" in unstake_alpha.stdout - ) + assert "✅ Included: Successfully unstaked all Alpha stakes" in unstake_alpha.stdout assert "Your extrinsic has been included" in unstake_alpha.stdout, ( unstake_alpha.stdout ) @@ -442,6 +441,6 @@ def test_unstaking(local_chain, wallet_setup): "144", ], ) - assert "✅ Finalized: Successfully unstaked all stakes from" in unstake_all.stdout + assert "✅ Included: Successfully unstaked all stakes from" in unstake_all.stdout assert "Your extrinsic has been included" in unstake_all.stdout, unstake_all.stdout print("Passed unstaking tests 🎉") diff --git a/tests/e2e_tests/test_wallet_interactions.py b/tests/e2e_tests/test_wallet_interactions.py index 3b92c4965..7ed705b65 100644 --- a/tests/e2e_tests/test_wallet_interactions.py +++ b/tests/e2e_tests/test_wallet_interactions.py @@ -74,6 +74,7 @@ def test_wallet_overview_inspect(local_chain, wallet_setup): "--additional-info", "Test subnet", "--no-prompt", + "--no-mev-protection", ], ) assert f"✅ Registered subnetwork with netuid: {netuid}" in result.stdout @@ -393,6 +394,7 @@ def test_wallet_identities(local_chain, wallet_setup): "--logo-url", "https://testsubnet.com/logo.png", "--no-prompt", + "--no-mev-protection", ], ) assert f"✅ Registered subnetwork with netuid: {netuid}" in result.stdout diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index 323797356..effd4cef9 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -132,6 +132,34 @@ def extract_coldkey_balance( } +def find_stake_entries( + stake_payload: dict, netuid: int, hotkey_ss58: str | None = None +) -> list[dict]: + """ + Return stake entries matching a given netuid, optionally scoped to a specific hotkey. + Requires json payload using `--json-output` flag. + + Args: + stake_payload: Parsed JSON payload containing `stake_info`. + netuid: The subnet identifier to filter on. + hotkey_ss58: Optional hotkey address to further narrow results. + + Returns: + A list of stake dicts matching the criteria (may be empty). + """ + stake_info = stake_payload.get("stake_info", {}) or {} + matching_stakes: list[dict] = [] + + for stake_hotkey, stakes in stake_info.items(): + if hotkey_ss58 and stake_hotkey != hotkey_ss58: + continue + for stake in stakes or []: + if stake.get("netuid") == netuid: + matching_stakes.append(stake) + + return matching_stakes + + def verify_subnet_entry(output_text: str, netuid: str, ss58_address: str) -> bool: """ Verifies the presence of a specific subnet entry subnets list output. diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py index a17ed8406..a061910e5 100644 --- a/tests/unit_tests/test_cli.py +++ b/tests/unit_tests/test_cli.py @@ -228,3 +228,434 @@ def test_swap_hotkey_netuid_1_no_warning(mock_console): assert not any( "WARNING" in str(call) and "netuid 0" in str(call) for call in warning_calls ) + + +# ============================================================================ +# Tests for proxy parameter handling +# ============================================================================ + + +def test_is_valid_proxy_name_or_ss58_with_none_proxy(): + """Test that None proxy is accepted when announce_only is False""" + cli_manager = CLIManager() + result = cli_manager.is_valid_proxy_name_or_ss58(None, announce_only=False) + assert result is None + + +def test_is_valid_proxy_name_or_ss58_raises_with_announce_only_without_proxy(): + """Test that announce_only=True without proxy raises BadParameter""" + cli_manager = CLIManager() + with pytest.raises(typer.BadParameter) as exc_info: + cli_manager.is_valid_proxy_name_or_ss58(None, announce_only=True) + assert "Cannot supply '--announce-only' without supplying '--proxy'" in str( + exc_info.value + ) + + +def test_is_valid_proxy_name_or_ss58_with_valid_ss58(): + """Test that a valid SS58 address is accepted""" + cli_manager = CLIManager() + valid_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + result = cli_manager.is_valid_proxy_name_or_ss58(valid_ss58, announce_only=False) + assert result == valid_ss58 + + +def test_is_valid_proxy_name_or_ss58_with_invalid_ss58(): + """Test that an invalid SS58 address raises BadParameter""" + cli_manager = CLIManager() + invalid_ss58 = "invalid_address" + with pytest.raises(typer.BadParameter) as exc_info: + cli_manager.is_valid_proxy_name_or_ss58(invalid_ss58, announce_only=False) + assert "Invalid SS58 address" in str(exc_info.value) + + +def test_is_valid_proxy_name_or_ss58_with_proxy_from_config(): + """Test that a proxy name from config is resolved to SS58 address""" + cli_manager = CLIManager() + valid_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + cli_manager.proxies = {"my_proxy": {"address": valid_ss58}} + + result = cli_manager.is_valid_proxy_name_or_ss58("my_proxy", announce_only=False) + assert result == valid_ss58 + + +def test_is_valid_proxy_name_or_ss58_with_invalid_proxy_from_config(): + """Test that an invalid SS58 in config raises BadParameter""" + cli_manager = CLIManager() + cli_manager.proxies = {"my_proxy": {"address": "invalid_address"}} + + with pytest.raises(typer.BadParameter) as exc_info: + cli_manager.is_valid_proxy_name_or_ss58("my_proxy", announce_only=False) + assert "Invalid SS58 address" in str(exc_info.value) + assert "from config" in str(exc_info.value) + + +@patch("bittensor_cli.cli.is_valid_ss58_address") +def test_wallet_transfer_calls_proxy_validation(mock_is_valid_ss58): + """Test that wallet_transfer calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + mock_is_valid_ss58.return_value = True + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet_ask.return_value = Mock() + + cli_manager.wallet_transfer( + destination_ss58_address="5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + amount=10.0, + transfer_all=False, + allow_death=False, + period=100, + proxy=valid_proxy, + announce_only=False, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + network=None, + prompt=False, + quiet=True, + verbose=False, + json_output=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False) + + +@patch("bittensor_cli.cli.is_valid_ss58_address") +def test_wallet_transfer_with_announce_only_requires_proxy(mock_is_valid_ss58): + """Test that wallet_transfer with announce_only=True requires proxy""" + cli_manager = CLIManager() + mock_is_valid_ss58.return_value = True + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + ): + mock_wallet_ask.return_value = Mock() + + with pytest.raises(typer.BadParameter) as exc_info: + cli_manager.wallet_transfer( + destination_ss58_address="5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + amount=10.0, + transfer_all=False, + allow_death=False, + period=100, + proxy=None, + announce_only=True, # announce_only without proxy should fail + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + network=None, + prompt=False, + quiet=True, + verbose=False, + json_output=False, + ) + + assert "Cannot supply '--announce-only' without supplying '--proxy'" in str( + exc_info.value + ) + + +def test_stake_add_calls_proxy_validation(): + """Test that stake_add calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + patch.object(cli_manager, "ask_safe_staking", return_value=False), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet_ask.return_value = Mock() + + cli_manager.stake_add( + stake_all=False, + amount=10.0, + include_hotkeys="", + exclude_hotkeys="", + all_hotkeys=False, + netuids="1", + all_netuids=False, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + proxy=valid_proxy, + announce_only=False, + network=None, + rate_tolerance=None, + safe_staking=False, + allow_partial_stake=None, + period=100, + prompt=False, + quiet=True, + verbose=False, + json_output=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False) + + +def test_stake_remove_calls_proxy_validation(): + """Test that stake_remove calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + patch.object(cli_manager, "ask_safe_staking", return_value=False), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet_ask.return_value = Mock() + + cli_manager.stake_remove( + network=None, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + netuid=1, + all_netuids=False, + unstake_all=False, + unstake_all_alpha=False, + amount=10.0, + hotkey_ss58_address="", + include_hotkeys="", + exclude_hotkeys="", + all_hotkeys=False, + proxy=valid_proxy, + announce_only=False, + rate_tolerance=None, + safe_staking=False, + allow_partial_stake=None, + period=100, + prompt=False, + quiet=True, + verbose=False, + json_output=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False) + + +def test_wallet_associate_hotkey_calls_proxy_validation(): + """Test that wallet_associate_hotkey calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + valid_hotkey = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + patch("bittensor_cli.cli.is_valid_ss58_address", return_value=True), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet = Mock() + mock_wallet.name = "test_wallet" + mock_wallet_ask.return_value = mock_wallet + + cli_manager.wallet_associate_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey=valid_hotkey, + network=None, + proxy=valid_proxy, + announce_only=False, + prompt=False, + quiet=True, + verbose=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False) + + +def test_wallet_set_id_calls_proxy_validation(): + """Test that wallet_set_id calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet_ask.return_value = Mock() + + cli_manager.wallet_set_id( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + network=None, + name="Test Name", + web_url="https://example.com", + image_url="https://example.com/image.png", + discord="testuser", + description="Test description", + additional="Additional info", + github_repo="test/repo", + proxy=valid_proxy, + announce_only=False, + quiet=True, + verbose=False, + prompt=False, + json_output=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False) + + +def test_wallet_swap_coldkey_calls_proxy_validation(): + """Test that wallet_swap_coldkey calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + new_coldkey = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + patch("bittensor_cli.cli.is_valid_ss58_address", return_value=True), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet = Mock() + mock_wallet.coldkeypub = Mock() + mock_wallet.coldkeypub.ss58_address = ( + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + ) + mock_wallet_ask.return_value = mock_wallet + + cli_manager.wallet_swap_coldkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + new_wallet_or_ss58=new_coldkey, + network=None, + proxy=valid_proxy, + announce_only=False, + quiet=True, + verbose=False, + force_swap=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False) + + +def test_stake_move_calls_proxy_validation(): + """Test that stake_move calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + dest_hotkey = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command", return_value=(None, None)), + patch("bittensor_cli.cli.is_valid_ss58_address", return_value=True), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet = Mock() + mock_wallet.hotkey_str = "test_hotkey" + mock_wallet_ask.return_value = mock_wallet + + cli_manager.stake_move( + network=None, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + origin_netuid=1, + destination_netuid=2, + destination_hotkey=dest_hotkey, + amount=10.0, + stake_all=False, + proxy=valid_proxy, + announce_only=False, + period=100, + prompt=False, + quiet=True, + verbose=False, + json_output=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False) + + +def test_stake_transfer_calls_proxy_validation(): + """Test that stake_transfer calls is_valid_proxy_name_or_ss58""" + cli_manager = CLIManager() + valid_proxy = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + dest_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command", return_value=(None, None)), + patch("bittensor_cli.cli.is_valid_ss58_address", return_value=True), + patch.object( + cli_manager, "is_valid_proxy_name_or_ss58", return_value=valid_proxy + ) as mock_proxy_validation, + ): + mock_wallet = Mock() + mock_wallet.hotkey_str = "test_hotkey" + mock_wallet_ask.return_value = mock_wallet + + cli_manager.stake_transfer( + network=None, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + origin_netuid=1, + dest_netuid=2, + dest_ss58=dest_ss58, + amount=10.0, + stake_all=False, + period=100, + proxy=valid_proxy, + announce_only=False, + prompt=False, + quiet=True, + verbose=False, + json_output=False, + ) + + # Assert that proxy validation was called + mock_proxy_validation.assert_called_once_with(valid_proxy, False)