diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index dab315863..011bfb450 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -97,6 +97,7 @@ add as add_stake, remove as remove_stake, claim as claim_stake, + validator_claim, wizard as stake_wizard, ) from bittensor_cli.src.commands.subnets import ( @@ -163,7 +164,9 @@ def validate_claim_type(value: Optional[str]) -> Optional[claim_stake.ClaimType] return member return claim_stake.ClaimType(value) except ValueError: - raise typer.BadParameter(f"'{value}' is not one of 'Keep', 'Swap'.") + raise typer.BadParameter( + f"'{value}' is not one of 'Keep', 'Swap', 'Delegated'." + ) class Options: @@ -1085,6 +1088,12 @@ def __init__(self): self.stake_app.command( "process-claim", rich_help_panel=HELP_PANELS["STAKE"]["CLAIM"] )(self.stake_process_claim) + self.stake_app.command( + "show-validator-claims", rich_help_panel=HELP_PANELS["STAKE"]["CLAIM"] + )(self.show_validator_claims) + self.stake_app.command( + "set-validator-claims", rich_help_panel=HELP_PANELS["STAKE"]["CLAIM"] + )(self.set_validator_claim_types) # stake-children commands children_app = typer.Typer() @@ -1286,6 +1295,14 @@ def __init__(self): "unclaim", hidden=True, )(self.stake_set_claim_type) + self.stake_app.command( + "show-validator-claim", + hidden=True, + )(self.show_validator_claims) + self.stake_app.command( + "set-validator-claim", + hidden=True, + )(self.set_validator_claim_types) # Crowdloan self.app.add_typer( @@ -5838,6 +5855,7 @@ def stake_set_claim_type( • [green]Swap[/green]: Future Root Alpha Emissions are swapped to TAO and added to root stake (default) • [yellow]Keep[/yellow]: Future Root Alpha Emissions are kept as Alpha tokens • [cyan]Keep Specific[/cyan]: Keep specific subnets as Alpha, swap others to TAO. You can use this type by selecting the netuids. + • [magenta]Delegated[/magenta]: Delegate claim choice to validator (inherits validator claim type) USAGE: @@ -5846,6 +5864,7 @@ def stake_set_claim_type( [green]$[/green] btcli stake claim swap [cyan](Swap all subnets)[/cyan] [green]$[/green] btcli stake claim keep --netuids 1-5,10,20-30 [cyan](Keep specific subnets)[/cyan] [green]$[/green] btcli stake claim swap --netuids 1-30 [cyan](Swap specific subnets)[/cyan] + [green]$[/green] btcli stake claim delegated [cyan](Delegate claim choice to validator)[/cyan] With specific wallet: @@ -5871,6 +5890,76 @@ def stake_set_claim_type( ) ) + def set_validator_claim_types( + self, + keep: Optional[str] = typer.Option( + None, + "--keep", + help="Subnets to keep emissions for (e.g. '1,3-5').", + ), + swap: Optional[str] = typer.Option( + None, + "--swap", + help="Subnets to swap emissions for (e.g. '2,6-10').", + ), + keep_all: bool = typer.Option( + False, + "--keep-all", + help="Set all registered subnets to Keep.", + ), + swap_all: bool = typer.Option( + False, + "--swap-all", + help="Set all registered subnets to Swap.", + ), + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + network: Optional[list[str]] = Options.network, + proxy: Optional[str] = Options.proxy, + announce_only: bool = Options.announce_only, + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Set the claim type for a validator across multiple subnets. + + This command allows validators to specify how they want to handle emissions for each subnet: + - [bold]Keep[/bold]: Keep emissions as Alpha tokens on the subnet. + - [bold]Swap[/bold]: Automatically swap Alpha emissions to TAO on the root network. + + You can set preferences for specific subnets using [blue]--keep[/blue] and [blue]--swap[/blue], or update all registered subnets using [blue]--keep-all[/blue] or [blue]--swap-all[/blue]. + + If no arguments are provided, an interactive editor will be launched. + + EXAMPLES: + [green]$[/green] btcli stake set-validator-claim --keep 1,3-5 --swap 2,10 + [green]$[/green] btcli stake set-validator-claim --keep-all + [green]$[/green] btcli stake set-validator-claim (Interactive mode) + """ + self.verbosity_handler(quiet, verbose, False, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + return self._run_command( + validator_claim.set_validator_claim_type( + wallet=wallet, + subtensor=self.initialize_chain(network), + keep=keep, + swap=swap, + keep_all=keep_all, + swap_all=swap_all, + prompt=prompt, + proxy=proxy, + ) + ) + def stake_process_claim( self, netuids: Optional[str] = Options.netuids, @@ -5939,6 +6028,58 @@ def stake_process_claim( ) ) + def show_validator_claims( + self, + hotkey_ss58: Optional[str] = Options.wallet_hotkey_ss58, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + network: Optional[list[str]] = Options.network, + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Show validator claim types (Keep/Swap) across all subnets for a validator hotkey. + + EXAMPLES: + [green]$[/green] btcli stake show-validator-claims --hotkey 5Grw... + [green]$[/green] btcli stake show-validator-claims --wallet-name my_wallet --wallet-hotkey hk + """ + self.verbosity_handler(quiet, verbose, json_output, False) + + if not hotkey_ss58 and not wallet_name: + ss58_or_wallet = Prompt.ask( + "Enter the [blue]hotkey ss58 address[/blue] or [blue]wallet name[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) + if is_valid_ss58_address(ss58_or_wallet): + hotkey_ss58 = ss58_or_wallet + else: + wallet_name = ss58_or_wallet + + if hotkey_ss58: + if is_valid_ss58_address(hotkey_ss58): + vali_hk_ss58 = hotkey_ss58 + else: + wallet = self.wallet_ask( + wallet_name, + wallet_path, + hotkey_ss58, + ask_for=[WO.NAME, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + vali_hk_ss58 = get_hotkey_pub_ss58(wallet) + + return self._run_command( + validator_claim.show_validator_claims( + subtensor=self.initialize_chain(network), + hotkey_ss58=vali_hk_ss58, + verbose=verbose, + json_output=json_output, + ) + ) + def stake_get_children( self, wallet_name: Optional[str] = Options.wallet_name, @@ -6023,9 +6164,9 @@ def stake_set_children( announce_only: bool = Options.announce_only, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, + prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, - prompt: bool = Options.prompt, json_output: bool = Options.json_output, ): """ diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 10004797f..1dc4c433a 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2034,6 +2034,103 @@ async def get_all_coldkeys_claim_type( return root_claim_types + async def get_vali_claim_types_for_hk_netuid( + self, + hotkey_ss58: str, + netuid: int, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict: + """ + Retrieves the validator claim type for a specific hotkey on a subnet. + + Args: + hotkey_ss58: Validator hotkey SS58 address. + netuid: Subnet identifier. + block_hash: Optional block hash for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + dict: Claim type information in one of these formats: + - {"type": "Swap"} + - {"type": "Keep"} + """ + result = await self.query( + module="SubtensorModule", + storage_function="ValidatorClaimType", + params=[hotkey_ss58, netuid], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + claim_type_key = next(iter(result.keys())) + return {"type": claim_type_key} + + async def get_all_vali_claim_types( + self, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[int, str]: + """ + Retrieves validator claim types for all netuids for all validator hotkeys. + + Args: + block_hash: Optional block hash for the query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + dict[int, str]: Mapping of netuid -> claim type ("Keep" or "Swap"). + """ + result = await self.substrate.query_map( + module="SubtensorModule", + storage_function="ValidatorClaimType", + params=[], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + claim_types: dict[str, dict[int, str]] = {} + async for hk_netuid, claim_type_data in result: + hotkey_ss58 = decode_account_id(hk_netuid[0]) + netuid = int(hk_netuid[1]) + claim_type_key = next(iter(claim_type_data.value.keys())) + if hotkey_ss58 not in claim_types: + claim_types[hotkey_ss58] = {} + claim_types[hotkey_ss58][netuid] = claim_type_key + + return claim_types + + async def get_vali_claim_types_for_hk( + self, + hotkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[int, str]: + """ + Retrieves validator claim types for all netuids for a given validator hotkey. + + Args: + hotkey_ss58: Validator hotkey SS58 address. + block_hash: Optional block hash for the query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + dict[int, str]: Mapping of netuid -> claim type ("Keep" or "Swap"). + """ + result = await self.substrate.query_map( + module="SubtensorModule", + storage_function="ValidatorClaimType", + params=[hotkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + claim_types: dict[int, str] = {} + async for netuid, claim_type_data in result: + claim_type_key = next(iter(claim_type_data.value.keys())) + claim_types[int(netuid)] = claim_type_key + + return claim_types + async def get_staking_hotkeys( self, coldkey_ss58: str, diff --git a/bittensor_cli/src/commands/stake/claim.py b/bittensor_cli/src/commands/stake/claim.py index 396255198..7845f26d1 100644 --- a/bittensor_cli/src/commands/stake/claim.py +++ b/bittensor_cli/src/commands/stake/claim.py @@ -29,6 +29,7 @@ class ClaimType(Enum): Keep = "Keep" Swap = "Swap" + Delegated = "Delegated" async def set_claim_type( @@ -536,18 +537,22 @@ async def _ask_for_claim_types( "[yellow]Options:[/yellow]\n" " • [green]Swap[/green] - Convert emissions to TAO\n" " • [green]Keep[/green] - Keep emissions as Alpha\n" - " • [green]Keep Specific[/green] - Keep selected subnets, swap others\n", + " • [green]Keep Specific[/green] - Keep selected subnets, swap others\n" + " • [green]Delegated[/green] - Delegate claim choice to validator\n", ) ) primary_choice = Prompt.ask( "\nSelect new root claim type", - choices=["keep", "swap", "cancel"], + choices=["keep", "swap", "delegated", "cancel"], default="cancel", ) if primary_choice == "cancel": return None + if primary_choice == "delegated": + return {"type": "Delegated"} + apply_to_all = Confirm.ask( f"\nSet {primary_choice.capitalize()} to ALL subnets?", default=True ) @@ -713,6 +718,10 @@ def _format_claim_type_display( result += f"\n[yellow] ⟳ Swap:[/yellow] {swap_display}" return result + + elif claim_type == "Delegated": + return "[magenta]Delegated[/magenta]" + else: return "[red]Unknown[/red]" @@ -742,5 +751,7 @@ def _prepare_claim_type_args(claim_info: dict) -> dict: elif claim_type == "KeepSubnets": subnets = claim_info["subnets"] return {"KeepSubnets": {"subnets": subnets}} + elif claim_type == "Delegated": + return {"Delegated": None} else: raise ValueError(f"Unknown claim type: {claim_type}") diff --git a/bittensor_cli/src/commands/stake/validator_claim.py b/bittensor_cli/src/commands/stake/validator_claim.py new file mode 100644 index 000000000..b6eae5fa4 --- /dev/null +++ b/bittensor_cli/src/commands/stake/validator_claim.py @@ -0,0 +1,558 @@ +import json +import asyncio +from typing import Optional, Any +from rich.prompt import Confirm, Prompt +from rich.panel import Panel +from rich.table import Table +from rich.columns import Columns +from rich.text import Text +from rich.console import Group +from rich import box + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.chain_data import DynamicInfo +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, + get_subnet_name, + millify_tao, + parse_subnet_range, + group_subnets, + unlock_key, + print_extrinsic_id, + json_console, +) + + +async def show_validator_claims( + subtensor, + hotkey_ss58: Optional[str] = None, + block_hash: Optional[str] = None, + verbose: bool = False, + json_output: bool = False, +) -> None: + """ + Displays the validator claim configuration (Keep vs Swap) for a given hotkey's subnets. + + This function fetches the current claim status for all subnets where the validator has presence. + + Args: + subtensor: The subtensor interface for chain interaction. + hotkey_ss58: The SS58 address of the validator's hotkey. + block_hash: Optional block hash to query state at. + verbose: If True, displays full precision values. + json_output: If True, prints JSON to stdout and suppresses table render. + """ + + def _format_subnet_row( + subnet: DynamicInfo, + mechanisms: dict[int, int], + ema_tao_inflow: dict[int, Any], + verbose: bool, + ) -> tuple[str, ...]: + symbol = f"{subnet.symbol}\u200e" + netuid = subnet.netuid + price_value = f"{subnet.price.tao:,.4f}" + + market_cap = (subnet.alpha_in.tao + subnet.alpha_out.tao) * subnet.price.tao + market_cap_value = ( + f"{millify_tao(market_cap)}" if not verbose else f"{market_cap:,.4f}" + ) + + emission_tao = 0.0 if netuid == 0 else subnet.tao_in_emission.tao + + alpha_out_value = ( + f"{millify_tao(subnet.alpha_out.tao)}" + if not verbose + else f"{subnet.alpha_out.tao:,.4f}" + ) + alpha_out_cell = ( + f"{alpha_out_value} {symbol}" + if netuid != 0 + else f"{symbol} {alpha_out_value}" + ) + + ema_value = ema_tao_inflow.get(netuid).tao if netuid in ema_tao_inflow else 0.0 + + return ( + str(netuid), + f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f"{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}] " + f"{get_subnet_name(subnet)}", + f"{price_value} τ/{symbol}", + f"τ {market_cap_value}", + f"τ {emission_tao:,.4f}", + f"τ {ema_value:,.4f}", + alpha_out_cell, + str(mechanisms.get(netuid, 1)), + ) + + def _render_table( + title: str, identity: str, hotkey_value: str, rows: list[tuple[str, ...]] + ) -> None: + table = Table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{title}[/]\n[dim]{identity}:{hotkey_value}\n", + show_footer=False, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + box=box.MINIMAL_DOUBLE_HEAD, + ) + + table.add_column("[bold white]Netuid", style="grey89", justify="center") + table.add_column("[bold white]Name", style="cyan", justify="left") + table.add_column( + "[bold white]Price \n(τ/α)", + style="dark_sea_green2", + justify="left", + ) + table.add_column( + "[bold white]Market Cap \n(α * Price)", + style="steel_blue3", + justify="left", + ) + table.add_column( + "[bold white]Emission (τ)", + style=COLOR_PALETTE["POOLS"]["EMISSION"], + justify="left", + ) + table.add_column( + "[bold white]Net Inflow EMA (τ)", + style=COLOR_PALETTE["POOLS"]["ALPHA_OUT"], + justify="left", + ) + table.add_column( + "[bold white]Stake (α_out)", + style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + justify="left", + ) + table.add_column( + "[bold white]Mechanisms", + style=COLOR_PALETTE["GENERAL"]["SUBHEADING_EXTRA_1"], + justify="center", + ) + + if not rows: + table.add_row("~", "No subnets", "-", "-", "-", "-", "-", "-") + else: + for row in rows: + table.add_row(*row) + + console.print(table) + + # Main function + hotkey_value = hotkey_ss58 + if not hotkey_value: + err_console.print("[red]Hotkey SS58 address is required.[/red]") + return + + block_hash = block_hash or await subtensor.substrate.get_chain_head() + + ( + validator_claims, + subnets, + mechanisms, + ema_tao_inflow, + identity, + ) = await asyncio.gather( + subtensor.get_vali_claim_types_for_hk( + hotkey_ss58=hotkey_value, block_hash=block_hash + ), + subtensor.all_subnets(block_hash=block_hash), + subtensor.get_all_subnet_mechanisms(block_hash=block_hash), + subtensor.get_all_subnet_ema_tao_inflow(block_hash=block_hash), + subtensor.query_identity(hotkey_value), + ) + + root_subnet = next(s for s in subnets if s.netuid == 0) + other_subnets = sorted( + [s for s in subnets if s.netuid != 0], + key=lambda x: (x.alpha_in.tao + x.alpha_out.tao) * x.price.tao, + reverse=True, + ) + sorted_subnets = [root_subnet] + other_subnets + + keep_rows = [] + swap_rows = [] + for subnet in sorted_subnets: + claim_type = validator_claims.get(subnet.netuid, "Keep") + row = _format_subnet_row(subnet, mechanisms, ema_tao_inflow, verbose) + if claim_type == "Swap": + swap_rows.append(row) + else: + keep_rows.append(row) + + if json_output: + output_data = { + "hotkey": hotkey_value, + "claims": {}, + } + for subnet in sorted_subnets: + claim_type = validator_claims.get(subnet.netuid, "Keep") + output_data["claims"][subnet.netuid] = claim_type + json_console.print(json.dumps(output_data)) + + validator_name = identity.get("name", "Unknown") + _render_table("Keep", validator_name, hotkey_value, keep_rows) + _render_table("[red]Swap[/red]", validator_name, hotkey_value, swap_rows) + return True + + +async def set_validator_claim_type( + wallet, + subtensor, + keep: Optional[str] = None, + swap: Optional[str] = None, + keep_all: bool = False, + swap_all: bool = False, + prompt: bool = True, + proxy: Optional[str] = None, +) -> bool: + """ + Configures the validator claim preference (Keep vs Swap) for subnets. + + Allows bulk updating of claim types for multiple subnets. Subnets set to 'Keep' will accumulate + emissions as Alpha (subnet token), while 'Swap' will automatically convert emissions to TAO. + + Operates in two modes: + 1. CLI Mode: Updates specific ranges via `--keep` and `--swap` flags. + 2. Interactive Mode: Launches a claim selector if no range flags are provided. + + Args: + wallet: The wallet configuration. + subtensor: The subtensor interface. + keep: Range info string for subnets to set to 'Keep'. + swap: Range info string for subnets to set to 'Swap'. + keep_all: If True, sets all valid subnets to 'Keep'. + swap_all: If True, sets all valid subnets to 'Swap'. + prompt: If True, requires confirmation before submitting extrinsic. + proxy: Optional proxy address for signing. + json_output: If True, outputs result as JSON. + + Returns: + bool: True if the operation succeeded, False otherwise. + """ + + def _render_current_claims( + state: dict[int, str], + identity: dict = None, + ss58: str = None, + ): + validator_name = identity.get("name", "Unknown") + header_text = ( + f"[dim]Validator:[/dim] [bold cyan]{validator_name}[/bold cyan]\n" + f"[dim]({ss58})[/dim]" + ) + console.print(header_text, "\n") + + default_list = sorted([n for n, t in state.items() if t == "Default"]) + keep_list = sorted([n for n, t in state.items() if t == "Keep"]) + swap_list = sorted([n for n, t in state.items() if t == "Swap"]) + + default_str = group_subnets(default_list) if default_list else "[dim]None[/dim]" + keep_str = group_subnets(keep_list) if keep_list else "[dim]None[/dim]" + swap_str = group_subnets(swap_list) if swap_list else "[dim]None[/dim]" + + default_panel = Panel( + default_str, + title="[bold blue]Default (Keep - α)[/bold blue]", + border_style="blue", + expand=False, + ) + keep_panel = Panel( + keep_str, + title="[bold green]Keep (α)[/bold green]", + border_style="green", + expand=False, + ) + swap_panel = Panel( + swap_str, + title="[bold red]Swap (τ)[/bold red]", + border_style="red", + expand=False, + ) + + top_row = Columns([keep_panel, swap_panel], expand=False, equal=True) + + total = len(state) + default_count = len(default_list) + keep_count = len(keep_list) + swap_count = len(swap_list) + + if total > 0: + effective_keep_count = keep_count + default_count + + keep_pct = effective_keep_count / total + bar_width = 30 + keep_chars = int(bar_width * keep_pct) + swap_chars = bar_width - keep_chars + + bar_visual = ( + f"[{'green' if effective_keep_count > 0 else 'dim'}]" + f"{'█' * keep_chars}[/]" + f"[{'red' if swap_count > 0 else 'dim'}]" + f"{'█' * swap_chars}[/]" + ) + + dist_text = ( + f"\n[bold]Distribution:[/bold] {bar_visual} " + f"[green]{effective_keep_count}[/green] vs [red]{swap_count}[/red]\n" + ) + else: + dist_text = "" + + console.print(Group(top_row, default_panel, Text.from_markup(dist_text))) + + def _print_changes_table(calls: list[tuple[int, str]]): + table = Table(title="Pending Root Claim Changes", box=box.SIMPLE_HEAD, width=50) + table.add_column("Netuid", justify="center", style="cyan") + table.add_column("New Type", justify="center") + + for netuid, new_type in sorted(calls, key=lambda x: x[0]): + color = "green" if new_type == "Keep" else "red" + table.add_row(str(netuid), f"[{color}]{new_type}[/{color}]") + + console.print("\n\n", table) + + async def _execute_claim_change_calls( + calls: list[tuple[int, str]], + ) -> bool: + extrinsic_calls = [] + for netuid, claim_type in calls: + type_arg = {claim_type: None} + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="set_validator_claim_type", + call_params={ + "hotkey": wallet.hotkey.ss58_address, + "netuid": netuid, + "new_claim_type": type_arg, + }, + ) + extrinsic_calls.append(call) + + with console.status( + ":satellite: Submitting updates...", + spinner="earth", + ): + if len(extrinsic_calls) == 1: + final_call = extrinsic_calls[0] + else: + final_call = await subtensor.substrate.compose_call( + call_module="Utility", + call_function="batch_all", + call_params={"calls": extrinsic_calls}, + ) + + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + final_call, wallet, proxy=proxy + ) + + if success: + console.print( + "[green]:white_check_mark: Successfully updated validator claim types![/green]" + ) + await print_extrinsic_id(ext_receipt) + return True + else: + err_console.print( + f"[red]:cross_mark: Transaction Failed: {err_msg}[/red]" + ) + return False + + def _interactive_claim_selector( + state: dict[int, str], + all_netuids: list[int], + identity: dict = None, + ss58: str = None, + ) -> Optional[dict[int, str]]: + working_state = {} + for n in all_netuids: + working_state[n] = state.get(n, "Default") + + while True: + console.print("\n") + _render_current_claims(working_state, identity, ss58) + help_table = Table( + box=box.SIMPLE_HEAVY, + show_header=True, + header_style="bold white", + expand=False, + ) + help_table.add_column("Command", style="cyan", no_wrap=True) + help_table.add_column("Description", style="dim") + + help_table.add_row("keep ", "Move subnets to Keep (e.g. '1,3-5')") + help_table.add_row("swap ", "Move subnets to Swap (e.g. '2,10')") + help_table.add_row( + "keep-all / swap-all", "Move ALL subnets to Keep or Swap" + ) + help_table.add_row("[green]done[/green]", "Finish and Apply changes") + help_table.add_row("[red]q / quit[/red]", "Cancel operation") + + console.print(help_table) + + cmd = Prompt.ask("Enter command").strip().lower() + + if cmd in ("q", "quit", "exit"): + return None + + if cmd == "done": + return working_state + + if cmd == "keep-all": + for n in working_state: + working_state[n] = "Keep" + continue + + if cmd == "swap-all": + for n in working_state: + working_state[n] = "Swap" + continue + + parts = cmd.split(" ", 1) + if len(parts) < 2: + console.print("[red]Invalid command format.[/red]") + continue + + action, ranges = parts[0], parts[1] + try: + selected_netuids = parse_subnet_range( + ranges, total_subnets=len(all_netuids) + ) + valid = [n for n in selected_netuids if n in working_state] + + if action == "keep": + for n in valid: + working_state[n] = "Keep" + elif action == "swap": + for n in valid: + working_state[n] = "Swap" + else: + console.print(f"[red]Unknown action '{action}'[/red]") + + except ValueError as e: + console.print(f"[red]Error parsing range: {e}[/red]") + + # Main function + if keep_all and swap_all: + err_console.print( + "[red]Cannot specify both --keep-all and --swap-all flags.[/red]" + ) + return False + + with console.status(":satellite: Fetching current state...", spinner="earth"): + block_hash = await subtensor.substrate.get_chain_head() + current_claims, all_netuids, identity, testing = await asyncio.gather( + subtensor.get_vali_claim_types_for_hk( + hotkey_ss58=wallet.hotkey.ss58_address, block_hash=block_hash + ), + subtensor.get_all_subnet_netuids(block_hash=block_hash), + subtensor.query_identity(wallet.coldkeypub.ss58_address), + subtensor.get_all_vali_claim_types(block_hash=block_hash), + ) + valid_subnets = [n for n in all_netuids if n != 0] + + target_keep: set[int] = set() + target_swap: set[int] = set() + + if keep_all: + target_keep = set(valid_subnets) + elif swap_all: + target_swap = set(valid_subnets) + + def process_ranges(arg_value, arg_name, target_set): + try: + parsed = parse_subnet_range(arg_value, total_subnets=len(valid_subnets)) + valid = {s for s in parsed if s in valid_subnets} + invalid = [s for s in parsed if s not in valid_subnets] + if invalid: + console.print( + f"[yellow]Ignored invalid/unknown subnets for {arg_name}: {invalid}[/yellow]" + ) + target_set.update(valid) + return True + except ValueError as e: + err_console.print(f"[red]Invalid --{arg_name} format: {e}[/red]") + return False + + if keep and not process_ranges(keep, "keep", target_keep): + return False + + if swap and not process_ranges(swap, "swap", target_swap): + return False + + # Check for duplicate entries in keep and swap + intersection = target_keep.intersection(target_swap) + if intersection: + err_console.print( + f"[red]Subnets cannot be both Keep and Swap: {intersection}[/red]" + ) + return False + + calls_to_make = [] + is_interactive = not (keep_all or swap_all or keep or swap) + # Interactive mode + if is_interactive: + state = {} + for netuid in valid_subnets: + state[netuid] = current_claims.get(netuid, "Default") + + final_state = _interactive_claim_selector( + state, list(valid_subnets), identity, wallet.coldkeypub.ss58_address + ) + if final_state is None: + console.print("[yellow]Operation cancelled.[/yellow]") + return False + + for netuid, new_type in final_state.items(): + if new_type == "Default": + continue + current_type = current_claims.get(netuid, "Default") + if new_type != current_type: + calls_to_make.append((netuid, new_type)) + + # Non-interactive + else: + for netuid in target_keep: + curr_type = current_claims.get(netuid, "Default") + if curr_type != "Keep": + calls_to_make.append((netuid, "Keep")) + + for netuid in target_swap: + curr_type = current_claims.get(netuid, "Default") + if curr_type != "Swap": + calls_to_make.append((netuid, "Swap")) + + final_state = {} + for n in valid_subnets: + final_state[n] = current_claims.get(n, "Default") + for n in target_keep: + final_state[n] = "Keep" + for n in target_swap: + final_state[n] = "Swap" + + _render_current_claims(final_state, identity, wallet.coldkeypub.ss58_address) + + if not calls_to_make: + console.print( + "[green]Desired state matches current chain state. No changes needed.[/green]" + ) + return True + + if prompt: + _print_changes_table(calls_to_make) + if not Confirm.ask("Do you want to apply these changes?"): + console.print("[yellow]Cancelled.[/yellow]") + return False + + if not (unlock := unlock_key(wallet)).success: + err_console.print(f"[red]Failed to unlock wallet: {unlock.message}[/red]") + return False + + return await _execute_claim_change_calls(calls_to_make) diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 526037bc6..8ff7030b8 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -1068,13 +1068,11 @@ async def show_root(): root_state, identities, old_identities, - root_claim_types, ) = await asyncio.gather( subtensor.all_subnets(block_hash=block_hash), subtensor.get_subnet_state(netuid=0, block_hash=block_hash), subtensor.query_all_identities(block_hash=block_hash), subtensor.get_delegate_identities(block_hash=block_hash), - subtensor.get_all_coldkeys_claim_type(block_hash=block_hash), ) root_info = next((s for s in all_subnets if s.netuid == 0), None) if root_info is None: @@ -1134,11 +1132,6 @@ async def show_root(): style=COLOR_PALETTE["GENERAL"]["SYMBOL"], justify="left", ) - table.add_column( - "[bold white]Claim Type", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], - justify="center", - ) sorted_hotkeys = sorted( enumerate(root_state.hotkeys), @@ -1170,9 +1163,6 @@ async def show_root(): ) coldkey_ss58 = root_state.coldkeys[idx] - claim_type_info = root_claim_types.get(coldkey_ss58, {"type": "Swap"}) - total_subnets = len([n for n in all_subnets if n != 0]) - claim_type = format_claim_type_for_root(claim_type_info, total_subnets) sorted_rows.append( ( @@ -1194,7 +1184,6 @@ async def show_root(): if not verbose else f"{root_state.coldkeys[idx]}", # Coldkey validator_identity, # Identity - claim_type, # Root Claim Type ) ) sorted_hks_delegation.append(root_state.hotkeys[idx]) @@ -1246,8 +1235,6 @@ async def show_root(): - Emission: The emission accrued to this hotkey across all subnets every block measured in TAO. - Hotkey: The hotkey ss58 address. - Coldkey: The coldkey ss58 address. - - Root Claim: The root claim type for this coldkey. 'Swap' converts Alpha to TAO every epoch. 'Keep' keeps Alpha emissions. - 'Keep (count)' indicates how many subnets this coldkey is keeping Alpha emissions for. """ ) if delegate_selection: @@ -1300,7 +1287,7 @@ async def show_subnet( identities, old_identities, current_burn_cost, - root_claim_types, + validator_claim_types, ema_tao_inflow, ) = await asyncio.gather( subtensor.subnet(netuid=netuid_, block_hash=block_hash), @@ -1309,7 +1296,7 @@ async def show_subnet( subtensor.get_hyperparameter( param_name="Burn", netuid=netuid_, block_hash=block_hash ), - subtensor.get_all_coldkeys_claim_type(block_hash=block_hash), + subtensor.get_all_vali_claim_types(block_hash=block_hash), subtensor.get_subnet_ema_tao_inflow( netuid=netuid_, block_hash=block_hash ), @@ -1420,14 +1407,19 @@ async def show_subnet( # Modify tao stake with TAO_WEIGHT tao_stake = metagraph_info.tao_stake[idx] * TAO_WEIGHT - # Get claim type for this coldkey if applicable TAO stake - coldkey_ss58 = metagraph_info.coldkeys[idx] - claim_type_info = {"type": "Swap"} # Default - claim_type = "-" - + # Get claim type for this hotkey if applicable TAO stake + hotkey_ss58 = metagraph_info.hotkeys[idx] + claim_type_value = None + claim_display = "-" if tao_stake.tao > 0: - claim_type_info = root_claim_types.get(coldkey_ss58, {"type": "Swap"}) - claim_type = format_claim_type_for_subnet(claim_type_info, netuid_) + claim_type_value = validator_claim_types.get(hotkey_ss58, {}).get( + netuid_, "Keep" + ) + claim_display = ( + f"[green]{claim_type_value}[/green]" + if claim_type_value == "Keep" + else f"[red]{claim_type_value}[/red]" + ) rows.append( ( @@ -1451,7 +1443,7 @@ async def show_subnet( if not verbose else f"{metagraph_info.coldkeys[idx]}", # Coldkey uid_identity, # Identity - claim_type, # Root Claim Type + claim_display, # Claim Type ) ) json_out_rows.append( @@ -1468,12 +1460,7 @@ async def show_subnet( "hotkey": metagraph_info.hotkeys[idx], "coldkey": metagraph_info.coldkeys[idx], "identity": uid_identity, - "claim_type": claim_type_info.get("type") - if tao_stake.tao > 0 - else None, - "claim_type_subnets": claim_type_info.get("subnets") - if claim_type_info.get("type") == "KeepSubnets" - else None, + "claim_type": claim_type_value, } ) diff --git a/tests/e2e_tests/test_claim_types.py b/tests/e2e_tests/test_claim_types.py new file mode 100644 index 000000000..ea5731950 --- /dev/null +++ b/tests/e2e_tests/test_claim_types.py @@ -0,0 +1,170 @@ +""" +Verify commands: + +* btcli stake set-claim swap +* btcli stake set-claim keep +* btcli stake set-claim keep --netuids 2-4 +* btcli stake set-claim delegated +* btcli stake set-validator-claims --swap 2-4 +* btcli stake set-validator-claims --keep 1-3 +""" + + +def test_claim_type_flows(local_chain, wallet_setup): + """ + Cover root claim type transitions (Swap, Keep, KeepSubnets, Delegated) + and validator claim type settings using the CLI. + """ + + # Wallet setup + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + "//Alice" + ) + + # Create multiple subnets + for netuid in [2, 3, 4]: + 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 f"✅ Registered subnetwork with netuid: {netuid}" in result.stdout + + # 1) Set to Swap (as a holder) + swap_result = exec_command_alice( + command="stake", + sub_command="set-claim", + extra_args=[ + "swap", + "--wallet-name", + wallet_alice.name, + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Successfully changed claim type" in swap_result.stdout + + # 2) Set to Keep (as a holder) + keep_result = exec_command_alice( + command="stake", + sub_command="set-claim", + extra_args=[ + "keep", + "--wallet-name", + wallet_alice.name, + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Successfully changed claim type" in keep_result.stdout + + # 3) Set to KeepSubnets (as a holder) + keep_subnets_result = exec_command_alice( + command="stake", + sub_command="set-claim", + extra_args=[ + "keep", + "--netuids", + "1-3", + "--wallet-name", + wallet_alice.name, + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Successfully changed claim type" in keep_subnets_result.stdout + + # 4) Set to Delegated (as a holder) + delegated_result = exec_command_alice( + command="stake", + sub_command="set-claim", + extra_args=[ + "delegated", + "--wallet-name", + wallet_alice.name, + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "✅ Successfully changed claim type" in delegated_result.stdout + + # 5) Validator claim (as a validator) + validator_claim_swap_result = exec_command_alice( + command="stake", + sub_command="set-validator-claims", + extra_args=[ + "--swap", + "1-3", + "--wallet-name", + wallet_alice.name, + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert ( + "✅ Successfully updated validator claim types" + in validator_claim_swap_result.stdout + ) + + # 6) Validator claim (as a validator) + validator_claim_keep_result = exec_command_alice( + command="stake", + sub_command="set-validator-claims", + extra_args=[ + "--keep", + "1-3", + "--wallet-name", + wallet_alice.name, + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert ( + "✅ Successfully updated validator claim types" + in validator_claim_keep_result.stdout + )