diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f96f8ad..d59961b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ **Full Changelog**: https://github.com/opentensor/btcli/compare/v9.13.1...v9.14.0 +# 9.13.2 /2025-10-20 +* Add warning and confirmation for `wallet swap_hotkey --netuid 0` to prevent accidental misuse. Using `--netuid 0` only swaps the hotkey on the root network (netuid 0) and does NOT move child hotkey delegation mappings. This is not a full swap across all subnets. Updated documentation and added comprehensive unit tests to clarify proper usage. + # 9.13.1 /2025-10-14 * Fix for complicated (user_liquidity_enabled) hyperparams by @thewhaleking in https://github.com/opentensor/btcli/pull/652 * Fixes a number of type annotations by @thewhaleking in https://github.com/opentensor/btcli/pull/653 diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 220de3d40..e54a5e690 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -2286,15 +2286,44 @@ def wallet_swap_hotkey( - Make sure that your original key pair (coldkeyA, hotkeyA) is already registered. - Make sure that you use a newly created hotkeyB in this command. A hotkeyB that is already registered cannot be used in this command. - - You can specify the netuid for which you want to swap the hotkey for. If it is not defined, the swap will be initiated for all subnets. + - If NO netuid is specified, the swap will be initiated for ALL subnets (recommended for most users). + - If a SPECIFIC netuid is specified (e.g., --netuid 1), the swap will only affect that particular subnet. + - WARNING: Using --netuid 0 will ONLY swap on the root network (netuid 0), NOT a full swap across all subnets. Use without --netuid for full swap. - Finally, note that this command requires a fee of 1 TAO for recycling and this fee is taken from your wallet (coldkeyA). EXAMPLE + Full swap across all subnets (recommended): + [green]$[/green] btcli wallet swap_hotkey destination_hotkey_name --wallet-name your_wallet_name --wallet-hotkey original_hotkey + + Swap for a specific subnet only: [green]$[/green] btcli wallet swap_hotkey destination_hotkey_name --wallet-name your_wallet_name --wallet-hotkey original_hotkey --netuid 1 """ netuid = get_optional_netuid(netuid, all_netuids) self.verbosity_handler(quiet, verbose, json_output) + + # Warning for netuid 0 - only swaps on root network, not a full swap + if netuid == 0 and prompt: + console.print( + "\n[bold yellow]⚠️ WARNING: Using --netuid 0 for swap_hotkey[/bold yellow]\n" + ) + console.print( + "[yellow]Specifying --netuid 0 will ONLY swap the hotkey on the root network (netuid 0).[/yellow]\n" + ) + console.print( + "[yellow]It will NOT move child hotkey delegation mappings on root.[/yellow]\n" + ) + console.print( + f"[bold green]btcli wallet swap_hotkey {destination_hotkey_name or ''} " + f"--wallet-name {wallet_name or ''} " + f"--wallet-hotkey {wallet_hotkey or ''}[/bold green]\n" + ) + + if not Confirm.ask( + "Are you SURE you want to proceed with --netuid 0 (only root network swap)?", + default=False, + ): + return original_wallet = self.wallet_ask( wallet_name, wallet_path, diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py index b7933e226..a17ed8406 100644 --- a/tests/unit_tests/test_cli.py +++ b/tests/unit_tests/test_cli.py @@ -1,8 +1,8 @@ import pytest import typer -from bittensor_cli.cli import parse_mnemonic -from unittest.mock import AsyncMock, patch, MagicMock +from bittensor_cli.cli import parse_mnemonic, CLIManager +from unittest.mock import AsyncMock, patch, MagicMock, Mock def test_parse_mnemonic(): @@ -51,3 +51,180 @@ async def test_subnet_sets_price_correctly(): ) mock_price_method.assert_awaited_once_with(netuid=1, block_hash=None) assert subnet_info.price == mock_price + + +@patch("bittensor_cli.cli.Confirm") +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_0_warning_with_prompt(mock_console, mock_confirm): + """ + Test that swap_hotkey shows warning when netuid=0 and prompt=True, + and exits when user declines confirmation + """ + # Setup + cli_manager = CLIManager() + mock_confirm.ask.return_value = False # User declines + + # Mock dependencies to prevent actual execution + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + ): + mock_wallet_ask.return_value = Mock() + + # Call the method with netuid=0 and prompt=True + result = cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=0, + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=True, + json_output=False, + ) + + # Assert: Warning was displayed (4 console.print calls for the warning) + assert mock_console.print.call_count >= 4 + warning_calls = [str(call) for call in mock_console.print.call_args_list] + assert any( + "WARNING" in str(call) and "netuid 0" in str(call) for call in warning_calls + ) + assert any("root network" in str(call) for call in warning_calls) + assert any( + "NOT move child hotkey delegation" in str(call) for call in warning_calls + ) + + # Assert: User was asked to confirm + mock_confirm.ask.assert_called_once() + confirm_message = mock_confirm.ask.call_args[0][0] + assert "SURE" in confirm_message + assert "netuid 0" in confirm_message or "root network" in confirm_message + + # Assert: Function returned None (early exit) because user declined + assert result is None + + +@patch("bittensor_cli.cli.Confirm") +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_0_proceeds_with_confirmation(mock_console, mock_confirm): + """ + Test that swap_hotkey proceeds when netuid=0 and user confirms + """ + # Setup + cli_manager = CLIManager() + mock_confirm.ask.return_value = True # User confirms + + # Mock dependencies + 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") as mock_run_command, + ): + mock_wallet = Mock() + mock_wallet_ask.return_value = mock_wallet + + # Call the method + cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=0, + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=True, + json_output=False, + ) + + # Assert: Warning was shown and confirmed + mock_confirm.ask.assert_called_once() + + # Assert: Command execution proceeded + mock_run_command.assert_called_once() + + +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_0_no_warning_with_no_prompt(mock_console): + """ + Test that swap_hotkey does NOT show warning when prompt=False + """ + # Setup + cli_manager = CLIManager() + + # Mock dependencies + 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"), + ): + mock_wallet = Mock() + mock_wallet_ask.return_value = mock_wallet + + # Call the method with prompt=False + cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=0, + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=False, # No prompt + json_output=False, + ) + + # Assert: No warning messages about netuid 0 + warning_calls = [str(call) for call in mock_console.print.call_args_list] + assert not any( + "WARNING" in str(call) and "netuid 0" in str(call) for call in warning_calls + ) + + +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_1_no_warning(mock_console): + """ + Test that swap_hotkey does NOT show warning when netuid != 0 + """ + # Setup + cli_manager = CLIManager() + + # Mock dependencies + 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"), + ): + mock_wallet = Mock() + mock_wallet_ask.return_value = mock_wallet + + # Call the method with netuid=1 + cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=1, # Not 0 + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=True, + json_output=False, + ) + + # Assert: No warning messages about netuid 0 + warning_calls = [str(call) for call in mock_console.print.call_args_list] + assert not any( + "WARNING" in str(call) and "netuid 0" in str(call) for call in warning_calls + )