Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion bittensor_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<destination_hotkey>'} "
f"--wallet-name {wallet_name or '<wallet_name>'} "
f"--wallet-hotkey {wallet_hotkey or '<original_hotkey>'}[/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,
Expand Down
181 changes: 179 additions & 2 deletions tests/unit_tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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
)
Loading