diff --git a/.github/workflows/e2e-subtensor-tests.yml b/.github/workflows/e2e-subtensor-tests.yml index fcd7ddf53..5d1493835 100644 --- a/.github/workflows/e2e-subtensor-tests.yml +++ b/.github/workflows/e2e-subtensor-tests.yml @@ -28,6 +28,11 @@ on: - testnet - devnet - devnet-ready + custom_image_tag: + description: "Custom docker image tag โ€” overrides selection above (e.g. pr-2441)" + required: false + type: string + default: "" env: CARGO_TERM_COLOR: always @@ -66,16 +71,25 @@ jobs: id: set-image env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_CUSTOM_TAG: ${{ github.event.inputs.custom_image_tag }} + INPUT_CHOICE_TAG: ${{ github.event.inputs.docker_image_tag }} run: | echo "Event: $GITHUB_EVENT_NAME" echo "Branch: $GITHUB_REF_NAME" - # Check if docker_image_tag input is provided (for workflow_dispatch) if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - docker_tag_input="${{ github.event.inputs.docker_image_tag }}" - if [[ -n "$docker_tag_input" ]]; then - image="ghcr.io/opentensor/subtensor-localnet:${docker_tag_input}" - echo "Using Docker image tag from workflow_dispatch input: ${docker_tag_input}" + custom_tag=$(echo "$INPUT_CUSTOM_TAG" | xargs) + if [[ -n "$custom_tag" ]]; then + image="ghcr.io/opentensor/subtensor-localnet:${custom_tag}" + echo "Using custom docker image tag: ${custom_tag}" + echo "โœ… Final selected image: $image" + echo "image=$image" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ -n "$INPUT_CHOICE_TAG" ]]; then + image="ghcr.io/opentensor/subtensor-localnet:${INPUT_CHOICE_TAG}" + echo "Using standard docker image tag: ${INPUT_CHOICE_TAG}" echo "โœ… Final selected image: $image" echo "image=$image" >> "$GITHUB_OUTPUT" exit 0 @@ -163,7 +177,7 @@ jobs: os: - ubuntu-latest test-file: ${{ fromJson(needs.find-tests.outputs.test-files) }} - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Check-out repository uses: actions/checkout@v4 diff --git a/.github/workflows/ruff-formatter.yml b/.github/workflows/ruff-formatter.yml index 93166c304..92d7c30c4 100644 --- a/.github/workflows/ruff-formatter.yml +++ b/.github/workflows/ruff-formatter.yml @@ -1,42 +1,27 @@ name: Ruff Formatter Check + +concurrency: + group: ruff-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read on: pull_request: - types: [opened, synchronize, reopened, edited] + types: [opened, synchronize, reopened] jobs: ruff: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9.13"] + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Set up caching for Ruff virtual environment - id: cache-ruff - uses: actions/cache@v4 - with: - path: .venv - key: v2-pypi-py-ruff-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} - restore-keys: | - v2-pypi-py-ruff-${{ matrix.python-version }}- - - - name: Set up Ruff virtual environment if cache is missed - if: steps.cache-ruff.outputs.cache-hit != 'true' - run: | - python -m venv .venv - .venv/bin/python -m pip install ruff==0.11.5 - - name: Ruff format check - run: | - .venv/bin/ruff format --diff bittensor_cli - .venv/bin/ruff format --diff tests + uses: astral-sh/ruff-action@v3 + with: + version: "0.11.5" + args: "format --diff" + src: "bittensor_cli tests" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4e5d34e41..4e96f29b6 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -27,7 +27,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Check-out repository uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 690dfc38c..97a21833e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## 9.19.0 /2026-03-02 + +## What's Changed +* fix: JSON output empty for `btcli subnets list --json-out` command by @GlobalStar117 in https://github.com/opentensor/btcli/pull/800 +* fix: disable wallet history command due to external API deprecation by @jose-blockchain in https://github.com/opentensor/btcli/pull/811 +* Reusable `create_table()` Utility for Consistent Table Styling by @eureka928 in https://github.com/opentensor/btcli/pull/790 +* fix: replace broad exception catches with specific exception types by @Achieve3318 in https://github.com/opentensor/btcli/pull/773 +* Improve disk caching by @thewhaleking in https://github.com/opentensor/btcli/pull/682 +* Feat/rework ck swap by @ibraheem-abe in https://github.com/opentensor/btcli/pull/792 +* Error message handled properly by @thewhaleking in https://github.com/opentensor/btcli/pull/814 +* Backmerge/9181 by @ibraheem-abe in https://github.com/opentensor/btcli/pull/816 +* Adds more to the debug section of the readme by @thewhaleking in https://github.com/opentensor/btcli/pull/817 +* Fix/proxy stake add remove by @ibraheem-abe in https://github.com/opentensor/btcli/pull/819 +* Fix/update proxy usage stuff by @ibraheem-abe in https://github.com/opentensor/btcli/pull/820 +* Feat: Add help cmd alias by @ibraheem-abe in https://github.com/opentensor/btcli/pull/821 +* Backmerge/9.18.1 by @ibraheem-abe in https://github.com/opentensor/btcli/pull/823 +* Feat/balancer swap updates by @ibraheem-abe in https://github.com/opentensor/btcli/pull/813 +* Handle different types in `sudo set` with arbitrary hyperparams by @thewhaleking in https://github.com/opentensor/btcli/pull/825 +* Update: Python 3.9 End of Life by @ibraheem-abe in https://github.com/opentensor/btcli/pull/829 +* Optimises the workflow for ruff. by @thewhaleking in https://github.com/opentensor/btcli/pull/831 +* Optimises the workflow for ruff. by @thewhaleking in https://github.com/opentensor/btcli/pull/832 +* feat: Add hyperparams: sudo_set_sn_owner_hotkey, sudo_set_subnet_owner_hotkey, sudo_set_recycle_or_burn by @MkDev11 in https://github.com/opentensor/btcli/pull/827 +* subnet buyback / stake-burn by @ibraheem-abe in https://github.com/opentensor/btcli/pull/818 +* Fix: Update test_stake_burn by @ibraheem-abe in https://github.com/opentensor/btcli/pull/837 +* Feat: Updated MevShield mechanics by @ibraheem-abe in https://github.com/opentensor/btcli/pull/828 +* docs: fix typo in README (pacakge โ†’ package) by @GeObts in https://github.com/opentensor/btcli/pull/839 +* Fix: Remove old mev_shield artifact from stake_burn by @ibraheem-abe in https://github.com/opentensor/btcli/pull/842 +* Revert "Feat/balancer swap updates" by @ibraheem-abe in https://github.com/opentensor/btcli/pull/836 +* Update/CK swap error handling by @ibraheem-abe in https://github.com/opentensor/btcli/pull/844 +* Tests: Add custom tags for docker images in e2e by @ibraheem-abe in https://github.com/opentensor/btcli/pull/848 +* Update: Cap MeV shield txs era to 8 by @ibraheem-abe in https://github.com/opentensor/btcli/pull/850 +* Update: Enforce era 'always' for mev_shield txs by @ibraheem-abe in https://github.com/opentensor/btcli/pull/851 +* Adds max_allowed_uids hyperparam for setting by @thewhaleking in https://github.com/opentensor/btcli/pull/852 +* Applies type hint to to `process_nested` by @thewhaleking in https://github.com/opentensor/btcli/pull/856 +* Update/runtime update by @ibraheem-abe in https://github.com/opentensor/btcli/pull/857 +* feat: add support for the `--all` to proxy remove by @eureka928 in https://github.com/opentensor/btcli/pull/834 +* Adds signed commits info to docs by @thewhaleking in https://github.com/opentensor/btcli/pull/859 +* Add better typing by @thewhaleking in https://github.com/opentensor/btcli/pull/858 +* Update: Pin btwallet requirement by @ibraheem-abe in https://github.com/opentensor/btcli/pull/864 + +## New Contributors +* @GlobalStar117 made their first contribution in https://github.com/opentensor/btcli/pull/800 +* @jose-blockchain made their first contribution in https://github.com/opentensor/btcli/pull/811 +* @Achieve3318 made their first contribution in https://github.com/opentensor/btcli/pull/773 +* @GeObts made their first contribution in https://github.com/opentensor/btcli/pull/839 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.18.1...v9.19.0 + ## 9.18.1 /2026-02-05 ## What's Changed diff --git a/README.md b/README.md index 37b4bc488..01b7df595 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ You can install `btcli` on your local machine directly from source, PyPI, or Hom Note that the macOS preinstalled CPython installation is compiled with LibreSSL instead of OpenSSL. There are a number of issues with LibreSSL, and as such is not fully supported by the libraries used by btcli. Thus we highly recommend, if you are using a Mac, to first install Python from [Homebrew](https://brew.sh/). Additionally, the Rust FFI bindings -[if installing from precompiled wheels (default)] require the Homebrew-installed OpenSSL pacakge. If you choose to use +[if installing from precompiled wheels (default)] require the Homebrew-installed OpenSSL package. If you choose to use the preinstalled Python version from macOS, things may not work completely. diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 295232c36..ebf4d29d8 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -32,7 +32,7 @@ from rich.prompt import FloatPrompt, Prompt, IntPrompt from rich.table import Column, Table from rich.tree import Tree -from typing_extensions import Annotated +from typing import Annotated from yaml import safe_dump, safe_load from bittensor_cli.src import ( @@ -641,8 +641,8 @@ def get_creation_data( return mnemonic, seed, json_path, json_password -def config_selector(conf: dict, title: str): - def curses_selector(stdscr): +def config_selector(conf: dict[str, bool], title: str) -> dict[str, bool]: + def curses_selector(stdscr) -> dict[str, bool]: """ Enhanced Curses TUI to make selections. """ @@ -698,7 +698,7 @@ def curses_selector(stdscr): return curses.wrapper(curses_selector) -def version_callback(value: bool): +def version_callback(value: bool) -> None: """ Prints the current version/branch-name """ @@ -716,7 +716,7 @@ def version_callback(value: bool): raise typer.Exit() -def commands_callback(value: bool): +def commands_callback(value: bool) -> None: """ Prints a tree of commands for the app """ @@ -726,7 +726,7 @@ def commands_callback(value: bool): raise typer.Exit() -def debug_callback(value: bool): +def debug_callback(value: bool) -> None: if value: debug_file_loc = Path( os.getenv("BTCLI_DEBUG_FILE") @@ -1165,6 +1165,9 @@ def __init__(self): self.sudo_app.command("trim", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"])( self.sudo_trim ) + self.sudo_app.command( + "stake-burn", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"] + )(self.sudo_stake_burn) # subnets commands self.subnets_app.command( @@ -1299,6 +1302,7 @@ def __init__(self): self.sudo_app.command("senate_vote", hidden=True)(self.sudo_senate_vote) self.sudo_app.command("get_take", hidden=True)(self.sudo_get_take) self.sudo_app.command("set_take", hidden=True)(self.sudo_set_take) + self.sudo_app.command("buyback", hidden=True)(self.sudo_stake_burn) # Stake self.stake_app.command( @@ -1385,7 +1389,7 @@ def generate_command_tree(self) -> Tree: Generates a rich.Tree of the commands, subcommands, and groups of this app """ - def build_rich_tree(data: dict, parent: Tree): + def build_rich_tree(data: dict, parent: Tree) -> None: for group, content in data.get("groups", {}).items(): group_node = parent.add( f"[bold cyan]{group}[/]" @@ -1939,7 +1943,7 @@ def del_config( with open(self.config_path, "w") as f: safe_dump(self.config, f) - def get_config(self): + def get_config(self) -> None: """ Prints the current config file in a table. """ @@ -2075,7 +2079,7 @@ def config_remove_proxy( print_error(f"Proxy {name} not found in address book.") self.config_get_proxies() - def config_get_proxies(self): + def config_get_proxies(self) -> None: """ Displays the current proxies address book @@ -2224,7 +2228,7 @@ def config_update_proxy( console.print("Proxy updated") self.config_get_proxies() - def config_clear_proxy_book(self): + def config_clear_proxy_book(self) -> None: """ Clears the proxy address book. Use with caution. Really only useful if you have corrupted your proxy address book. @@ -3543,62 +3547,63 @@ def wallet_check_ck_swap( wallet_ss58_address: Optional[str] = Options.wallet_ss58_address, wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hotkey, - scheduled_block: Optional[int] = typer.Option( - None, - "--block", - help="Block number where the swap was scheduled", - ), show_all: bool = typer.Option( False, "--all", "-a", - help="Show all pending coldkey swaps", + help="Show all pending coldkey swap announcements", ), network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ - Check the status of scheduled coldkey swaps. + Check the status of pending coldkey swap announcements. + + Coldkey swaps use a two-step announcement system. Use this command + to check if you have any pending announcements and when they become executable. USAGE - This command can be used in three ways: - 1. Show all pending swaps (--all) - 2. Check status of a specific wallet's swap or SS58 address - 3. Check detailed swap status with block number (--block) + This command can be used in two ways: + + 1. Show all pending announcements (--all) + + 2. Check status of a specific wallet or SS58 address EXAMPLES - Show all pending swaps: + 1. Show all pending swap announcements: + [green]$[/green] btcli wallet swap-check --all - Check specific wallet's swap: + 2. Check specific wallet's announcement: + [green]$[/green] btcli wallet swap-check --wallet-name my_wallet - Check swap using SS58 address: - [green]$[/green] btcli wallet swap-check --ss58 5DkQ4... + 3. Check announcement using SS58 address: - Check swap details with block number: - [green]$[/green] btcli wallet swap-check --wallet-name my_wallet --block 12345 + [green]$[/green] btcli wallet swap-check --ss58 5DkQ4... """ - # TODO add json_output if this ever gets used again (doubtful) - self.verbosity_handler(quiet, verbose, json_output=False, prompt=False) + self.verbosity_handler(quiet, verbose, json_output=json_output, prompt=False) self.initialize_chain(network) if show_all: return self._run_command( - wallets.check_swap_status(self.subtensor, None, None) + wallets.check_swap_status(self.subtensor, None, json_output=json_output) ) if not wallet_ss58_address: wallet_ss58_address = Prompt.ask( "Enter [blue]wallet name[/blue] or [blue]SS58 address[/blue] [dim]" - "(leave blank to show all pending swaps)[/dim]" + "(leave blank to show all pending announcements)[/dim]" ) if not wallet_ss58_address: return self._run_command( - wallets.check_swap_status(self.subtensor, None, None) + wallets.check_swap_status( + self.subtensor, None, json_output=json_output + ) ) if is_valid_ss58_address(wallet_ss58_address): @@ -3613,26 +3618,11 @@ def wallet_check_ck_swap( ) ss58_address = wallet.coldkeypub.ss58_address - if not scheduled_block: - block_input = Prompt.ask( - "[blue]Enter the block number[/blue] where the swap was scheduled " - "[dim](optional, press enter to skip)[/dim]", - default="", - ) - if block_input: - try: - scheduled_block = int(block_input) - except ValueError: - print_error("Invalid block number") - raise typer.Exit() - logger.debug( - "args:\n" - f"scheduled_block {scheduled_block}\n" - f"ss58_address {ss58_address}\n" - f"network {network}\n" - ) + logger.debug(f"args:\nss58_address {ss58_address}\nnetwork {network}\n") return self._run_command( - wallets.check_swap_status(self.subtensor, ss58_address, scheduled_block) + wallets.check_swap_status( + self.subtensor, ss58_address, json_output=json_output + ) ) def wallet_create_wallet( @@ -3842,8 +3832,10 @@ def wallet_history( # if self.config.get("network") != "finney": # console.print(no_use_config_str) - # For Rao games - print_error("This command is disabled on the 'rao' network.") + print_error( + "This command is currently disabled as it used external APIs which are no longer " + "feasible; meanwhile a chain native data fetching solution is being investigated." + ) raise typer.Exit() self.verbosity_handler(quiet, verbose, False, False) @@ -4150,6 +4142,10 @@ def wallet_verify( def wallet_swap_coldkey( self, + action: str = typer.Argument( + None, + help="Action to perform: 'announce' to announce intent, 'execute' to complete swap after delay, 'dispute' to freeze the swap.", + ), wallet_name: Optional[str] = Options.wallet_name, wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hotkey, @@ -4159,39 +4155,70 @@ def wallet_swap_coldkey( "--new-coldkey-ss58", "--new-wallet", "--new", - help="SS58 address of the new coldkey that will replace the current one.", + help="SS58 address or wallet name of the new coldkey.", ), + mev_protection: bool = Options.mev_protection, network: Optional[list[str]] = Options.network, - proxy: Optional[str] = Options.proxy, - announce_only: bool = Options.announce_only, - decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, - force_swap: bool = typer.Option( - False, - "--force", - "-f", - "--force-swap", - help="Force the swap even if the new coldkey is already scheduled for a swap.", - ), + decline: bool = Options.decline, + prompt: bool = Options.prompt, ): """ - Schedule a coldkey swap for a wallet. + Swap your coldkey to a new address using a two-step announcement process. + + Coldkey swaps require two steps for security: + + 1. [bold]Announce[/bold]: Declare your intent to swap. This pays the swap fee and starts a delay period. - This command allows you to schedule a coldkey swap for a wallet. You can either provide a new wallet name, or SS58 address. + 2. [bold]Execute[/bold]: After the delay (typically 5 days), complete the swap. + + If you suspect compromise, you can [bold]Dispute[/bold] an active announcement to freeze + all activity for the coldkey until the triumvirate can intervene. EXAMPLES - [green]$[/green] btcli wallet schedule-coldkey-swap --new-wallet my_new_wallet + Step 1 - Announce your intent to swap: + + [green]$[/green] btcli wallet swap-coldkey announce --new-coldkey 5Dk...X3q + + Step 2 - After the delay period, execute the swap: + + [green]$[/green] btcli wallet swap-coldkey execute --new-coldkey 5Dk...X3q + + Dispute an active swap (freezes the swap process): + + [green]$[/green] btcli wallet swap-coldkey dispute - [green]$[/green] btcli wallet schedule-coldkey-swap --new-coldkey-ss58 5Dk...X3q + Check status of pending swaps: + + [green]$[/green] btcli wallet swap-check """ self.verbosity_handler(quiet, verbose, prompt=False, json_output=False) - proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + + if not action: + console.print( + "\n[bold][blue]Coldkey Swap Actions:[/blue][/bold]\n" + " [dark_sea_green3]announce[/dark_sea_green3] - Start the swap process (pays fee, starts delay timer)\n" + " [dark_sea_green3]execute[/dark_sea_green3] - Complete the swap (after delay period)\n" + " [dark_sea_green3]dispute[/dark_sea_green3] - Freeze the swap process if you suspect compromise\n\n" + " [dim]You can check the current status of your swap with 'btcli wallet swap-check'.[/dim]\n" + ) + action = Prompt.ask( + "Select action", + choices=["announce", "execute", "dispute"], + default="announce", + ) + + if action.lower() not in ("announce", "execute", "dispute"): + print_error( + f"Invalid action: {action}. Must be 'announce', 'execute', or 'dispute'." + ) + raise typer.Exit(1) if not wallet_name: wallet_name = Prompt.ask( - "Enter the [blue]wallet name[/blue] which you want to swap the coldkey for", + "Enter the [blue]wallet name[/blue] of the coldkey to swap", default=self.config.get("wallet_name") or defaults.wallet.name, ) wallet = self.wallet_ask( @@ -4202,48 +4229,74 @@ def wallet_swap_coldkey( validate=WV.WALLET, ) console.print( - f"\nWallet selected to swap the [blue]coldkey[/blue] from: \n" - f"[dark_sea_green3]{wallet}[/dark_sea_green3]\n" + f"\nWallet selected: [dark_sea_green3]{wallet}[/dark_sea_green3]\n" ) - if not new_wallet_or_ss58: - new_wallet_or_ss58 = Prompt.ask( - "Enter the [blue]new wallet name[/blue] or [blue]SS58 address[/blue] of the new coldkey", - ) + new_wallet_coldkey_ss58 = None + if action != "dispute": + if not new_wallet_or_ss58: + new_wallet_or_ss58 = Prompt.ask( + "Enter the [blue]new wallet name[/blue] or [blue]SS58 address[/blue] of the new coldkey", + ) + + if is_valid_ss58_address(new_wallet_or_ss58): + new_wallet_coldkey_ss58 = new_wallet_or_ss58 + else: + new_wallet_name = new_wallet_or_ss58 + new_wallet = self.wallet_ask( + new_wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME], + validate=WV.WALLET, + ) + console.print( + f"\nNew coldkey wallet: [dark_sea_green3]{new_wallet}[/dark_sea_green3]\n" + ) + new_wallet_coldkey_ss58 = new_wallet.coldkeypub.ss58_address - if is_valid_ss58_address(new_wallet_or_ss58): - new_wallet_coldkey_ss58 = new_wallet_or_ss58 - else: - new_wallet_name = new_wallet_or_ss58 - new_wallet = self.wallet_ask( - new_wallet_name, - wallet_path, - wallet_hotkey, - ask_for=[WO.NAME], - validate=WV.WALLET, - ) - console.print( - f"\nNew wallet to swap the [blue]coldkey[/blue] to: \n" - f"[dark_sea_green3]{new_wallet}[/dark_sea_green3]\n" - ) - new_wallet_coldkey_ss58 = new_wallet.coldkeypub.ss58_address logger.debug( - "args:\n" - f"network {network}\n" - f"new_coldkey_ss58 {new_wallet_coldkey_ss58}\n" - f"force_swap {force_swap}" + f"args:\n" + f"action: {action}\n" + f"network: {network}\n" + f"new_coldkey_ss58: {new_wallet_coldkey_ss58}" ) - return self._run_command( - wallets.schedule_coldkey_swap( - wallet=wallet, - subtensor=self.initialize_chain(network), - new_coldkey_ss58=new_wallet_coldkey_ss58, - force_swap=force_swap, - decline=decline, - quiet=quiet, - proxy=proxy, + + if action == "announce": + return self._run_command( + wallets.announce_coldkey_swap( + wallet=wallet, + subtensor=self.initialize_chain(network), + new_coldkey_ss58=new_wallet_coldkey_ss58, + decline=decline, + quiet=quiet, + prompt=prompt, + mev_protection=mev_protection, + ) + ) + elif action == "dispute": + return self._run_command( + wallets.dispute_coldkey_swap( + wallet=wallet, + subtensor=self.initialize_chain(network), + decline=decline, + quiet=quiet, + prompt=prompt, + mev_protection=mev_protection, + ) + ) + else: + return self._run_command( + wallets.execute_coldkey_swap( + wallet=wallet, + subtensor=self.initialize_chain(network), + new_coldkey_ss58=new_wallet_coldkey_ss58, + decline=decline, + quiet=quiet, + prompt=prompt, + mev_protection=mev_protection, + ) ) - ) def axon_reset( self, @@ -7343,6 +7396,107 @@ def sudo_trim( ) ) + def sudo_stake_burn( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey_ss58, + netuid: int = Options.netuid, + amount: float = typer.Option( + None, + "--amount", + "-a", + help="Amount of TAO to stake and burn", + prompt="Enter the amount of TAO to stake and burn", + ), + proxy: Optional[str] = Options.proxy, + rate_tolerance: Optional[float] = Options.rate_tolerance, + safe_staking: Optional[bool] = Options.safe_staking, + mev_protection: bool = Options.mev_protection, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + prompt: bool = Options.prompt, + decline: bool = Options.decline, + period: int = Options.period, + ): + """ + Allows subnet owners to buy back alpha on their subnet by staking TAO and immediately burning the acquired alpha. + + [bold]Examples:[/bold] + 1. Stake and burn 10 TAO on subnet 14: + [green]$[/green] btcli sudo stake-burn --netuid 14 --amount 10 + 2. Stake and burn 10 TAO on subnet 14 with safe staking and 5% rate tolerance: + [green]$[/green] btcli sudo stake-burn --netuid 14 --amount 10 --tolerance 0.05 + 3. Stake and burn 10 TAO on subnet 14 with a specific hotkey: + [green]$[/green] btcli sudo stake-burn --netuid 14 --amount 10 --wallet-hotkey + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only=False) + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + rate_tolerance = self.ask_rate_tolerance(rate_tolerance) + + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="sudo stake-burn", + ) + + if not wallet_hotkey: + wallet_hotkey = Prompt.ask( + "Enter the [blue]hotkey[/blue] name or " + "[blue]hotkey ss58 address[/blue] [dim](to use for the stake burn)[/dim]", + default=self.config.get("wallet_hotkey") or defaults.wallet.hotkey, + ) + + if wallet_hotkey and is_valid_ss58_address(wallet_hotkey): + hotkey_ss58 = wallet_hotkey + wallet = self.wallet_ask( + wallet_name, + wallet_path, + None, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + else: + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + hotkey_ss58 = get_hotkey_pub_ss58(wallet) + + if amount <= 0: + print_error(f"You entered an incorrect stake and burn amount: {amount}") + raise typer.Exit() + + if netuid == 0: + print_error("Cannot stake and burn on the root subnet.") + raise typer.Exit() + + self._run_command( + sudo.stake_burn( + subtensor=self.initialize_chain(network), + wallet=wallet, + netuid=netuid, + amount=amount, + hotkey_ss58=hotkey_ss58, + safe_staking=safe_staking, + proxy=proxy, + rate_tolerance=rate_tolerance, + mev_protection=mev_protection, + json_output=json_output, + prompt=prompt, + decline=decline, + quiet=quiet, + period=period, + ) + ) + # Subnets def subnets_list( @@ -9653,13 +9807,18 @@ def proxy_add( def proxy_remove( self, delegate: Annotated[ - str, + Optional[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", + prompt=False, + help="The SS58 address of the delegate to remove (required if --all is not used)", ), - ] = "", + ] = None, + all_: bool = typer.Option( + False, + "--all", + help="Remove all proxies associated with this account", + ), network: Optional[list[str]] = Options.network, proxy_type: ProxyType = Options.proxy_type, delay: int = typer.Option(0, help="Delay, in number of blocks"), @@ -9686,10 +9845,10 @@ def proxy_remove( [green]$[/green] btcli proxy remove --delegate 5GDel... --proxy-type Transfer """ - # TODO should add a --all flag to call Proxy.remove_proxies ? logger.debug( "args:\n" f"delegate: {delegate}\n" + f"all: {all_}\n" f"network: {network}\n" f"proxy_type: {proxy_type}\n" f"delay: {delay}\n" @@ -9697,6 +9856,41 @@ def proxy_remove( f"wait_for_inclusion: {wait_for_inclusion}\n" f"era: {period}\n" ) + # Validate that --delegate and --all are not used together + if all_ and delegate: + if not json_output: + print_error("--delegate cannot be used together with --all flag.") + else: + json_console.print_json( + data={ + "success": False, + "message": "--delegate cannot be used together with --all flag.", + "extrinsic_identifier": None, + } + ) + return + + # If --all is not used and delegate is not provided, prompt or error + if not all_ and not delegate: + if not prompt: + if not json_output: + print_error( + "Either --delegate must be provided or --all flag must be used." + ) + else: + json_console.print_json( + data={ + "success": False, + "message": "Either --delegate must be provided or --all flag must be used.", + "extrinsic_identifier": None, + } + ) + return + delegate = Prompt.ask( + "Enter the SS58 address of the delegate to remove, e.g. 5dxds..." + ) + delegate = is_valid_ss58_address_param(delegate) + self.verbosity_handler(quiet, verbose, json_output, prompt) wallet = self.wallet_ask( wallet_name=wallet_name, @@ -9719,6 +9913,7 @@ def proxy_remove( wait_for_finalization=wait_for_finalization, period=period, json_output=json_output, + remove_all=all_, ) ) @@ -10121,11 +10316,11 @@ def best_connection( ) return True - def run(self): + def run(self) -> None: self.app() -def main(): +def main() -> None: manager = CLIManager() manager.run() diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index e4de4da53..fc1931fd6 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -683,12 +683,16 @@ class RootSudoOnly(Enum): "bonds_reset_enabled": ("sudo_set_bonds_reset_enabled", RootSudoOnly.FALSE), "transfers_enabled": ("sudo_set_toggle_transfer", RootSudoOnly.FALSE), "min_allowed_uids": ("sudo_set_min_allowed_uids", RootSudoOnly.TRUE), + "sn_owner_hotkey": ("sudo_set_sn_owner_hotkey", RootSudoOnly.FALSE), + "subnet_owner_hotkey": ("sudo_set_sn_owner_hotkey", RootSudoOnly.FALSE), + "recycle_or_burn": ("sudo_set_recycle_or_burn", RootSudoOnly.FALSE), # Note: These are displayed but not directly settable via HYPERPARAMS # They are derived or set via other mechanisms "alpha_high": ("", RootSudoOnly.FALSE), # Derived from alpha_values "alpha_low": ("", RootSudoOnly.FALSE), # Derived from alpha_values "subnet_is_active": ("", RootSudoOnly.FALSE), # Set via btcli subnets start "yuma_version": ("", RootSudoOnly.FALSE), # Related to yuma3_enabled + "max_allowed_uids": ("sudo_set_max_allowed_uids", RootSudoOnly.FALSE), } HYPERPARAMS_MODULE = { @@ -895,6 +899,24 @@ class RootSudoOnly(Enum): "owner_settable": False, "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#minalloweduids", }, + "sn_owner_hotkey": { + "description": "Set the subnet owner hotkey.", + "side_effects": "Changes which hotkey is authorized as subnet owner for the given subnet.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters", + }, + "subnet_owner_hotkey": { + "description": "Alias for sn_owner_hotkey; sets the subnet owner hotkey.", + "side_effects": "Same as sn_owner_hotkey.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters", + }, + "recycle_or_burn": { + "description": "Set whether subnet TAO is recycled or burned.", + "side_effects": "Controls whether unstaked TAO is recycled back into the subnet or burned.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters", + }, # Additional hyperparameters that appear in chain data but aren't directly settable via HYPERPARAMS "alpha_high": { "description": "High bound of the alpha range for stake calculations.", @@ -920,6 +942,12 @@ class RootSudoOnly(Enum): "owner_settable": True, "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#yuma3", }, + "max_allowed_uids": { + "description": "Maximum number of UIDs (neurons) on the subnet, essentially 'untrimming'.", + "side_effects": "See description for min_allowed_uids", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxalloweduids", + }, } # Help Panels for cli help diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py index cfcc699f5..b5df2bd37 100644 --- a/bittensor_cli/src/bittensor/chain_data.py +++ b/bittensor_cli/src/bittensor/chain_data.py @@ -1,7 +1,8 @@ from abc import abstractmethod from dataclasses import dataclass +from collections.abc import Sequence from enum import Enum -from typing import Optional, Any, Union +from typing import Optional, Any, Union, Callable, Hashable import netaddr from scalecodec.utils.ss58 import ss58_encode @@ -16,6 +17,11 @@ get_netuid_and_subuid_by_storage_index, ) +try: + from typing import Self +except ImportError: + from typing_extensions import Self + class ChainDataType(Enum): NeuronInfo = 1 @@ -69,9 +75,12 @@ def _chr_str(codes: tuple[int]) -> str: return "".join(map(chr, codes)) -def process_nested(data: Union[tuple, dict], chr_transform): +def process_nested( + data: Sequence[dict[Hashable, tuple[int]]] | dict, + chr_transform: Callable[[tuple[int]], str], +) -> list[dict[Hashable, str]] | dict[Hashable, str]: """Processes nested data structures by applying a transformation function to their elements.""" - if isinstance(data, (list, tuple)): + if isinstance(data, Sequence): if len(data) > 0 and isinstance(data[0], dict): return [ {k: chr_transform(v) for k, v in item.items()} @@ -79,9 +88,12 @@ def process_nested(data: Union[tuple, dict], chr_transform): else None for item in data ] + # TODO @abe why do we kind of silently fail here? return {} elif isinstance(data, dict): return {k: chr_transform(v) for k, v in data.items()} + else: + raise TypeError(f"Unsupported data type {type(data)}") @dataclass @@ -127,17 +139,17 @@ class InfoBase: """Base dataclass for info objects.""" @abstractmethod - def _fix_decoded(self, decoded: Any) -> "InfoBase": + def _fix_decoded(self, decoded: Any) -> Self: raise NotImplementedError( "This is an abstract method and must be implemented in a subclass." ) @classmethod - def from_any(cls, data: Any) -> "InfoBase": + def from_any(cls, data: Any) -> Self: return cls._fix_decoded(data) @classmethod - def list_from_any(cls, data_list: list[Any]) -> list["InfoBase"]: + def list_from_any(cls, data_list: list[Any]) -> list[Self]: return [cls.from_any(data) for data in data_list] def __getitem__(self, item): @@ -884,20 +896,34 @@ def alpha_to_tao_with_slippage( @dataclass -class ScheduledColdkeySwapInfo(InfoBase): - """Dataclass for scheduled coldkey swap information.""" +class ColdkeySwapAnnouncementInfo(InfoBase): + """ + Information about a coldkey swap announcement. - old_coldkey: str - new_coldkey: str - arbitration_block: int + Contains information about a pending coldkey swap announcement when a coldkey + wants to declare its intent to swap to a new coldkey address. + The announcement is made before the actual swap can be executed, + allowing time for verification and security checks. + + The destination coldkey address is stored as a hash. + This is to prevent the actual coldkey address from being exposed + to the network. The hash is computed using the BlakeTwo256 hashing algorithm. + """ + + coldkey: str + execution_block: int + new_coldkey_hash: str @classmethod - def _fix_decoded(cls, decoded: Any) -> "ScheduledColdkeySwapInfo": - """Fixes the decoded values.""" + def _fix_decoded( + cls, coldkey: str, decoded: tuple + ) -> "ColdkeySwapAnnouncementInfo": + execution_block, new_coldkey_hash = decoded + hash_str = "0x" + bytes(new_coldkey_hash[0]).hex() return cls( - old_coldkey=decode_account_id(decoded.get("old_coldkey")), - new_coldkey=decode_account_id(decoded.get("new_coldkey")), - arbitration_block=decoded.get("arbitration_block"), + coldkey=coldkey, + execution_block=int(execution_block), + new_coldkey_hash=hash_str, ) diff --git a/bittensor_cli/src/bittensor/extrinsics/mev_shield.py b/bittensor_cli/src/bittensor/extrinsics/mev_shield.py index 4eabbd2b3..81750f2a2 100644 --- a/bittensor_cli/src/bittensor/extrinsics/mev_shield.py +++ b/bittensor_cli/src/bittensor/extrinsics/mev_shield.py @@ -1,4 +1,3 @@ -import hashlib from typing import TYPE_CHECKING, Optional from async_substrate_interface import AsyncExtrinsicReceipt @@ -37,18 +36,15 @@ async def encrypt_extrinsic( 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() + ciphertext = encrypt_mlkem768( + ml_kem_768_public_key, plaintext, include_key_hash=True + ) # 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, }, ) @@ -56,29 +52,9 @@ async def encrypt_extrinsic( 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, @@ -112,7 +88,7 @@ async def _noop(_): return True starting_block = await subtensor.substrate.get_block_number(submit_block_hash) - current_block = starting_block + 1 + current_block = starting_block while current_block - starting_block <= timeout_blocks: if status: @@ -137,20 +113,6 @@ async def _noop(_): 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, diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 57b4a627e..41edb1cea 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -30,6 +30,7 @@ MetagraphInfo, SimSwapResult, CrowdloanData, + ColdkeySwapAnnouncementInfo, ) from bittensor_cli.src import DelegatesDetails from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float @@ -296,7 +297,7 @@ async def get_stake_for_coldkey_and_hotkey( self, hotkey_ss58: str, coldkey_ss58: str, - netuid: Optional[int] = None, + netuid: int, block_hash: Optional[str] = None, ) -> Balance: """ @@ -304,42 +305,18 @@ async def get_stake_for_coldkey_and_hotkey( :param hotkey_ss58: The SS58 address of the hotkey. :param coldkey_ss58: The SS58 address of the coldkey. - :param netuid: The subnet ID to filter by. If provided, only returns stake for this specific - subnet. + :param netuid: The subnet ID for the stake query. :param block_hash: The block hash at which to query the stake information. :return: Balance: The stake under the coldkey - hotkey pairing. """ - alpha_shares, hotkey_alpha, hotkey_shares = await asyncio.gather( - self.query( - module="SubtensorModule", - storage_function="Alpha", - params=[hotkey_ss58, coldkey_ss58, netuid], - block_hash=block_hash, - ), - self.query( - module="SubtensorModule", - storage_function="TotalHotkeyAlpha", - params=[hotkey_ss58, netuid], - block_hash=block_hash, - ), - self.query( - module="SubtensorModule", - storage_function="TotalHotkeyShares", - params=[hotkey_ss58, netuid], - block_hash=block_hash, - ), + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_info_for_hotkey_coldkey_netuid", + params=[hotkey_ss58, coldkey_ss58, netuid], + block_hash=block_hash, ) - - alpha_shares_as_float = fixed_to_float(alpha_shares or 0) - hotkey_shares_as_float = fixed_to_float(hotkey_shares or 0) - - if hotkey_shares_as_float == 0: - return Balance.from_rao(0).set_unit(netuid=netuid) - - stake = alpha_shares_as_float / hotkey_shares_as_float * (hotkey_alpha or 0) - - return Balance.from_rao(int(stake)).set_unit(netuid=netuid) + return StakeInfo.from_any(result).stake # Alias get_stake = get_stake_for_coldkey_and_hotkey @@ -1248,6 +1225,9 @@ async def create_signed(call_to_sign, n): ) inner_hash = "" if mev_protection: + max_mev_era = 8 + if era is None or era["period"] > max_mev_era: + era = {"period": max_mev_era} next_nonce = await self.substrate.get_account_next_index( keypair.ss58_address ) @@ -1289,6 +1269,11 @@ async def create_signed(call_to_sign, n): return False, format_error_message(await response.error_message), None except SubstrateRequestException as e: err_msg = format_error_message(e) + if mev_protection and "'result': 'invalid'" in str(e).lower(): + err_msg = ( + f"MEV Shield extrinsic rejected as invalid. " + f"This usually means the MEV Shield NextKey changed between fetching and submission." + ) if proxy and "Invalid Transaction" in err_msg: extrinsic_fee, signer_balance = await asyncio.gather( self.get_extrinsic_fee( @@ -1482,25 +1467,6 @@ async def get_delegate_identities( return all_delegates_details - async def get_stake_for_coldkey_and_hotkey_on_netuid( - self, - hotkey_ss58: str, - coldkey_ss58: str, - netuid: int, - block_hash: Optional[str] = None, - ) -> "Balance": - """Returns the stake under a coldkey - hotkey - netuid pairing""" - _result = await self.query( - "SubtensorModule", - "Alpha", - [hotkey_ss58, coldkey_ss58, netuid], - block_hash, - ) - if _result is None: - return Balance(0).set_unit(netuid) - else: - return Balance.from_rao(fixed_to_float(_result)).set_unit(int(netuid)) - async def get_mechagraph_info( self, netuid: int, mech_id: int, block_hash: Optional[str] = None ) -> Optional[MetagraphInfo]: @@ -1805,30 +1771,119 @@ async def sim_swap( destination_netuid, ) - async def get_scheduled_coldkey_swap( + async def get_coldkey_swap_announcements( self, block_hash: Optional[str] = None, reuse_block: bool = False, - ) -> Optional[list[str]]: + ) -> list[ColdkeySwapAnnouncementInfo]: + """Fetches all pending coldkey swap announcements. + + Args: + block_hash: Block hash at which to perform query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + A list of ColdkeySwapAnnouncementInfo for all pending announcements. """ - Queries the chain to fetch the list of coldkeys that are scheduled for a swap. + result = await self.substrate.query_map( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) - :param block_hash: Block hash at which to perform query. - :param reuse_block: Whether to reuse the last-used block hash. + announcements = [] + async for ss58, data in result: + coldkey = decode_account_id(ss58) + announcements.append( + ColdkeySwapAnnouncementInfo._fix_decoded(coldkey, data) + ) + return announcements + + async def get_coldkey_swap_announcement( + self, + coldkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional[ColdkeySwapAnnouncementInfo]: + """Fetches a pending coldkey swap announcement for a specific coldkey. + + Args: + coldkey_ss58: The SS58 address of the coldkey to query. + block_hash: Block hash at which to perform query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + ColdkeySwapAnnouncementInfo if an announcement exists, None otherwise. + """ + result = await self.query( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + if result is None: + return None + + return ColdkeySwapAnnouncementInfo._fix_decoded(coldkey_ss58, result) + + async def get_coldkey_swap_disputes( + self, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list[tuple[str, int]]: + """Fetch all coldkey swap disputes. + + Args: + block_hash: Optional block hash at which to query storage. + reuse_block: Whether to reuse the last-used block hash. - :return: A list of SS58 addresses of the coldkeys that are scheduled for a coldkey swap. + Returns: + list[tuple[str, int]]: Tuples of `(coldkey_ss58, disputed_block)`. """ result = await self.substrate.query_map( module="SubtensorModule", - storage_function="ColdkeySwapScheduled", + storage_function="ColdkeySwapDisputes", block_hash=block_hash, reuse_block_hash=reuse_block, ) - keys_pending_swap = [] - async for ss58, _ in result: - keys_pending_swap.append(decode_account_id(ss58)) - return keys_pending_swap + disputes: list[tuple[str, int]] = [] + async for ss58, data in result: + coldkey = decode_account_id(ss58) + disputes.append((coldkey, data.value)) + return disputes + + async def get_coldkey_swap_dispute( + self, + coldkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional[int]: + """Fetch the disputed block for a given coldkey swap. + + Args: + coldkey_ss58: Coldkey SS58 address. + block_hash: Optional block hash at which to query storage. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + int | None: Block number when disputed, or None if no dispute exists. + """ + result = await self.query( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + if result is None: + return None + + return int(result) async def get_crowdloans( self, block_hash: Optional[str] = None @@ -1962,24 +2017,53 @@ async def get_crowdloan_contributors( return contributor_contributions - async def get_coldkey_swap_schedule_duration( + async def get_coldkey_swap_announcement_delay( self, block_hash: Optional[str] = None, reuse_block: bool = False, ) -> int: + """Retrieves the delay (in blocks) before a coldkey swap can be executed. + + This is the time the user must wait after announcing a coldkey swap + before they can execute the swap. + + Args: + block_hash: The hash of the blockchain block number for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The number of blocks to wait after announcement. """ - Retrieves the duration (in blocks) required for a coldkey swap to be executed. + result = await self.query( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncementDelay", + params=[], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + return result + + async def get_coldkey_swap_reannouncement_delay( + self, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Retrieves the delay (in blocks) before the user can reannounce a coldkey swap. + + If the user has already announced a swap, they must wait this many blocks + after the original execution block before they can announce a new swap. Args: block_hash: The hash of the blockchain block number for the query. reuse_block: Whether to reuse the last-used blockchain block hash. Returns: - int: The number of blocks required for the coldkey swap schedule duration. + The number of blocks to wait before reannouncing. """ result = await self.query( module="SubtensorModule", - storage_function="ColdkeySwapScheduleDuration", + storage_function="ColdkeySwapReannouncementDelay", params=[], block_hash=block_hash, reuse_block_hash=reuse_block, @@ -1987,6 +2071,30 @@ async def get_coldkey_swap_schedule_duration( return result + async def get_coldkey_swap_cost( + self, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Balance: + """Retrieves the fee required to announce a coldkey swap. + + Args: + block_hash: Block hash at which to query the constant. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The swap cost as a Balance object. Returns 0 TAO if constant not found. + """ + swap_cost = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="KeySwapCost", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + if swap_cost is None: + return None + return Balance.from_rao(swap_cost.value) + async def get_coldkey_claim_type( self, coldkey_ss58: str, @@ -2239,7 +2347,7 @@ async def get_claimable_stake_for_netuid( After manual claim, claimable (available) stake will be added to subnet stake. """ root_stake, root_claimable_rate, root_claimed = await asyncio.gather( - self.get_stake_for_coldkey_and_hotkey_on_netuid( + self.get_stake( coldkey_ss58=coldkey_ss58, hotkey_ss58=hotkey_ss58, netuid=0, diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 53a4e4191..9851a9016 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -25,6 +25,7 @@ from numpy.typing import NDArray from rich.console import Console from rich.prompt import Confirm, Prompt +from rich.table import Table from scalecodec import GenericCall from scalecodec.utils.ss58 import ss58_encode, ss58_decode import typer @@ -89,6 +90,63 @@ def confirm_action( return Confirm.ask(message, default=default) +def create_table(*columns, title: str = "", **overrides) -> Table: + """ + Creates a Rich Table with consistent CLI styling. + + Default styling: no edge borders, bold white headers, bright black borders, + footer enabled, center-aligned title, and no lines between rows. + + Args: + *columns: Optional Column objects to add to the table upfront. + title: Table title with rich markup support. + **overrides: Any Table() parameter to override defaults (e.g., show_footer, + border_style, box, expand). + + Returns: + Configured Rich Table ready for adding columns/rows. + + Examples: + Basic usage (add columns later): + >>> table = create_table(title="My Subnets") + >>> table.add_column("Netuid", justify="center") + >>> table.add_row("1") + + With Column objects upfront: + >>> from rich.table import Column + >>> table = create_table( + ... Column("Name", justify="left"), + ... Column("Value", justify="right"), + ... title="Settings" + ... ) + >>> table.add_row("Timeout", "30s") + + Custom styling: + >>> from rich import box + >>> table = create_table( + ... title="Custom", + ... border_style="blue", + ... box=box.ROUNDED + ... ) + """ + defaults = { + "title": title, + "show_footer": True, + "show_edge": False, + "header_style": "bold white", + "border_style": "bright_black", + "style": "bold", + "title_justify": "center", + "show_lines": False, + "pad_edge": True, + } + + # Merge overrides into defaults + config = {**defaults, **overrides} + + return Table(*columns, **config) + + jinja_env = Environment( loader=PackageLoader("bittensor_cli", "src/bittensor/templates"), autoescape=select_autoescape(), @@ -897,7 +955,7 @@ def normalize_hyperparameters( norm_value = norm_value.to_dict() else: norm_value = value - except Exception: + except (KeyError, ValueError, TypeError, AttributeError): # bittensor.logging.warning(f"Error normalizing parameter '{param}': {e}") norm_value = "-" if not json_output: @@ -1699,8 +1757,9 @@ def prompt_for_subnet_identity( "github_repo", "[blue]GitHub repository URL [dim](optional)[/blue]", github_repo, - lambda x: x - and (not is_valid_github_url(x) or len(x.encode("utf-8")) > 1024), + lambda x: ( + x and (not is_valid_github_url(x) or len(x.encode("utf-8")) > 1024) + ), "[red]Error:[/red] Please enter a valid GitHub repository URL (e.g., https://github.com/username/repo).", ), ( @@ -1785,7 +1844,7 @@ def is_valid_github_url(url: str) -> bool: return False return True - except Exception: # TODO figure out the exceptions that can be raised in here + except (ValueError, TypeError, AttributeError): return False diff --git a/bittensor_cli/src/commands/proxy.py b/bittensor_cli/src/commands/proxy.py index 8852fedf1..67c00bd0c 100644 --- a/bittensor_cli/src/commands/proxy.py +++ b/bittensor_cli/src/commands/proxy.py @@ -255,8 +255,8 @@ async def create_proxy( async def remove_proxy( subtensor: "SubtensorInterface", wallet: "Wallet", - proxy_type: ProxyType, - delegate: str, + proxy_type: Optional[ProxyType], + delegate: Optional[str], delay: int, prompt: bool, decline: bool, @@ -265,18 +265,35 @@ async def remove_proxy( wait_for_finalization: bool, period: int, json_output: bool, + remove_all: bool = False, ) -> None: """ - Executes the remove proxy call on the chain + Executes the remove proxy call on the chain. + + If remove_all is True, removes all proxies for the account. + Otherwise, removes a specific proxy identified by delegate, proxy_type, and delay. """ + # Handle confirmation prompt if prompt: - if not confirm_action( - f"This will remove a proxy of type {proxy_type.value} for delegate {delegate}." - f"Do you want to proceed?", - decline=decline, - quiet=quiet, - ): - return None + if remove_all: + confirmation = Prompt.ask( + "[red]WARNING:[/red] This will remove ALL proxies associated with this account.\n" + "[red]All proxy relationships will be permanently lost.[/red]\n" + "To proceed, enter [red]REMOVE[/red]" + ) + if confirmation != "REMOVE": + print_error("Invalid input. Operation cancelled.") + return None + else: + if not confirm_action( + f"This will remove a proxy of type {proxy_type.value} for delegate {delegate}. " + f"Do you want to proceed?", + decline=decline, + quiet=quiet, + ): + return None + + # Unlock wallet if not (ulw := unlock_key(wallet, print_out=not json_output)).success: if not json_output: print_error(ulw.message) @@ -289,15 +306,25 @@ async def remove_proxy( } ) 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, - }, - ) + + # Compose the appropriate call + if remove_all: + call = await subtensor.substrate.compose_call( + call_module="Proxy", + call_function="remove_proxies", + call_params={}, + ) + else: + 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, diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 2379a62aa..2172de8ac 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -11,12 +11,12 @@ 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 ( confirm_action, console, + create_table, get_hotkey_wallets_for_wallet, is_valid_ss58_address, print_error, @@ -166,11 +166,9 @@ async def safe_stake_extrinsic( 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_, ) @@ -258,11 +256,9 @@ async def stake_extrinsic( 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_, ) @@ -652,19 +648,11 @@ def _define_stake_table( Returns: Table: An initialized rich Table object with appropriate columns """ - table = Table( + table = create_table( title=f"\n[{COLOR_PALETTE.G.HEADER}]Staking to:\n" f"Wallet: [{COLOR_PALETTE.G.CK}]{wallet.name}[/{COLOR_PALETTE.G.CK}], " f"Coldkey ss58: [{COLOR_PALETTE.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE.G.CK}]\n" f"Network: {subtensor.network}[/{COLOR_PALETTE.G.HEADER}]\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column("Netuid", justify="center", style="grey89") diff --git a/bittensor_cli/src/commands/stake/auto_staking.py b/bittensor_cli/src/commands/stake/auto_staking.py index 3d7888321..0afee7bce 100644 --- a/bittensor_cli/src/commands/stake/auto_staking.py +++ b/bittensor_cli/src/commands/stake/auto_staking.py @@ -4,12 +4,12 @@ from bittensor_wallet import Wallet from rich import box -from rich.table import Table from bittensor_cli.src import COLOR_PALETTE from bittensor_cli.src.bittensor.utils import ( confirm_action, console, + create_table, json_console, print_success, get_subnet_name, @@ -127,7 +127,7 @@ def resolve_identity(hotkey: str) -> Optional[str]: json_console.print(json.dumps(data_output)) return data_output - table = Table( + table = create_table( title=( f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Auto Stake Destinations" f" for [bold]{coldkey_display}[/bold]\n" @@ -135,13 +135,6 @@ def resolve_identity(hotkey: str) -> Optional[str]: f"Coldkey: {coldkey_ss58}\n" f"[/{COLOR_PALETTE['GENERAL']['HEADER']}]" ), - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, box=box.SIMPLE_HEAD, ) @@ -214,18 +207,11 @@ async def set_auto_stake_destination( hotkey_identity = delegate_info.display if prompt_user and not json_output: - table = Table( + table = create_table( title=( f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Confirm Auto-Stake Destination" f"[/{COLOR_PALETTE['GENERAL']['HEADER']}]" ), - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, box=box.SIMPLE_HEAD, ) table.add_column( diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index a2a554578..24e5dbaa7 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -167,9 +167,11 @@ def create_table( root_stakes = [s for s in substakes_ if s.netuid == 0] other_stakes = sorted( [s for s in substakes_ if s.netuid != 0], - key=lambda x: dynamic_info[x.netuid] - .alpha_to_tao(Balance.from_rao(int(x.stake.rao)).set_unit(x.netuid)) - .tao, + key=lambda x: ( + dynamic_info[x.netuid] + .alpha_to_tao(Balance.from_rao(int(x.stake.rao)).set_unit(x.netuid)) + .tao + ), reverse=True, ) sorted_substakes = root_stakes + other_stakes @@ -328,9 +330,11 @@ def format_cell( root_stakes = [s for s in substakes if s.netuid == 0] other_stakes = sorted( [s for s in substakes if s.netuid != 0], - key=lambda x: dynamic_info_for_lt[x.netuid] - .alpha_to_tao(Balance.from_rao(int(x.stake.rao)).set_unit(x.netuid)) - .tao, + key=lambda x: ( + dynamic_info_for_lt[x.netuid] + .alpha_to_tao(Balance.from_rao(int(x.stake.rao)).set_unit(x.netuid)) + .tao + ), reverse=True, ) sorted_substakes = root_stakes + other_stakes diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 402bd4f26..b7b28c213 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -4,18 +4,17 @@ from typing import TYPE_CHECKING, Optional from bittensor_wallet import Wallet -from rich.table import Table from rich.prompt import Prompt 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 ( confirm_action, console, + create_table, print_error, group_subnets, get_subnet_name, @@ -167,7 +166,7 @@ async def display_stake_movement_cross_subnets( ) # Create and display table - table = Table( + table = create_table( title=( f"\n[{COLOR_PALETTE.G.HEADER}]" f"Moving stake from: " @@ -178,14 +177,6 @@ async def display_stake_movement_cross_subnets( f"[/{COLOR_PALETTE.G.SUBHEAD}]\nNetwork: {subtensor.network}\n" f"[/{COLOR_PALETTE.G.HEADER}]" ), - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column( @@ -352,16 +343,8 @@ async def stake_move_transfer_selection( raise ValueError # Display hotkeys with stakes - table = Table( + table = create_table( title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Hotkeys with Stakes\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column("Index", justify="right") table.add_column("Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]) @@ -405,13 +388,12 @@ async def stake_move_transfer_selection( origin_hotkey_ss58 = origin_hotkey_info["hotkey_ss58"] # Display available netuids for selected hotkey - table = Table( + table = create_table( title=f"\n[{COLOR_PALETTE.G.HEADER}]Available Stakes for Hotkey\n[/{COLOR_PALETTE.G.HEADER}]" f"[{COLOR_PALETTE.G.HK}]{origin_hotkey_ss58}[/{COLOR_PALETTE.G.HK}]\n", - show_edge=False, - header_style="bold white", - border_style="bright_black", - title_justify="center", + show_footer=False, + show_lines=True, + pad_edge=False, width=len(origin_hotkey_ss58) + 20, ) table.add_column("Netuid", style="cyan") @@ -483,13 +465,12 @@ async def stake_swap_selection( raise ValueError # Display available stakes - table = Table( + table = create_table( title=f"\n[{COLOR_PALETTE.G.HEADER}]Available Stakes for Hotkey\n[/{COLOR_PALETTE.G.HEADER}]" f"[{COLOR_PALETTE.G.HK}]{wallet.hotkey_str}: {hotkey_ss58}[/{COLOR_PALETTE.G.HK}]\n", - show_edge=False, - header_style="bold white", - border_style="bright_black", - title_justify="center", + show_footer=False, + show_lines=True, + pad_edge=False, width=len(hotkey_ss58) + 20, ) @@ -709,11 +690,9 @@ async def move_stake( 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, ) @@ -927,11 +906,9 @@ async def transfer_stake( 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, ) @@ -1165,11 +1142,9 @@ async def swap_stake( 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, ) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index bb8faceb5..84b753c40 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -11,13 +11,13 @@ 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 ( confirm_action, console, + create_table, print_success, print_verbose, print_error, @@ -450,21 +450,13 @@ async def unstake_all( if not unstake_all_alpha else "Unstaking Summary - All Alpha Stakes" ) - table = Table( + table = create_table( title=( 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, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column("Netuid", justify="center", style="grey89") table.add_column( @@ -649,11 +641,9 @@ async def _unstake_extrinsic( 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, ) @@ -764,11 +754,9 @@ async def _safe_unstake_extrinsic( 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, ) @@ -894,11 +882,9 @@ async def _unstake_all_extrinsic( 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, ) @@ -1056,16 +1042,8 @@ 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( + table = create_table( title=f"\n[{COLOR_PALETTE.G.HEADER}]Hotkeys with Stakes{subnet_filter}\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column("Index", justify="right") table.add_column("Identity", style=COLOR_PALETTE.G.SUBHEAD) @@ -1093,18 +1071,10 @@ async def _unstake_selection( netuid_stakes = hotkey_stakes[selected_hotkey_ss58] # Display hotkey's staked netuids with amount. - table = Table( + table = create_table( title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Stakes for hotkey \n" f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{selected_hotkey_name}\n" f"{selected_hotkey_ss58}\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column("Subnet", justify="right") table.add_column("Symbol", style=COLOR_PALETTE["GENERAL"]["SYMBOL"]) @@ -1342,16 +1312,8 @@ def _create_unstake_table( 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( + table = create_table( title=title, - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column("Netuid", justify="center", style="grey89") diff --git a/bittensor_cli/src/commands/stake/wizard.py b/bittensor_cli/src/commands/stake/wizard.py index f1886f65e..1b11f93c3 100644 --- a/bittensor_cli/src/commands/stake/wizard.py +++ b/bittensor_cli/src/commands/stake/wizard.py @@ -10,12 +10,12 @@ 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, + create_table, print_error, is_valid_ss58_address, get_hotkey_pub_ss58, @@ -159,12 +159,11 @@ def get_identity(hotkey_ss58_: str) -> str: return old_identity.display return "~" - table = Table( + table = create_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", + show_footer=False, + show_lines=True, + pad_edge=False, ) table.add_column("Hotkey Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]) diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 57a247103..fcbc7dc03 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -20,7 +20,6 @@ ) 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 @@ -30,6 +29,7 @@ confirm_action, console, create_and_populate_table, + create_table, print_success, print_verbose, print_error, @@ -271,11 +271,9 @@ async def _find_event_attributes_in_extrinsic_receipt( # 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, ) @@ -364,17 +362,9 @@ def define_table( tao_emission_percentage: str, total_tao_flow_ema: float, ): - defined_table = Table( + defined_table = create_table( title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnets" f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}\n\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) defined_table.add_column( @@ -602,7 +592,7 @@ def dict_table(subnets_, block_number_, mechanisms, ema_tao_inflow) -> dict: tao_flow_ema = None if netuid in ema_tao_inflow: tao_flow_ema = ema_tao_inflow[netuid].tao - total_tao_flow_ema += tao_flow_ema.tao + total_tao_flow_ema += tao_flow_ema subnet_rows[netuid] = { "netuid": netuid, "subnet_name": subnet_name, @@ -1095,17 +1085,9 @@ async def show_root(): tao_sum = sum(root_state.tao_stake).tao - table = Table( + table = create_table( title=f"[{COLOR_PALETTE.G.HEADER}]Root Network\n[{COLOR_PALETTE.G.SUBHEAD}]" f"Network: {subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column("[bold white]Position", style="white", justify="center") @@ -1340,18 +1322,10 @@ async def show_subnet( # Define table properties mechanism_label = f"Mechanism {selected_mechanism_id}" - table = Table( + table = create_table( title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnet [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid_}" f"{': ' + get_subnet_name(subnet_info)}" f"\n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Network: {subtensor.network} โ€ข {mechanism_label}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) # For table footers @@ -1892,22 +1866,13 @@ async def _storage_key(storage_fn: str) -> StorageKey: return if prompt and not json_output: - # TODO make this a reusable function, also used in subnets list # Show creation table. - table = Table( + table = create_table( 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", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) table.add_column( "Netuid", style="rgb(253,246,227)", no_wrap=True, justify="center" @@ -2476,16 +2441,8 @@ async def metagraph_cmd( table_cols_indices.append(idx) table_cols.append(v) - table = Table( + table = create_table( *table_cols, - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_style="bold white", - title_justify="center", - show_lines=False, expand=True, title=( f"[underline dark_orange]Metagraph[/underline dark_orange]\n\n" @@ -2496,7 +2453,6 @@ async def metagraph_cmd( f"Issuance: [bright_blue]{metadata_info['issuance']}[/bright_blue], " f"Difficulty: [bright_cyan]{metadata_info['difficulty']}[/bright_cyan]\n" ), - pad_edge=True, ) if all(x is False for x in display_cols.values()): @@ -2521,7 +2477,7 @@ def create_identity_table(title: str = None): if not title: title = "Subnet Identity" - table = Table( + table = create_table( Column( "Item", justify="right", @@ -2530,14 +2486,6 @@ def create_identity_table(title: str = None): ), Column("Value", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]), title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{title}\n", - show_footer=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, ) return table diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index ec6d1461c..6fb51e4bb 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -18,10 +18,15 @@ DelegatesDetails, COLOR_PALETTE, ) +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.extrinsics.mev_shield import ( + wait_for_extrinsic_by_hash, +) from bittensor_cli.src.bittensor.chain_data import decode_account_id from bittensor_cli.src.bittensor.utils import ( confirm_action, console, + create_table, print_error, print_success, print_verbose, @@ -94,6 +99,234 @@ def string_to_bool(val) -> Union[bool, Type[ValueError]]: return ValueError +async def stake_burn( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + amount: float, + hotkey_ss58: Optional[str], + safe_staking: bool, + proxy: Optional[str], + rate_tolerance: Optional[float], + mev_protection: bool, + json_output: bool, + prompt: bool, + decline: bool, + quiet: bool, + period: int, +) -> bool: + """ + Perform a stake burn (owner-only). + Stakes TAO into the subnet and immediately burns the acquired alpha. + """ + subnet_owner = await subtensor.query( + module="SubtensorModule", + storage_function="SubnetOwner", + params=[netuid], + ) + if subnet_owner != wallet.coldkeypub.ss58_address: + err_msg = ( + f"Coldkey {wallet.coldkeypub.ss58_address} does not own subnet {netuid}." + ) + if json_output: + json_console.print_json( + data={ + "success": False, + "message": err_msg, + "extrinsic_identifier": None, + } + ) + else: + print_error(err_msg) + return False + + subnet_info = await subtensor.subnet(netuid=netuid) + stake_burn_amount = Balance.from_tao(amount) + rate_tolerance = rate_tolerance if rate_tolerance is not None else 0.0 + + price_limit: Optional[Balance] = None + if safe_staking: + price_limit = Balance.from_tao(subnet_info.price.tao * (1 + rate_tolerance)) + + call_params = { + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount": stake_burn_amount.rao, + "limit": price_limit.rao if price_limit else None, + } + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake_burn", + call_params=call_params, + ) + + if not json_output: + extrinsic_fee = await subtensor.get_extrinsic_fee( + call, wallet.coldkeypub, proxy=proxy + ) + amount_minus_fee = stake_burn_amount - extrinsic_fee + sim_swap = await subtensor.sim_swap( + origin_netuid=0, + destination_netuid=netuid, + amount=amount_minus_fee.rao, + ) + + received_amount = sim_swap.alpha_amount + current_price_float = subnet_info.price.tao + rate = 1.0 / current_price_float + + table = _define_stake_burn_table( + wallet=wallet, + subtensor=subtensor, + safe_staking=safe_staking, + rate_tolerance=rate_tolerance, + ) + row = [ + str(netuid), + hotkey_ss58, + str(stake_burn_amount), + str(rate) + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", + str(received_amount.set_unit(netuid)), + str(sim_swap.tao_fee), + str(extrinsic_fee), + ] + if safe_staking: + price_with_tolerance = current_price_float * (1 + rate_tolerance) + rate_with_tolerance = 1.0 / price_with_tolerance + rate_with_tolerance_str = ( + f"{rate_with_tolerance:.4f} " + f"{Balance.get_unit(netuid)}/{Balance.get_unit(0)} " + ) + row.append(rate_with_tolerance_str) + + table.add_row(*row) + console.print(table) + + if prompt and not confirm_action( + "Would you like to continue?", decline=decline, quiet=quiet + ): + print_error("User aborted.") + return False + + if not unlock_key(wallet).success: + return False + + with console.status( + f":satellite: Performing subnet stake burn on [bold]{netuid}[/bold]...", + spinner="earth", + ) as status: + next_nonce = await subtensor.substrate.get_account_next_index( + wallet.coldkeypub.ss58_address + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + nonce=next_nonce, + era={"period": period}, + proxy=proxy, + mev_protection=mev_protection, + ) + + if not success: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": err_msg, + "extrinsic_identifier": None, + } + ) + else: + print_error(err_msg, status=status) + return False + + if mev_protection: + inner_hash = err_msg + mev_success, mev_error, ext_receipt = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=ext_receipt.block_hash, + status=status, + ) + if not mev_success: + status.stop() + if json_output: + json_console.print_json( + data={ + "success": False, + "message": mev_error, + "extrinsic_identifier": None, + } + ) + else: + print_error(mev_error, status=status) + return False + + ext_id = await ext_receipt.get_extrinsic_identifier() + + msg = f"Subnet stake burn succeeded on SN{netuid}." + if json_output: + json_console.print_json( + data={"success": True, "message": msg, "extrinsic_identifier": ext_id} + ) + else: + await print_extrinsic_id(ext_receipt) + print_success(msg) + + return True + + +def _define_stake_burn_table( + wallet: Wallet, + subtensor: "SubtensorInterface", + safe_staking: bool, + rate_tolerance: float, +) -> Table: + table = create_table( + title=f"\n[{COLOR_PALETTE.G.HEADER}]Subnet Buyback:\n" + f"Wallet: [{COLOR_PALETTE.G.CK}]{wallet.name}[/{COLOR_PALETTE.G.CK}], " + f"Coldkey ss58: [{COLOR_PALETTE.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE.G.CK}]\n" + f"Network: {subtensor.network}[/{COLOR_PALETTE.G.HEADER}]\n", + ) + table.add_column("Netuid", justify="center", style="grey89") + table.add_column( + "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + ) + table.add_column( + "Amount (ฯ„)", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO"], + ) + table.add_column( + "Rate (per ฯ„)", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + "Est. Burned", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], + ) + table.add_column( + "Fee (ฯ„)", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + ) + table.add_column( + "Extrinsic Fee (ฯ„)", + justify="center", + style=COLOR_PALETTE.STAKE.TAO, + ) + if safe_staking: + table.add_column( + f"Rate with tolerance: [blue]({rate_tolerance * 100}%)[/blue]", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + return table + + def search_metadata( param_name: str, value: Union[str, bool, float, list[float]], @@ -126,8 +359,19 @@ def type_converter_with_retry(type_, val, arg_name): return arg_types[type_](val) except ValueError: return type_converter_with_retry(type_, None, arg_name) + except KeyError: + print_error( + f"Type {type_} is not recognized. " + "You will be unable to set this parameter via this command.\n" + "Some hyperparams must be set by their dedicated command, such as `btcli subnets mech`" + ) - arg_types = {"bool": string_to_bool, "u16": string_to_u16, "u64": string_to_u64} + arg_types = { + "bool": string_to_bool, + "u16": string_to_u16, + "u64": string_to_u64, + "MechId": int, + } arg_type_output = {"bool": "bool", "u16": "float", "u64": "float"} call_crafter = {"netuid": netuid} diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 1b15ff2c8..03293a611 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import itertools import json import os @@ -15,31 +16,31 @@ from rich.table import Column, Table from rich.tree import Tree from rich.padding import Padding -from bittensor_cli.src import COLOR_PALETTE, COLORS, Constants +from bittensor_cli.src import COLOR_PALETTE, COLORS from bittensor_cli.src.bittensor import utils from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.chain_data import ( DelegateInfo, NeuronInfoLite, ) +from bittensor_cli.src.bittensor.extrinsics.mev_shield import ( + wait_for_extrinsic_by_hash, +) from bittensor_cli.src.bittensor.extrinsics.registration import ( run_faucet_extrinsic, swap_hotkey_extrinsic, ) from bittensor_cli.src.bittensor.extrinsics.transfer import transfer_extrinsic from bittensor_cli.src.bittensor.networking import int_to_ip -from bittensor_cli.src.bittensor.subtensor_interface import ( - SubtensorInterface, - GENESIS_ADDRESS, -) +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.bittensor.utils import ( RAO_PER_TAO, confirm_action, console, - convert_blocks_to_time, json_console, print_error, print_verbose, + print_success, get_all_wallets_for_path, get_hotkey_wallets_for_wallet, is_valid_ss58_address, @@ -49,7 +50,6 @@ unlock_key, WalletLike, blocks_to_duration, - decode_account_id, get_hotkey_pub_ss58, print_extrinsic_id, ) @@ -374,7 +374,7 @@ async def new_hotkey( if uri: try: keypair = Keypair.create_from_uri(uri) - except Exception as e: + except TypeError as e: print_error(f"Failed to create keypair from URI {uri}: {str(e)}") return wallet.set_hotkey(keypair=keypair, encrypt=use_password) @@ -425,7 +425,7 @@ async def new_coldkey( if uri: try: keypair = Keypair.create_from_uri(uri) - except Exception as e: + except TypeError as e: print_error(f"Failed to create keypair from URI {uri}: {str(e)}") wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=False) wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=False) @@ -498,7 +498,7 @@ async def wallet_create( "hotkey_ss58": wallet.hotkeypub.ss58_address, "coldkey_ss58": wallet.coldkeypub.ss58_address, } - except Exception as e: + except (ValueError, TypeError, KeyFileError) as e: err = f"Failed to create keypair from URI: {str(e)}" print_error(err) output_dict["error"] = err @@ -1766,11 +1766,16 @@ async def swap_hotkey( return result -def create_identity_table(title: str = None): - if not title: - title = "On-Chain Identity" +def create_key_value_table(title: str = "Details") -> Table: + """Creates a key-value table for displaying information for various cmds. - table = Table( + Args: + title: The title shown above the table. + + Returns: + A Rich Table for key-value display. + """ + return Table( Column( "Item", justify="right", @@ -1788,7 +1793,6 @@ def create_identity_table(title: str = None): show_lines=False, pad_edge=True, ) - return table async def set_id( @@ -1845,7 +1849,7 @@ async def set_id( output_dict["success"] = True identity = await subtensor.query_identity(wallet.coldkeypub.ss58_address) - table = create_identity_table(title="New on-chain Identity") + table = create_key_value_table(title="New on-chain Identity\n") table.add_row("Address", wallet.coldkeypub.ss58_address) for key, value in identity.items(): table.add_row(key, str(value) if value else "~") @@ -1879,7 +1883,7 @@ async def get_id( json_console.print("{}") return {} - table = create_identity_table(title) + table = create_key_value_table(title) table.add_row("Address", ss58_address) for key, value in identity.items(): table.add_row(key, str(value) if value else "~") @@ -1890,43 +1894,6 @@ async def get_id( return identity -async def check_coldkey_swap(wallet: Wallet, subtensor: SubtensorInterface): - arbitration_check = len( # TODO verify this works - ( - await subtensor.query( - module="SubtensorModule", - storage_function="ColdkeySwapDestinations", - params=[wallet.coldkeypub.ss58_address], - ) - ) - ) - if arbitration_check == 0: - console.print( - "[green]There has been no previous key swap initiated for your coldkey.[/green]" - ) - elif arbitration_check == 1: - arbitration_block = await subtensor.query( - module="SubtensorModule", - storage_function="ColdkeyArbitrationBlock", - params=[wallet.coldkeypub.ss58_address], - ) - arbitration_remaining = ( - arbitration_block.value - await subtensor.substrate.get_block_number(None) - ) - - hours, minutes, seconds = convert_blocks_to_time(arbitration_remaining) - console.print( - "[yellow]There has been 1 swap request made for this coldkey already." - " By adding another swap request, the key will enter arbitration." - f" Your key swap is scheduled for {hours} hours, {minutes} minutes, {seconds} seconds" - " from now.[/yellow]" - ) - elif arbitration_check > 1: - console.print( - f"[red]This coldkey is currently in arbitration with a total swaps of {arbitration_check}.[/red]" - ) - - async def sign( wallet: Wallet, message: str, use_hotkey: bool, json_output: bool = False ): @@ -2040,272 +2007,615 @@ async def verify( return is_valid -async def schedule_coldkey_swap( +def compute_coldkey_hash(ss58_address: str) -> str: + """ + Compute Blake2b-256 hash of a coldkey AccountId. + + Args: + ss58_address: SS58 address of the coldkey. + + Returns: + str: 0x-prefixed hex hash. + + Notes: + Hashes AccountId bytes (not the SS58). Used by coldkey-swap announcements. + """ + keypair = Keypair(ss58_address=ss58_address) + public_key_bytes = keypair.public_key + + hash_result = hashlib.blake2b(public_key_bytes, digest_size=32) + return "0x" + hash_result.hexdigest() + + +async def announce_coldkey_swap( wallet: Wallet, subtensor: SubtensorInterface, new_coldkey_ss58: str, - force_swap: bool = False, decline: bool = False, quiet: bool = False, - proxy: Optional[str] = None, + prompt: bool = True, + mev_protection: bool = False, ) -> bool: - """Schedules a coldkey swap operation to be executed at a future block. + """Announces intent to swap a coldkey to a new address. + + This is the first step of a two-step coldkey swap process. After announcing, + the user must wait for the announcement delay period to pass before executing + the swap with execute_coldkey_swap. Args: - wallet (Wallet): The wallet initiating the coldkey swap - subtensor (SubtensorInterface): Connection to the Bittensor network - new_coldkey_ss58 (str): SS58 address of the new coldkey - force_swap (bool, optional): Whether to force the swap even if the new coldkey is already scheduled for a swap. Defaults to False. + wallet: The wallet initiating the coldkey swap. + subtensor: Connection to the Bittensor network. + new_coldkey_ss58: SS58 address of the new coldkey. + decline: If True, skip confirmation prompt and decline. + quiet: If True, skip confirmation prompt and proceed. + mev_protection: If True, encrypt the extrinsic with MEV protection. + Returns: - bool: True if the swap was scheduled successfully, False otherwise + True if the announcement was successful, False otherwise. """ if not is_valid_ss58_address(new_coldkey_ss58): print_error(f"Invalid SS58 address format: {new_coldkey_ss58}") return False - scheduled_coldkey_swap = await subtensor.get_scheduled_coldkey_swap() - if wallet.coldkeypub.ss58_address in scheduled_coldkey_swap: - print_error( - f"Coldkey {wallet.coldkeypub.ss58_address} is already scheduled for a swap." - ) - console.print("[dim]Use the force_swap (--force) flag to override this.[/dim]") - if not force_swap: + # Check for existing announcement + block_hash = await subtensor.substrate.get_chain_head() + new_coldkey_hash = compute_coldkey_hash(new_coldkey_ss58) + + existing = await subtensor.get_coldkey_swap_announcement( + wallet.coldkeypub.ss58_address, + block_hash=block_hash, + ) + if existing: + current_block, reannounce_delay, announce_delay = await asyncio.gather( + subtensor.substrate.get_block_number(block_hash=block_hash), + subtensor.get_coldkey_swap_reannouncement_delay(block_hash=block_hash), + subtensor.get_coldkey_swap_announcement_delay(block_hash=block_hash), + ) + remaining = existing.execution_block - current_block + reannounce_block = existing.execution_block + reannounce_delay + same_hash = new_coldkey_hash.lower() == str(existing.new_coldkey_hash).lower() + + # Show existing announcement info + table = create_key_value_table("Existing Coldkey Swap Announcement") + table.add_row("Execution Block", str(existing.execution_block)) + if remaining > 0: + table.add_row( + "Time Remaining", f"[yellow]{blocks_to_duration(remaining)}[/yellow]" + ) + table.add_row("Status", "[yellow]Pending[/yellow]") + else: + table.add_row("Status", "[green]Ready to Execute[/green]") + table.add_row("Announced Hash", f"[dim]{existing.new_coldkey_hash}[/dim]") + table.add_row("Requested Hash", f"[dim]{new_coldkey_hash}[/dim]") + table.add_row("Match", "[green]Yes[/green]" if same_hash else "[red]No[/red]") + console.print(table) + + # Check if reannouncement allowed + if current_block < reannounce_block: + time_until_reannounce = blocks_to_duration(reannounce_block - current_block) + console.print( + f"\n[dim]You can reannounce after block {reannounce_block} ({time_until_reannounce} from now).[/dim]", + f"Current block: {current_block}", + ) return False + + # Reannouncement allowed + if same_hash: + console.print( + "\n[yellow]You already have an announcement for this coldkey.[/yellow] " + "You can execute the existing swap without reannouncing." + ) + if prompt and not confirm_action( + "Do you still want to reannounce the same hash (the period to wait before executing the swap will be reset)?", + decline=decline, + quiet=quiet, + ): + return False else: console.print( - "[yellow]Continuing with the swap due to force_swap flag.[/yellow]\n" + f"\n[dim]Reannouncing with a different coldkey will reset the waiting period " + f"to {blocks_to_duration(announce_delay)} from now.[/dim]" ) + if prompt and not confirm_action( + "Proceed with reannouncement and reset the waiting period?", + decline=decline, + quiet=quiet, + ): + return False + + # Proceed with the announcement + swap_cost, delay = await asyncio.gather( + subtensor.get_coldkey_swap_cost(block_hash=block_hash), + subtensor.get_coldkey_swap_announcement_delay(block_hash=block_hash), + ) - prompt_msg = ( - "You are [red]swapping[/red] your [blue]coldkey[/blue] to a new address.\n" - f"Current ss58: [{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]\n" - f"New ss58: [{COLORS.G.CK}]{new_coldkey_ss58}[/{COLORS.G.CK}]\n" - "Are you sure you want to continue?" + table = create_key_value_table("Announcing Coldkey Swap\n") + table.add_row( + "Current Coldkey", + f"[{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]", + ) + table.add_row("New Coldkey", f"[{COLORS.G.CK}]{new_coldkey_ss58}[/{COLORS.G.CK}]") + table.add_row( + "New Coldkey Hash", + f"[dim]{new_coldkey_hash}[/dim]", + ) + table.add_row("Swap Cost", f"[green]{swap_cost}[/green]") + table.add_row( + "Delay Before Execution", f"[yellow]{blocks_to_duration(delay)}[/yellow]" ) - if not confirm_action(prompt_msg, decline=decline, quiet=quiet): + console.print(table) + + if prompt and not confirm_action( + "Are you sure you want to continue?", decline=decline, quiet=quiet + ): return False if not unlock_key(wallet).success: return False - block_pre_call, call = await asyncio.gather( - subtensor.substrate.get_block_number(), - subtensor.substrate.compose_call( + with console.status(":satellite: Announcing coldkey swap on-chain...") as status: + call = await subtensor.substrate.compose_call( call_module="SubtensorModule", - call_function="schedule_swap_coldkey", + call_function="announce_coldkey_swap", call_params={ - "new_coldkey": new_coldkey_ss58, + "new_coldkey_hash": new_coldkey_hash, }, - ), - ) - swap_info = None - with console.status(":satellite: Scheduling coldkey swap on-chain..."): + ) success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion=True, wait_for_finalization=True, - proxy=proxy, + mev_protection=mev_protection, ) - block_post_call = await subtensor.substrate.get_block_number() if not success: - print_error(f"Failed to schedule coldkey swap: {err_msg}") + print_error(f"Failed to announce coldkey swap: {err_msg}") return False - console.print( - ":white_heavy_check_mark: [green]Successfully scheduled coldkey swap" - ) + if mev_protection: + inner_hash = err_msg + mev_success, mev_error, ext_receipt = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=ext_receipt.block_hash, + status=status, + ) + if not mev_success: + print_error( + f"Failed to announce coldkey swap: {mev_error}", status=status + ) + return False + + print_success("[dark_sea_green3]Successfully announced coldkey swap") await print_extrinsic_id(ext_receipt) - for event in await ext_receipt.triggered_events: - if ( - event.get("event", {}).get("module_id") == "SubtensorModule" - and event.get("event", {}).get("event_id") == "ColdkeySwapScheduled" - ): - attributes = event["event"].get("attributes", {}) - old_coldkey = decode_account_id(attributes["old_coldkey"][0]) - - if old_coldkey == wallet.coldkeypub.ss58_address: - swap_info = { - "block_num": block_pre_call, - "dest_coldkey": decode_account_id(attributes["new_coldkey"][0]), - "execution_block": attributes["execution_block"], - } - if not swap_info: - swap_info = await find_coldkey_swap_extrinsic( - subtensor=subtensor, - start_block=block_pre_call, - end_block=block_post_call, - wallet_ss58=wallet.coldkeypub.ss58_address, + # Post-success information + new_block_hash = await subtensor.substrate.get_chain_head() + announcement = await subtensor.get_coldkey_swap_announcement( + wallet.coldkeypub.ss58_address, + block_hash=new_block_hash, + ) + if announcement: + current_block = await subtensor.substrate.get_block_number( + block_hash=new_block_hash ) + remaining = announcement.execution_block - current_block - if not swap_info: + details_table = create_key_value_table("Coldkey Swap Announced\n") + details_table.add_row( + "Original Coldkey", + f"[{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]", + ) + details_table.add_row( + "New Coldkey", f"[{COLORS.G.CK}]{new_coldkey_ss58}[/{COLORS.G.CK}]" + ) + details_table.add_row( + "New Coldkey Hash", + f"[dim]{new_coldkey_hash}[/dim]", + ) + details_table.add_row( + "Execution Block", f"[green]{announcement.execution_block}[/green]" + ) + details_table.add_row( + "Time Until Executable", f"[yellow]{blocks_to_duration(remaining)}[/yellow]" + ) + + console.print(details_table) console.print( - "[yellow]Warning: Could not find the swap extrinsic in recent blocks" + f"\n[dim]After the delay, run:" + f"\n[green]btcli wallet swap-coldkey execute --new-coldkey {new_coldkey_ss58}[/green]" ) - return True - console.print( - "\n[green]Coldkey swap details:[/green]" - f"\nBlock number: {swap_info['block_num']}" - f"\nOriginal address: [{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]" - f"\nDestination address: [{COLORS.G.CK}]{swap_info['dest_coldkey']}[/{COLORS.G.CK}]" - f"\nThe swap will be completed at block: [green]{swap_info['execution_block']}[/green]" - f"\n[dim]You can provide the block number to `btcli wallet swap-check`[/dim]" - ) + return True -async def find_coldkey_swap_extrinsic( +async def dispute_coldkey_swap( + wallet: Wallet, subtensor: SubtensorInterface, - start_block: int, - end_block: int, - wallet_ss58: str, -) -> dict: - """Search for a coldkey swap event in a range of blocks. + decline: bool = False, + quiet: bool = False, + prompt: bool = True, + mev_protection: bool = False, +) -> bool: + """Dispute a pending coldkey swap for the calling coldkey. + + Disputing freezes the current swap process until the triumvirate can intervene. Args: - subtensor: SubtensorInterface for chain queries - start_block: Starting block number to search - end_block: Ending block number to search (inclusive) - wallet_ss58: SS58 address of the signing wallet + wallet: Wallet initiating the dispute (must be the announcing coldkey). + subtensor: Connection to the Bittensor network. + decline: If True, default to declining at confirmation prompt. + quiet: If True, skip confirmation prompts and proceed. + mev_protection: If True, encrypt the extrinsic with MEV protection. Returns: - dict: Contains the following keys if found: - - block_num: Block number where swap was scheduled - - dest_coldkey: SS58 address of destination coldkey - - execution_block: Block number when swap will execute - Empty dict if not found + bool: True if the dispute extrinsic was included successfully, else False. """ + block_hash = await subtensor.substrate.get_chain_head() + announcement, dispute = await asyncio.gather( + subtensor.get_coldkey_swap_announcement( + wallet.coldkeypub.ss58_address, block_hash=block_hash + ), + subtensor.get_coldkey_swap_dispute( + wallet.coldkeypub.ss58_address, block_hash=block_hash + ), + ) + + if not announcement: + print_error( + f"No coldkey swap announcement found for {wallet.coldkeypub.ss58_address}.\n" + "You can only dispute an active announcement." + ) + return False - current_block, genesis_block = await asyncio.gather( - subtensor.substrate.get_block_number(), subtensor.substrate.get_block_hash(0) + if dispute is not None: + console.print( + f"[yellow]Swap already disputed at block {dispute}.[/yellow] " + "Account remains frozen until a root reset." + ) + return False + + current_block = await subtensor.substrate.get_block_number(block_hash=block_hash) + info = create_key_value_table("Dispute Coldkey Swap\n") + info.add_row( + "Coldkey", f"[{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]" ) - if ( - current_block - start_block > 300 - and genesis_block == Constants.genesis_block_hash_map["finney"] - ): - console.print("Querying archive node for coldkey swap events...") - await subtensor.substrate.close() - subtensor.substrate.chain_endpoint = Constants.archive_entrypoint - subtensor.substrate.url = Constants.archive_entrypoint - subtensor.substrate.initialized = False - await subtensor.substrate.initialize() - - block_hashes = await asyncio.gather( - *[ - subtensor.substrate.get_block_hash(block_num) - for block_num in range(start_block, end_block + 1) - ] + info.add_row("Execution Block", str(announcement.execution_block)) + info.add_row( + "Status", + "[yellow]Pending[/yellow]" + if current_block < announcement.execution_block + else "[green]Ready[/green]", ) - block_events = await asyncio.gather( - *[ - subtensor.substrate.get_events(block_hash=block_hash) - for block_hash in block_hashes - ] + info.add_row( + "Warning", + "[red]Disputing freezes the current swap process until the triumvirate can intervene.[/red]", ) + console.print(info) - for block_num, events in zip(range(start_block, end_block + 1), block_events): - for event in events: - if ( - event.get("event", {}).get("module_id") == "SubtensorModule" - and event.get("event", {}).get("event_id") == "ColdkeySwapScheduled" - ): - attributes = event["event"].get("attributes", {}) - old_coldkey = decode_account_id(attributes["old_coldkey"][0]) - - if old_coldkey == wallet_ss58: - return { - "block_num": block_num, - "dest_coldkey": decode_account_id(attributes["new_coldkey"][0]), - "execution_block": attributes["execution_block"], - } + if prompt and not confirm_action( + "Proceed with dispute? Your swap process will be frozen until the triumvirate can intervene.", + decline=decline, + quiet=quiet, + ): + return False - return {} + if not unlock_key(wallet).success: + return False + with console.status(":satellite: Disputing coldkey swap on-chain...") as status: + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="dispute_coldkey_swap", + call_params={}, + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, + wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + mev_protection=mev_protection, + ) -async def check_swap_status( + if not success: + print_error(f"Failed to dispute coldkey swap: {err_msg}") + return False + + if mev_protection: + inner_hash = err_msg + mev_success, mev_error, ext_receipt = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=ext_receipt.block_hash, + status=status, + ) + if not mev_success: + print_error( + f"Failed to dispute coldkey swap: {mev_error}", status=status + ) + return False + + print_success("[dark_sea_green3]Coldkey swap disputed.") + await print_extrinsic_id(ext_receipt) + + console.print( + "\n[dim]Your coldkey is now frozen. The triumvirate will need to intervene to clear the dispute.[/dim]" + ) + return True + + +async def execute_coldkey_swap( + wallet: Wallet, subtensor: SubtensorInterface, - origin_ss58: Optional[str] = None, - expected_block_number: Optional[int] = None, -) -> None: - """ - Check the status of a coldkey swap. + new_coldkey_ss58: str, + decline: bool = False, + quiet: bool = False, + prompt: bool = True, + mev_protection: bool = True, +) -> bool: + """Executes a previously announced coldkey swap. + + This is the second step of a two-step coldkey swap process. You must have + previously called announce_coldkey_swap and waited for the delay period. Args: - subtensor: Connection to the network - origin_ss58: The SS58 address of the original coldkey - expected_block_number: Optional block number where the swap was scheduled + wallet: The wallet executing the coldkey swap. + subtensor: Connection to the Bittensor network. + new_coldkey_ss58: SS58 address of the new coldkey (must match announcement). + decline: If True, skip confirmation prompt and decline. + quiet: If True, skip confirmation prompt and proceed. + mev_protection: If True, encrypt the extrinsic with MEV protection. + Returns: + True if the swap was executed successfully, False otherwise. """ + if not is_valid_ss58_address(new_coldkey_ss58): + print_error(f"Invalid SS58 address format: {new_coldkey_ss58}") + return False - if not origin_ss58: - scheduled_swaps = await subtensor.get_scheduled_coldkey_swap() - if not scheduled_swaps: - console.print("[yellow]No pending coldkey swaps found.[/yellow]") - return + if not mev_protection: + console.print( + "[yellow]WARNING: MEV protection is disabled.\n" + "This transaction is not protected & will expose the new coldkey.[/yellow]" + ) - table = Table( - Column( - "Original Coldkey", - justify="Left", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING_MAIN"], - no_wrap=True, - ), - Column("Status", style="dark_sea_green3"), - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Pending Coldkey Swaps\n", - show_header=True, - show_edge=False, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, + block_hash = await subtensor.substrate.get_chain_head() + announcement = await subtensor.get_coldkey_swap_announcement( + wallet.coldkeypub.ss58_address, + block_hash=block_hash, + ) + if not announcement: + print_error( + f"No pending coldkey swap announcement found for {wallet.coldkeypub.ss58_address}.\n" + "You must first announce your swap with 'btcli wallet swap-coldkey announce'." ) + return False - for coldkey in scheduled_swaps: - table.add_row(coldkey, "Pending") + expected_hash = compute_coldkey_hash(new_coldkey_ss58) + if announcement.new_coldkey_hash != expected_hash: + table = create_key_value_table("Coldkey Hash Mismatch\n") + table.add_row("Announced Hash", f"[dim]{announcement.new_coldkey_hash}[/dim]") + table.add_row("Provided Hash", f"[dim]{expected_hash}[/dim]") + console.print(table) + print_error( + "The provided coldkey does not match the announced hash.\n" + "Make sure you're using the same coldkey you announced." + ) + return False + current_block = await subtensor.substrate.get_block_number(block_hash=block_hash) + if current_block < announcement.execution_block: + remaining = announcement.execution_block - current_block + table = create_key_value_table("Coldkey Swap Not Ready") + table.add_row("Current Block", str(current_block)) + table.add_row("Execution Block", str(announcement.execution_block)) + table.add_row( + "Time Remaining", f"[yellow]{blocks_to_duration(remaining)}[/yellow]" + ) console.print(table) - console.print( - "\n[dim]Tip: Check specific swap details by providing the original coldkey " - "SS58 address and the block number.[/dim]" + print_error( + "Coldkey swap cannot be executed yet. Please wait for the delay period." ) - return - chain_reported_completion_block, destination_address = await subtensor.query( - "SubtensorModule", "ColdkeySwapScheduled", [origin_ss58] + return False + + # Display confirmation table + table = create_key_value_table("Executing Coldkey Swap\n") + table.add_row( + "Current Coldkey", + f"[{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]", ) - destination_address = decode_account_id(destination_address[0]) - if chain_reported_completion_block != 0 and destination_address != GENESIS_ADDRESS: - is_pending = True - else: - is_pending = False + table.add_row("New Coldkey", f"[{COLORS.G.CK}]{new_coldkey_ss58}[/{COLORS.G.CK}]") - if not is_pending: - console.print( - f"[red]No pending swap found for coldkey:[/red] [{COLORS.G.CK}]{origin_ss58}[/{COLORS.G.CK}]" + console.print(table) + console.print( + "\n[bold red]WARNING:[/bold red] This action is irreversible. All assets will be transferred.\n" + ) + + if prompt and not confirm_action( + "Are you sure you want to continue?", decline=decline, quiet=quiet + ): + return False + + if not unlock_key(wallet).success: + return False + + with console.status(":satellite: Executing coldkey swap on-chain...") as status: + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="swap_coldkey_announced", + call_params={ + "new_coldkey": new_coldkey_ss58, + }, + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, + wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + mev_protection=mev_protection, ) - return - console.print( - f"[green]Found pending swap for coldkey:[/green] [{COLORS.G.CK}]{origin_ss58}[/{COLORS.G.CK}]" + if not success: + print_error(f"Failed to execute coldkey swap: {err_msg}", status=status) + return False + + if mev_protection: + inner_hash = err_msg + mev_success, mev_error, ext_receipt = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=ext_receipt.block_hash, + status=status, + ) + if not mev_success: + print_error( + f"Failed to execute coldkey swap: {mev_error}", status=status + ) + return False + + print_success("[dark_sea_green3]Successfully executed coldkey swap!") + await print_extrinsic_id(ext_receipt) + + # Success details table + success_table = create_key_value_table("Coldkey Swap Completed\n") + success_table.add_row( + "Old Coldkey", + f"[{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]", + ) + success_table.add_row( + "New Coldkey", f"[{COLORS.G.CK}]{new_coldkey_ss58}[/{COLORS.G.CK}]" + ) + console.print(success_table) + console.print("\n[dim]All assets have been transferred to the new coldkey.[/dim]") + + return True + + +async def check_swap_status( + subtensor: SubtensorInterface, + origin_ss58: Optional[str] = None, + json_output: bool = False, +) -> None: + """Retrieves and displays the status of coldkey swap announcements. + + Args: + subtensor: Connection to the network. + origin_ss58: The SS58 address of the coldkey to check. If None, shows all pending announcements. + json_output: If True, print JSON payload instead of rich table. + """ + block_hash = await subtensor.substrate.get_chain_head() + if origin_ss58: + announcement, dispute, current_block = await asyncio.gather( + subtensor.get_coldkey_swap_announcement(origin_ss58, block_hash=block_hash), + subtensor.get_coldkey_swap_dispute(origin_ss58, block_hash=block_hash), + subtensor.substrate.get_block_number(block_hash=block_hash), + ) + if not announcement: + console.print( + f"[yellow]No pending swap announcement found for coldkey:[/yellow] " + f"[{COLORS.G.CK}]{origin_ss58}[/{COLORS.G.CK}]" + ) + return + announcements = [announcement] + disputes = [(origin_ss58, dispute)] if dispute is not None else [] + + else: + announcements, disputes, current_block = await asyncio.gather( + subtensor.get_coldkey_swap_announcements(block_hash=block_hash), + subtensor.get_coldkey_swap_disputes(block_hash=block_hash), + subtensor.substrate.get_block_number(block_hash=block_hash), + ) + if not announcements: + console.print( + "[yellow]No pending coldkey swap announcements found.[/yellow]" + ) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "current_block": current_block, + "announcements": [], + } + ) + ) + return + + dispute_map = { + coldkey: block for coldkey, block in disputes if coldkey and block is not None + } + + payload = { + "success": True, + "current_block": current_block, + "announcements": [], + } + + table = Table( + Column( + "Coldkey", + justify="left", + style=COLOR_PALETTE["GENERAL"]["SUBHEADING_MAIN"], + no_wrap=True, + ), + Column("New Coldkey Hash", justify="left", style="dim", no_wrap=True), + Column("Execution Block", justify="right", style="dark_sea_green3"), + Column("Time Remaining", justify="right", style="yellow"), + Column("Status", justify="center", style="green"), + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Pending Coldkey Swap Announcements\nCurrent Block: {current_block}\n", + show_header=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, ) - if expected_block_number is None: - expected_block_number = chain_reported_completion_block + for announcement in announcements: + dispute_block = dispute_map.get(announcement.coldkey) + remaining_blocks = announcement.execution_block - current_block + if dispute_block is not None: + status = "[red]Disputed[/red]" + time_str = f"Disputed @ {dispute_block}" + status_label = "disputed" + elif remaining_blocks <= 0: + status = "Ready" + time_str = "[green]Ready[/green]" + status_label = "ready" + else: + status = "Pending" + time_str = blocks_to_duration(remaining_blocks) + status_label = "pending" + hash_display = f"{announcement.new_coldkey_hash[:12]}...{announcement.new_coldkey_hash[-6:]}" - current_block = await subtensor.substrate.get_block_number() - remaining_blocks = expected_block_number - current_block + table.add_row( + announcement.coldkey, + hash_display, + str(announcement.execution_block), + time_str, + status, + ) + + payload["announcements"].append( + { + "coldkey": announcement.coldkey, + "new_coldkey_hash": announcement.new_coldkey_hash, + "execution_block": announcement.execution_block, + "status": status_label, + "time_remaining_blocks": max(0, remaining_blocks), + "disputed_block": dispute_block, + } + ) - if remaining_blocks <= 0: - console.print("[green]Swap period has completed![/green]") + if json_output: + json_console.print(json.dumps(payload)) return + console.print(table) console.print( - "\n[green]Coldkey swap details:[/green]" - f"\nOriginal address: [{COLORS.G.CK}]{origin_ss58}[/{COLORS.G.CK}]" - f"\nDestination address: [{COLORS.G.CK}]{destination_address}[/{COLORS.G.CK}]" - f"\nCompletion block: {chain_reported_completion_block}" - f"\nTime remaining: {blocks_to_duration(remaining_blocks)}" + "\n[dim]To execute a ready swap:[/dim] " + "[green]btcli wallet swap-coldkey execute[/green]" ) diff --git a/contrib/CONTRIBUTING.MD b/contrib/CONTRIBUTING.MD index aba2b2ad9..7458a848c 100644 --- a/contrib/CONTRIBUTING.MD +++ b/contrib/CONTRIBUTING.MD @@ -39,3 +39,24 @@ We welcome feature requests and suggestions for improving the Bittensor CLI. To 4. Explain the feature you'd like to see added, why it would be useful, and how it could be implemented. Be as specific as possible. 5. If applicable, include examples, screenshots, or mockups to help illustrate your feature request. 6. Be patient and understanding. The maintainers will review your feature request and provide feedback. + + +### Signed Commits + +All commits in pull requests must be signed. We require signed commits to verify the authenticity of contributions and ensure code integrity. + +To sign your commits, you must have GPG signing configured in Git: + +```bash +git commit -S -m "your commit message" +``` + +Or configure Git to sign all commits automatically: + +```bash +git config --global commit.gpgsign true +``` + +For instructions on setting up GPG key signing, see [GitHub's documentation on signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). + +> **Note:** Pull requests containing unsigned commits will not be merged. diff --git a/pyproject.toml b/pyproject.toml index 0cfe2953b..f2a8aee59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.18.1" +version = "9.19.0" description = "Bittensor CLI" readme = "README.md" authors = [ @@ -12,12 +12,11 @@ authors = [ ] license = "MIT" scripts = { btcli = "bittensor_cli.cli:main" } -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -30,10 +29,10 @@ classifiers = [ ] dependencies = [ "wheel", - "async-substrate-interface>=1.6.0", + "async-substrate-interface>=1.6.2", "aiohttp~=3.13", "backoff~=2.2.1", - "bittensor-drand>=1.2.0", + "bittensor-drand>=1.3.0", "GitPython>=3.0.0", "netaddr~=1.3.0", "numpy>=2.0.1,<3.0.0", @@ -43,7 +42,8 @@ dependencies = [ "rich>=13.7,<15.0", "scalecodec==1.2.12", "typer>=0.16", - "bittensor-wallet>=4.0.0", + "typing_extensions>4.0.0; python_version<'3.11'", + "bittensor-wallet==4.0.1", "packaging", "plotille>=5.0.0", "plotly>=6.0.0", diff --git a/tests/e2e_tests/test_coldkey_swap.py b/tests/e2e_tests/test_coldkey_swap.py new file mode 100644 index 000000000..b91fb543b --- /dev/null +++ b/tests/e2e_tests/test_coldkey_swap.py @@ -0,0 +1,427 @@ +import asyncio +import json +import time +from .utils import ( + find_stake_entries, +) + + +def _wait_until_block(substrate, target_block: int): + async def _wait(): + while True: + head = await substrate.get_chain_head() + current = await substrate.get_block_number(block_hash=head) + if current >= target_block: + return current + await asyncio.sleep(1) + + return asyncio.run(_wait()) + + +def test_coldkey_swap_with_stake(local_chain, wallet_setup): + """ + Coldkey swap with stake: + 0. Bob registers on root and adds stake. + 1. Bob announces coldkey swap. + 2. Status shows pending. + 3. Wait until execution block. + 4. Execute swap. + 5. Status clear and root stake moves to new coldkey. + """ + print("Testing coldkey swap with stake ๐Ÿงช") + wallet_path_bob = "//Bob" + wallet_path_new = "//Charlie" + + _, wallet_bob, path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + _, wallet_new, path_new, _ = wallet_setup(wallet_path_new) + netuid = 2 + time.sleep(12) + # Create a new subnet by Bob + create_sn = exec_command_bob( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--subnet-name", + "Test Subnet CK Swap", + "--repo", + "https://github.com/opentensor/subnet-repo", + "--contact", + "bob@opentensor.dev", + "--url", + "https://subnet.example.com", + "--discord", + "bob#1234", + "--description", + "Subnet for coldkey swap e2e", + "--logo-url", + "https://subnet.example.com/logo.png", + "--additional-info", + "Created for e2e coldkey swap test", + "--no-prompt", + "--json-output", + "--no-mev-protection", + ], + ) + create_payload = json.loads(create_sn.stdout) + assert create_payload["success"] is True + + # Start emission schedule + start_sn = exec_command_bob( + command="subnets", + sub_command="start", + extra_args=[ + "--netuid", + str(2), + "--wallet-name", + wallet_bob.name, + "--wallet-path", + path_bob, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "Successfully started subnet" in start_sn.stdout, start_sn.stdout + + # Add stake to the new subnet + stake_add = exec_command_bob( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "5", + "--unsafe", + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "โœ… Finalized" in stake_add.stdout, stake_add.stdout + + # Announce swap + announce = exec_command_bob( + command="wallet", + sub_command="swap-coldkey", + extra_args=[ + "announce", + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--new-coldkey", + wallet_new.coldkeypub.ss58_address, + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "Successfully announced coldkey swap" in announce.stdout, announce.stdout + + # Fetch announcement and wait for execution block + status_json = exec_command_bob( + command="wallet", + sub_command="swap-check", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + status_payload = json.loads(status_json.stdout) + assert status_payload["announcements"], status_payload + when = status_payload["announcements"][0]["execution_block"] + _wait_until_block(local_chain, when) + + # Execute swap + execute = exec_command_bob( + command="wallet", + sub_command="swap-coldkey", + extra_args=[ + "execute", + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--new-coldkey", + wallet_new.coldkeypub.ss58_address, + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "Successfully executed coldkey swap" in execute.stdout, execute.stdout + + # Status should clear + status = exec_command_bob( + command="wallet", + sub_command="swap-check", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + ], + ) + assert "No pending swap announcement" in status.stdout, status.stdout + + # Stake should now be on the new coldkey + stake_new = exec_command_bob( + command="stake", + sub_command="list", + extra_args=[ + "--coldkey-ss58", + wallet_new.coldkeypub.ss58_address, + "--network", + "ws://127.0.0.1:9945", + "--json-output", + "--no-prompt", + ], + ) + payload_new = json.loads(stake_new.stdout) + new_entries = find_stake_entries( + payload_new, netuid=netuid, hotkey_ss58=wallet_bob.hotkey.ss58_address + ) + assert len(new_entries) > 0, "Stake not found on new coldkey" + assert float(new_entries[0].get("value", 0)) > 0 + + # Old coldkey should have no stake + stake_old = exec_command_bob( + command="stake", + sub_command="list", + extra_args=[ + "--coldkey-ss58", + wallet_bob.coldkeypub.ss58_address, + "--network", + "ws://127.0.0.1:9945", + "--json-output", + "--no-prompt", + ], + ) + assert not stake_old.stdout, "Old coldkey still has stake" + + +def test_coldkey_swap_dispute(local_chain, wallet_setup): + """ + Dispute path: + 1. Bob announces swap. + 2. Status pending. + 3. Bob disputes immediately. + 4. Execute attempt fails and status shows Disputed. + """ + print("Testing coldkey swap dispute path ๐Ÿงช") + wallet_path_bob = "//Bob" + wallet_path_new = "//Dave" + + _, wallet_bob, path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + _, wallet_new, _, _ = wallet_setup(wallet_path_new) + + time.sleep(12) + # Create subnet, start, and stake on it + create_sn = exec_command_bob( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--subnet-name", + "Test Subnet CK Swap Dispute", + "--repo", + "https://github.com/opentensor/subnet-repo", + "--contact", + "bob@opentensor.dev", + "--url", + "https://subnet.example.com", + "--discord", + "bob#1234", + "--description", + "Subnet for coldkey swap dispute test", + "--logo-url", + "https://subnet.example.com/logo.png", + "--additional-info", + "Created for e2e coldkey swap dispute test", + "--no-prompt", + "--json-output", + "--no-mev-protection", + ], + ) + create_payload = json.loads(create_sn.stdout) + assert create_payload.get("success") is True, create_payload + netuid = create_payload["netuid"] + + start_sn = exec_command_bob( + command="subnets", + sub_command="start", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-name", + wallet_bob.name, + "--wallet-path", + path_bob, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "Successfully started subnet" in start_sn.stdout, start_sn.stdout + + stake_add = exec_command_bob( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "2", + "--unsafe", + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "โœ… Finalized" in stake_add.stdout, stake_add.stdout + + announce = exec_command_bob( + command="wallet", + sub_command="swap-coldkey", + extra_args=[ + "announce", + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--new-coldkey", + wallet_new.coldkeypub.ss58_address, + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "Successfully announced coldkey swap" in announce.stdout, announce.stdout + + status = exec_command_bob( + command="wallet", + sub_command="swap-check", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + status_payload = json.loads(status.stdout) + assert status_payload["announcements"], status_payload + assert status_payload["announcements"][0]["status"] == "pending" + + dispute = exec_command_bob( + command="wallet", + sub_command="swap-coldkey", + extra_args=[ + "dispute", + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "Coldkey swap disputed" in dispute.stdout, dispute.stdout + + status_after_dispute = exec_command_bob( + command="wallet", + sub_command="swap-check", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + status_payload_after_dispute = json.loads(status_after_dispute.stdout) + if status_payload_after_dispute["announcements"]: + when = status_payload_after_dispute["announcements"][0]["execution_block"] + _wait_until_block(local_chain, when) + + execute = exec_command_bob( + command="wallet", + sub_command="swap-coldkey", + extra_args=[ + "execute", + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--new-coldkey", + wallet_new.coldkeypub.ss58_address, + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "ColdkeySwapDisputed" in execute.stderr, execute.stderr + + status_after = exec_command_bob( + command="wallet", + sub_command="swap-check", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + status_after_payload = json.loads(status_after.stdout) + assert status_after_payload["announcements"], status_after_payload + assert status_after_payload["announcements"][0]["status"] == "disputed" diff --git a/tests/e2e_tests/test_proxy.py b/tests/e2e_tests/test_proxy.py index e5e76724e..092daa0e8 100644 --- a/tests/e2e_tests/test_proxy.py +++ b/tests/e2e_tests/test_proxy.py @@ -684,3 +684,456 @@ def test_add_proxy(local_chain, wallet_setup): os.environ["BTCLI_PROXIES_PATH"] = "" if os.path.exists(testing_db_loc): os.remove(testing_db_loc) + + +def test_remove_all_proxies(local_chain, wallet_setup): + """ + Tests removing all proxies using the --all flag + + Steps: + 1. Add multiple proxies (Dave and Bob as proxies of Alice) + 2. Verify proxies are added + 3. Remove all proxies using --all flag + 4. Verify all proxies are removed + 5. Attempt to use one of the proxies (should fail) + """ + 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, Bob, and Dave + 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_1 = "Transfer" + proxy_type_2 = "Staking" + delay = 0 + + try: + # Add Dave as a proxy of Alice (Transfer type) + add_result_1 = 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_1, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + add_result_1_output = json.loads(add_result_1.stdout) + assert add_result_1_output["success"] is True + assert ( + add_result_1_output["data"]["delegatee"] + == wallet_dave.coldkeypub.ss58_address + ) + assert add_result_1_output["data"]["proxy_type"] == proxy_type_1 + print("Proxy 1 (Dave - Transfer) added successfully") + + # Add Bob as a proxy of Alice (Staking type) + add_result_2 = 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_bob.coldkeypub.ss58_address, + "--proxy-type", + proxy_type_2, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + add_result_2_output = json.loads(add_result_2.stdout) + assert add_result_2_output["success"] is True + assert ( + add_result_2_output["data"]["delegatee"] + == wallet_bob.coldkeypub.ss58_address + ) + assert add_result_2_output["data"]["proxy_type"] == proxy_type_2 + print("Proxy 2 (Bob - Staking) added successfully") + + # Remove all proxies using --all flag + # Note: proxy_type is still required by CLI even with --all due to prompt=True + # so we provide it with a default value to avoid prompting + remove_all_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", + "--all", + "--proxy-type", + "Any", # Required to avoid prompt even with --all + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + remove_all_result_output = json.loads(remove_all_result.stdout) + assert remove_all_result_output["success"] is True + assert remove_all_result_output["message"] == "" + assert isinstance(remove_all_result_output["extrinsic_identifier"], str) + print("All proxies removed successfully") + + # Verify proxies are removed by attempting to use one (should fail) + # Try to use Dave as proxy (should fail since proxy was removed) + 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 proxy was removed + assert transfer_result_proxy_output["success"] is False + print("Verified proxy removal - transfer via removed proxy failed as expected") + + finally: + os.environ["BTCLI_PROXIES_PATH"] = "" + if os.path.exists(testing_db_loc): + os.remove(testing_db_loc) + + +def test_remove_proxy_validation(local_chain, wallet_setup): + """ + Tests validation for proxy remove command + + Steps: + 1. Attempt to remove proxy without --delegate or --all (should fail) + 2. Attempt to remove with --all flag (should succeed if no proxies exist) + """ + testing_db_loc = "/tmp/btcli-test.db" + os.environ["BTCLI_PROXIES_PATH"] = testing_db_loc + wallet_path_alice = "//Alice" + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + + try: + # Attempt to remove proxy without --delegate or --all (should fail) + # Note: proxy_type must be provided to avoid Typer prompt, but validation + # should still fail because delegate is missing + 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", + "--proxy-type", + "Any", # Required to avoid prompt, but delegate still missing + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + remove_result_output = json.loads(remove_result.stdout) + assert remove_result_output["success"] is False + assert ( + "Either --delegate must be provided or --all flag must be used" + in remove_result_output["message"] + ) + print("Validation test passed - correctly rejected missing delegate/--all") + + # Attempt to remove all proxies when none exist (should succeed) + # Note: proxy_type is still required by CLI even with --all due to prompt=True + # so we provide it with a default value to avoid prompting + remove_all_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", + "--all", + "--proxy-type", + "Any", # Required to avoid prompt even with --all + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + remove_all_result_output = json.loads(remove_all_result.stdout) + # Should succeed even if no proxies exist (remove_proxies extrinsic handles this) + assert remove_all_result_output["success"] is True + print("Validation test passed - --all works even when no proxies exist") + + finally: + os.environ["BTCLI_PROXIES_PATH"] = "" + if os.path.exists(testing_db_loc): + os.remove(testing_db_loc) + + +def test_remove_proxy(local_chain, wallet_setup): + """ + Tests removing a single proxy (without --all flag) + + Steps: + 1. Add Dave as a proxy of Alice + 2. Verify proxy is added and can be used + 3. Remove the specific proxy using --delegate flag + 4. Verify proxy is removed by attempting to use it (should fail) + """ + 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, Bob, and Dave + 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 = "Transfer" + delay = 0 + + 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 ( + 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 added successfully") + + # Verify proxy works by checking 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"] + + # Check Alice's initial 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 + ) + + # Use the proxy to transfer from Alice to Bob + 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) + assert transfer_result_proxy_output["success"] is True + print("Proxy transfer successful - proxy is working") + + # Verify Bob received the 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"]["free"] + == float(amount_to_transfer_proxy) + bob_init_balance + ) + + # Now remove the proxy using single proxy removal (without --all) + 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("Single proxy removal successful") + + # Verify proxy is removed by attempting to use it (should fail) + transfer_result_proxy_2 = 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_2_output = json.loads(transfer_result_proxy_2.stdout) + # Should fail because proxy was removed + assert transfer_result_proxy_2_output["success"] is False + print("Verified proxy removal - transfer via removed proxy failed as expected") + + 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_stake_burn.py b/tests/e2e_tests/test_stake_burn.py new file mode 100644 index 000000000..a85f24137 --- /dev/null +++ b/tests/e2e_tests/test_stake_burn.py @@ -0,0 +1,165 @@ +import json +import time + +import pytest + +from .utils import extract_coldkey_balance + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_stake_burn(local_chain, wallet_setup): + """ + Test stake burn + 1. Create a subnet + 2. Start the subnet's emission schedule + 3. Buyback the subnet (stake burn) + 3. Check the balance before and after the buyback upon success + 4. Try to buyback again and expect it to fail due to rate limit + """ + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup("//Alice") + time.sleep(12) + netuid = 2 + 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", + "test@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "test#1234", + "--description", + "A test subnet for e2e testing", + "--logo-url", + "https://testsubnet.com/logo.png", + "--additional-info", + "Test subnet", + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "โœ… Registered subnetwork with netuid: 2" in result.stdout + + # Start the subnet's emission schedule + start_call_netuid_2 = 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 ( + "Successfully started subnet 2's emission schedule." + in start_call_netuid_2.stdout + ) + assert "Your extrinsic has been included" in start_call_netuid_2.stdout + time.sleep(2) + + # Balance before buyback + _balance_before = exec_command_alice( + "wallet", + "balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--network", + "ws://127.0.0.1:9945", + ], + ) + balance_before = extract_coldkey_balance( + _balance_before.stdout, wallet_alice.name, wallet_alice.coldkey.ss58_address + )["free_balance"] + + # First stake burn + amount_tao = 1.0 + stake_burn_result = exec_command_alice( + "sudo", + "stake-burn", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--network", + "ws://127.0.0.1:9945", + "--netuid", + str(netuid), + "--amount", + str(amount_tao), + "--no-prompt", + "--json-output", + ], + ) + stale_burn_ok_out = json.loads(stake_burn_result.stdout) + assert stale_burn_ok_out["success"] is True, stake_burn_result.stdout + + # Balance after stake burn + _balance_after = exec_command_alice( + "wallet", + "balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--network", + "ws://127.0.0.1:9945", + ], + ) + balance_after = extract_coldkey_balance( + _balance_after.stdout, wallet_alice.name, wallet_alice.coldkey.ss58_address + )["free_balance"] + assert balance_after < balance_before, (balance_before, balance_after) + + # Should fail due to rate limit + stake_burn_ratelimited_result = exec_command_alice( + "sudo", + "buyback", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--network", + "ws://127.0.0.1:9945", + "--netuid", + str(netuid), + "--amount", + str(amount_tao), + "--no-prompt", + "--json-output", + ], + ) + stake_burn_ratelimited = json.loads(stake_burn_ratelimited_result.stdout) + assert stake_burn_ratelimited["success"] is False, ( + stake_burn_ratelimited_result.stdout + ) + assert "AddStakeBurnRateLimitExceeded" in stake_burn_ratelimited["message"] diff --git a/tests/e2e_tests/test_wallet_interactions.py b/tests/e2e_tests/test_wallet_interactions.py index 7ed705b65..02797b978 100644 --- a/tests/e2e_tests/test_wallet_interactions.py +++ b/tests/e2e_tests/test_wallet_interactions.py @@ -447,17 +447,17 @@ def test_wallet_identities(local_chain, wallet_setup): assert "Your extrinsic has been included as" in set_id_output[1] - assert alice_identity["name"] in set_id_output[7] - assert alice_identity["url"] in set_id_output[8] - assert alice_identity["github_repo"] in set_id_output[9] - assert alice_identity["image"] in set_id_output[10] - assert alice_identity["discord"] in set_id_output[11] - assert alice_identity["description"] in set_id_output[12] - assert alice_identity["additional"] in set_id_output[13] + assert alice_identity["name"] in set_id_output[8] + assert alice_identity["url"] in set_id_output[9] + assert alice_identity["github_repo"] in set_id_output[10] + assert alice_identity["image"] in set_id_output[11] + assert alice_identity["discord"] in set_id_output[12] + assert alice_identity["description"] in set_id_output[13] + assert alice_identity["additional"] in set_id_output[14] # TODO: Currently coldkey + hotkey are the same for test wallets. # Maybe we can add a new key to help in distinguishing - assert wallet_alice.coldkeypub.ss58_address in set_id_output[6] + assert wallet_alice.coldkeypub.ss58_address in set_id_output[7] # Execute btcli get-identity using hotkey get_identity = exec_command_alice( diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py index 60cc10708..5b73fd68c 100644 --- a/tests/unit_tests/test_cli.py +++ b/tests/unit_tests/test_cli.py @@ -4,6 +4,7 @@ from async_substrate_interface import AsyncSubstrateInterface from bittensor_cli.cli import parse_mnemonic, CLIManager +from bittensor_cli.src import HYPERPARAMS, HYPERPARAMS_METADATA, RootSudoOnly from bittensor_cli.src.bittensor.extrinsics.root import ( get_current_weights_for_uid, set_root_weights_extrinsic, @@ -551,46 +552,6 @@ def test_wallet_set_id_calls_proxy_validation(): 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() @@ -805,3 +766,304 @@ async def test_set_root_weights_skips_current_weights_without_prompt(): ) mock_get_current.assert_not_called() + + +# HYPERPARAMS / HYPERPARAMS_METADATA (issue #826) +NEW_HYPERPARAMS_826 = {"sn_owner_hotkey", "subnet_owner_hotkey", "recycle_or_burn"} + + +def test_new_hyperparams_in_hyperparams(): + for key in NEW_HYPERPARAMS_826: + assert key in HYPERPARAMS, f"{key} should be in HYPERPARAMS" + extrinsic, root_only = HYPERPARAMS[key] + assert extrinsic, f"{key} must have non-empty extrinsic name" + assert root_only is RootSudoOnly.FALSE + + +def test_subnet_owner_hotkey_alias_maps_to_same_extrinsic(): + ext_sn, _ = HYPERPARAMS["sn_owner_hotkey"] + ext_subnet, _ = HYPERPARAMS["subnet_owner_hotkey"] + assert ext_sn == ext_subnet == "sudo_set_sn_owner_hotkey" + + +def test_new_hyperparams_have_metadata(): + required = {"description", "side_effects", "owner_settable", "docs_link"} + for key in NEW_HYPERPARAMS_826: + assert key in HYPERPARAMS_METADATA, f"{key} should be in HYPERPARAMS_METADATA" + meta = HYPERPARAMS_METADATA[key] + for field in required: + assert field in meta, f"{key} metadata missing '{field}'" + assert isinstance(meta["description"], str) + assert isinstance(meta["owner_settable"], bool) + + +def test_new_hyperparams_owner_settable_true(): + for key in NEW_HYPERPARAMS_826: + assert HYPERPARAMS_METADATA[key]["owner_settable"] is True + + +# ============================================================================ +# Tests for proxy_remove command +# ============================================================================ + + +@patch("bittensor_cli.cli.print_error") +def test_proxy_remove_errors_without_delegate_or_all_no_prompt(mock_print_error): + """Test that proxy_remove errors when neither --delegate nor --all is provided and prompt is disabled""" + cli_manager = CLIManager() + + cli_manager.proxy_remove( + delegate=None, + all_=False, + network=None, + proxy_type="Transfer", + delay=0, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=100, + quiet=True, + verbose=False, + json_output=False, + ) + + mock_print_error.assert_called_once_with( + "Either --delegate must be provided or --all flag must be used." + ) + + +@patch("bittensor_cli.cli.json_console") +def test_proxy_remove_errors_without_delegate_or_all_json(mock_json_console): + """Test that proxy_remove returns JSON error when neither --delegate nor --all and json_output""" + cli_manager = CLIManager() + + cli_manager.proxy_remove( + delegate=None, + all_=False, + network=None, + proxy_type="Transfer", + delay=0, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=100, + quiet=True, + verbose=False, + json_output=True, + ) + + mock_json_console.print_json.assert_called_once() + call_args = mock_json_console.print_json.call_args[1]["data"] + assert call_args["success"] is False + assert ( + "Either --delegate must be provided or --all flag must be used." + in call_args["message"] + ) + + +@patch("bittensor_cli.cli.is_valid_ss58_address_param") +@patch("bittensor_cli.cli.Prompt") +@patch("bittensor_cli.cli.proxy_commands") +def test_proxy_remove_prompts_delegate_when_not_provided( + mock_proxy_commands, mock_prompt, mock_validate +): + """Test that proxy_remove prompts for delegate when prompt is enabled and neither --delegate nor --all is used""" + cli_manager = CLIManager() + valid_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + mock_prompt.ask.return_value = valid_ss58 + mock_validate.return_value = valid_ss58 + mock_wallet = Mock() + mock_subtensor = Mock() + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask", return_value=mock_wallet), + patch.object(cli_manager, "initialize_chain", return_value=mock_subtensor), + patch.object(cli_manager, "_run_command") as mock_run_command, + ): + cli_manager.proxy_remove( + delegate=None, + all_=False, + network=None, + proxy_type="Transfer", + delay=0, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + prompt=True, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=100, + quiet=True, + verbose=False, + json_output=False, + ) + + # Should prompt for delegate + mock_prompt.ask.assert_called_once() + + # Should call remove_proxy with the prompted delegate + mock_proxy_commands.remove_proxy.assert_called_once_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + delegate=valid_ss58, + proxy_type="Transfer", + delay=0, + prompt=True, + decline=False, + quiet=True, + wait_for_inclusion=False, + wait_for_finalization=False, + period=100, + json_output=False, + remove_all=False, + ) + + mock_run_command.assert_called_once() + + +@patch("bittensor_cli.cli.print_error") +def test_proxy_remove_with_all_and_delegate_errors(mock_print_error): + """Test that proxy_remove with both --all and --delegate flags returns an error""" + cli_manager = CLIManager() + valid_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + cli_manager.proxy_remove( + delegate=valid_ss58, + all_=True, + network=None, + proxy_type="Transfer", + delay=0, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=100, + quiet=True, + verbose=False, + json_output=False, + ) + + # Should show error that --delegate cannot be used with --all + mock_print_error.assert_called_once_with( + "--delegate cannot be used together with --all flag." + ) + + +@patch("bittensor_cli.cli.proxy_commands") +def test_proxy_remove_with_all_flag(mock_proxy_commands): + """Test that proxy_remove with --all flag calls remove_proxy with remove_all=True""" + cli_manager = CLIManager() + mock_wallet = Mock() + mock_subtensor = Mock() + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask", return_value=mock_wallet), + patch.object(cli_manager, "initialize_chain", return_value=mock_subtensor), + patch.object(cli_manager, "_run_command") as mock_run_command, + ): + cli_manager.proxy_remove( + delegate=None, + all_=True, + network=None, + proxy_type="Transfer", + delay=0, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=100, + quiet=True, + verbose=False, + json_output=False, + ) + + # Should call remove_proxy with remove_all=True + mock_proxy_commands.remove_proxy.assert_called_once_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + delegate=None, + proxy_type="Transfer", + delay=0, + prompt=False, + decline=False, + quiet=True, + wait_for_inclusion=False, + wait_for_finalization=False, + period=100, + json_output=False, + remove_all=True, + ) + + # Should call _run_command with the result + mock_run_command.assert_called_once() + + +@patch("bittensor_cli.cli.proxy_commands") +def test_proxy_remove_with_delegate_calls_remove_proxy(mock_proxy_commands): + """Test that proxy_remove with --delegate calls remove_proxy with correct parameters""" + cli_manager = CLIManager() + valid_delegate = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + mock_wallet = Mock() + mock_subtensor = Mock() + + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask", return_value=mock_wallet), + patch.object(cli_manager, "initialize_chain", return_value=mock_subtensor), + patch.object(cli_manager, "_run_command") as mock_run_command, + ): + cli_manager.proxy_remove( + delegate=valid_delegate, + all_=False, + network=None, + proxy_type="Transfer", + delay=10, + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="test_hotkey", + prompt=False, + decline=False, + wait_for_inclusion=True, + wait_for_finalization=True, + period=100, + quiet=True, + verbose=False, + json_output=False, + ) + + # Should call remove_proxy with correct parameters + mock_proxy_commands.remove_proxy.assert_called_once_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + delegate=valid_delegate, + proxy_type="Transfer", + delay=10, + prompt=False, + decline=False, + quiet=True, + wait_for_inclusion=True, + wait_for_finalization=True, + period=100, + json_output=False, + remove_all=False, + ) + + # Should call _run_command with the result + mock_run_command.assert_called_once() diff --git a/tests/unit_tests/test_hyperparams.py b/tests/unit_tests/test_hyperparams.py new file mode 100644 index 000000000..8f6b42647 --- /dev/null +++ b/tests/unit_tests/test_hyperparams.py @@ -0,0 +1,40 @@ +"""Unit tests for HYPERPARAMS and HYPERPARAMS_METADATA (issue #826).""" + +from bittensor_cli.src import HYPERPARAMS, HYPERPARAMS_METADATA, RootSudoOnly + + +NEW_HYPERPARAMS_826 = { + "sn_owner_hotkey", + "subnet_owner_hotkey", + "recycle_or_burn", +} + + +def test_new_hyperparams_in_hyperparams(): + for key in NEW_HYPERPARAMS_826: + assert key in HYPERPARAMS, f"{key} should be in HYPERPARAMS" + extrinsic, root_only = HYPERPARAMS[key] + assert extrinsic, f"{key} must have non-empty extrinsic name" + assert root_only is RootSudoOnly.FALSE + + +def test_subnet_owner_hotkey_alias_maps_to_same_extrinsic(): + ext_sn, _ = HYPERPARAMS["sn_owner_hotkey"] + ext_subnet, _ = HYPERPARAMS["subnet_owner_hotkey"] + assert ext_sn == ext_subnet == "sudo_set_sn_owner_hotkey" + + +def test_new_hyperparams_have_metadata(): + required = {"description", "side_effects", "owner_settable", "docs_link"} + for key in NEW_HYPERPARAMS_826: + assert key in HYPERPARAMS_METADATA, f"{key} should be in HYPERPARAMS_METADATA" + meta = HYPERPARAMS_METADATA[key] + for field in required: + assert field in meta, f"{key} metadata missing '{field}'" + assert isinstance(meta["description"], str) + assert isinstance(meta["owner_settable"], bool) + + +def test_new_hyperparams_owner_settable_true(): + for key in NEW_HYPERPARAMS_826: + assert HYPERPARAMS_METADATA[key]["owner_settable"] is True