From 4515343034cbadde5b278eb88f0aaf2d6149026f Mon Sep 17 00:00:00 2001 From: eureka928 Date: Wed, 8 Apr 2026 15:37:31 +0200 Subject: [PATCH 1/3] refactor: simplify set_children and fix 3 bugs in children_hotkeys - Unified single-netuid and all-netuids paths into one loop in set_children - Replaced raw ValueError with print_error + JSON error output - Always render table in get_children even when no children exist - fix(critical): revoke_children passed None instead of netuid_ in all-netuids loop - fix: restored proportions > 1.0 validation removed during refactor - fix: get_children all-netuids path now returns data instead of None --- bittensor_cli/cli.py | 1 + .../src/commands/stake/children_hotkeys.py | 122 +++++++++--------- 2 files changed, 65 insertions(+), 58 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index ab92d1753..db56b7e9a 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -6414,6 +6414,7 @@ def stake_set_children( ask_for=[WO.NAME, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) + logger.debug( "args:\n" f"network: {network}\n" diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index 69d4083e4..b096ea011 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -493,16 +493,21 @@ async def _render_table( f"Failed to get children from subtensor {netuid_}: {err_mg}" ) await _render_table(get_hotkey_pub_ss58(wallet), netuid_children_tuples) + return netuid_children_tuples else: success, children, err_mg = await subtensor.get_children( get_hotkey_pub_ss58(wallet), netuid ) if not success: print_error(f"Failed to get children from subtensor: {err_mg}") + + # Always render the table, even if there are no children if children: netuid_children_tuples = [(netuid, children)] - await _render_table(get_hotkey_pub_ss58(wallet), netuid_children_tuples) + else: + netuid_children_tuples = [] + await _render_table(get_hotkey_pub_ss58(wallet), netuid_children_tuples) return children @@ -518,96 +523,97 @@ async def set_children( json_output: bool = False, proxy: Optional[str] = None, ): - """Set children hotkeys.""" - # TODO holy shit I hate this. It needs to be rewritten. + """ + Set children hotkeys with proportions for a parent hotkey on specified subnet(s). + + Args: + wallet: Wallet containing the coldkey for signing transactions. + subtensor: SubtensorInterface instance for blockchain interaction. + children: List of child hotkey SS58 addresses. + proportions: List of stake proportions (floats between 0 and 1). + netuid: Optional specific subnet ID. If None, operates on ALL non-root subnets. + wait_for_inclusion: Wait for transaction to be included in a block. + wait_for_finalization: Wait for transaction to be finalized. + prompt: Prompt user for confirmation before submitting transactions. + json_output: Output results as JSON instead of formatted text. + proxy: Optional proxy SS58 address for transaction submission. + """ + parent_hotkey = get_hotkey_pub_ss58(wallet) + # Validate children SS58 addresses - # TODO check to see if this should be allowed to be specified by user instead of pulling from wallet - hotkey = get_hotkey_pub_ss58(wallet) for child in children: if not is_valid_ss58_address(child): - print_error(f"Invalid SS58 address: {child}") + msg = f"Invalid SS58 address: {child}" + print_error(msg) + if json_output: + json_console.print(json.dumps({"success": False, "message": msg})) return - if child == hotkey: - print_error("Cannot set yourself as a child.") + if child == parent_hotkey: + msg = "Cannot set yourself as a child." + print_error(msg) + if json_output: + json_console.print(json.dumps({"success": False, "message": msg})) return total_proposed = sum(proportions) if total_proposed > 1: - raise ValueError( + msg = ( f"Invalid proportion: The sum of all proportions cannot be greater than 1. " f"Proposed sum of proportions is {total_proposed}." ) + print_error(msg) + if json_output: + json_console.print(json.dumps({"success": False, "message": msg})) + return + children_with_proportions = list(zip(proportions, children)) - successes = {} + + # Determine netuids if netuid is not None: + netuids = [netuid] + else: + all_netuids = await subtensor.get_all_subnet_netuids() + netuids = [n for n in all_netuids if n != 0] + + # Execute operations + dict_output = {} + for netuid_ in netuids: success, message, ext_id = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, - netuid=netuid, - hotkey=hotkey, + netuid=netuid_, + hotkey=parent_hotkey, proxy=proxy, children_with_proportions=children_with_proportions, prompt=prompt, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) - successes[netuid] = { + dict_output[netuid_] = { "success": success, - "error": message, "completion_block": None, + "error": message, "set_block": None, "extrinsic_identifier": ext_id, } - # Result + if success: - if wait_for_inclusion and wait_for_finalization: - current_block, completion_block = await get_childkey_completion_block( - subtensor, netuid - ) - successes[netuid]["completion_block"] = completion_block - successes[netuid]["set_block"] = current_block - console.print( - f"Your childkey request has been submitted. It will be completed around block {completion_block}. " - f"The current block is {current_block}" - ) - print_success("Set children hotkeys.") - else: - print_error(f"Unable to set children hotkeys. {message}") - else: - # set children on all subnets that parent is registered on - netuids = await subtensor.get_all_subnet_netuids() - for netuid_ in netuids: - if netuid_ == 0: # dont include root network - continue - console.print(f"Setting children on netuid {netuid_}.") - success, message, ext_id = await set_children_extrinsic( - subtensor=subtensor, - wallet=wallet, - netuid=netuid_, - hotkey=hotkey, - proxy=proxy, - children_with_proportions=children_with_proportions, - prompt=prompt, - wait_for_inclusion=True, - wait_for_finalization=False, - ) current_block, completion_block = await get_childkey_completion_block( subtensor, netuid_ ) - successes[netuid_] = { - "success": success, - "error": message, - "completion_block": completion_block, - "set_block": current_block, - "extrinsic_identifier": ext_id, - } + dict_output[netuid_]["set_block"] = current_block + dict_output[netuid_]["completion_block"] = completion_block console.print( - f"Your childkey request for netuid {netuid_} has been submitted. It will be completed around " - f"block {completion_block}. The current block is {current_block}." + f":white_heavy_check_mark: Your childkey request for netuid {netuid_} has been submitted. " + f"It will be completed around block {completion_block}. The current block is {current_block}" + ) + else: + print_error( + f"Failed to set children hotkeys for netuid {netuid_}: {message}" ) - print_success("Sent set children request for all subnets.") + if json_output: - json_console.print(json.dumps(successes)) + json_console.print(json.dumps(dict_output)) async def revoke_children( @@ -667,7 +673,7 @@ async def revoke_children( success, message, ext_id = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, - netuid=netuid, # TODO should this be able to allow netuid = None ? + netuid=netuid_, hotkey=get_hotkey_pub_ss58(wallet), children_with_proportions=[], proxy=proxy, From 8e0be894ef586964cf287a9a897180a71979e5f4 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Wed, 8 Apr 2026 15:37:41 +0200 Subject: [PATCH 2/3] test: add unit tests for children_hotkeys bug fixes 6 tests covering each bug fix, verified by reverting fixes and confirming failures: - test_revoke_all_netuids_passes_each_netuid - test_set_children_rejects_proportions_over_one - test_set_children_valid_proportions_proceeds - test_get_children_all_netuids_returns_data - test_get_children_single_netuid_returns_list - test_prepare_child_proportions --- tests/unit_tests/test_children_hotkeys.py | 157 ++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/unit_tests/test_children_hotkeys.py diff --git a/tests/unit_tests/test_children_hotkeys.py b/tests/unit_tests/test_children_hotkeys.py new file mode 100644 index 000000000..0d94ad38e --- /dev/null +++ b/tests/unit_tests/test_children_hotkeys.py @@ -0,0 +1,157 @@ +"""Unit tests for children_hotkeys bug fixes.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bittensor_cli.src.commands.stake.children_hotkeys import ( + get_children, + prepare_child_proportions, + revoke_children, + set_children, +) +from .conftest import HOTKEY_SS58, DEST_SS58, ALT_HOTKEY_SS58, _make_successful_receipt + +PATCH_UNLOCK = patch( + "bittensor_cli.src.commands.stake.children_hotkeys.unlock_key", + side_effect=lambda *a, **k: SimpleNamespace(success=True, message=""), +) +PATCH_HOTKEY = patch( + "bittensor_cli.src.commands.stake.children_hotkeys.get_hotkey_pub_ss58", + return_value=HOTKEY_SS58, +) +PATCH_PRINT_EXT = patch( + "bittensor_cli.src.commands.stake.children_hotkeys.print_extrinsic_id", + new_callable=AsyncMock, +) + + +def _success_receipt(): + r = _make_successful_receipt() + return (True, "Included.", r) + + +# -- Bug #1: revoke_children all-netuids passed None instead of netuid_ ------ + + +@pytest.mark.asyncio +async def test_revoke_all_netuids_passes_each_netuid(mock_wallet, mock_subtensor): + mock_subtensor.get_all_subnet_netuids = AsyncMock(return_value=[0, 1, 2]) + mock_subtensor.sign_and_send_extrinsic = AsyncMock(return_value=_success_receipt()) + mock_subtensor.query = AsyncMock(return_value=100) + mock_subtensor.get_hyperparameter = AsyncMock(return_value=360) + + with PATCH_UNLOCK, PATCH_HOTKEY, PATCH_PRINT_EXT: + await revoke_children( + wallet=mock_wallet, + subtensor=mock_subtensor, + netuid=None, + prompt=False, + wait_for_inclusion=True, + wait_for_finalization=False, + ) + + netuids_sent = [ + c.kwargs.get("call_params", c[1].get("call_params", {})).get("netuid") + for c in mock_subtensor.substrate.compose_call.call_args_list + ] + assert None not in netuids_sent, f"None passed as netuid: {netuids_sent}" + assert set(netuids_sent) == {1, 2} + + +# -- Bug #2: set_children proportions > 1.0 must be rejected ----------------- + + +@pytest.mark.asyncio +async def test_set_children_rejects_proportions_over_one(mock_wallet, mock_subtensor): + with PATCH_HOTKEY: + await set_children( + wallet=mock_wallet, + subtensor=mock_subtensor, + children=[DEST_SS58, ALT_HOTKEY_SS58], + proportions=[0.6, 0.5], + netuid=1, + prompt=False, + ) + mock_subtensor.substrate.compose_call.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_children_valid_proportions_proceeds(mock_wallet, mock_subtensor): + mock_subtensor.sign_and_send_extrinsic = AsyncMock(return_value=_success_receipt()) + mock_subtensor.query = AsyncMock(return_value=100) + mock_subtensor.get_hyperparameter = AsyncMock(return_value=360) + + with PATCH_UNLOCK, PATCH_HOTKEY, PATCH_PRINT_EXT: + await set_children( + wallet=mock_wallet, + subtensor=mock_subtensor, + children=[DEST_SS58], + proportions=[0.5], + netuid=1, + prompt=False, + ) + mock_subtensor.substrate.compose_call.assert_called() + + +# -- Bug #3: get_children all-netuids returned None instead of data ----------- + + +@pytest.mark.asyncio +async def test_get_children_all_netuids_returns_data(mock_wallet, mock_subtensor): + children_data = [(9223372036854775808, DEST_SS58)] + + async def _get_children(hotkey, netuid): + if netuid == 0: + return (True, [], "") + return (True, children_data, "") + + mock_subtensor.get_all_subnet_netuids = AsyncMock(return_value=[0, 1]) + mock_subtensor.get_children = AsyncMock(side_effect=_get_children) + mock_subtensor.get_total_stake_for_hotkey = AsyncMock( + return_value={ + HOTKEY_SS58: {1: MagicMock(tao=100.0)}, + DEST_SS58: {1: MagicMock(tao=50.0)}, + } + ) + + with ( + PATCH_HOTKEY, + patch( + "bittensor_cli.src.commands.stake.children_hotkeys.get_childkey_take", + new_callable=AsyncMock, + return_value=0, + ), + ): + result = await get_children( + wallet=mock_wallet, + subtensor=mock_subtensor, + netuid=None, + ) + + assert result is not None + assert len(result) == 1 + assert result[0][0] == 1 # netuid + + +@pytest.mark.asyncio +async def test_get_children_single_netuid_returns_list(mock_wallet, mock_subtensor): + mock_subtensor.get_children = AsyncMock(return_value=(True, [], "")) + + with PATCH_HOTKEY: + result = await get_children( + wallet=mock_wallet, + subtensor=mock_subtensor, + netuid=1, + ) + assert result == [] + + +# -- prepare_child_proportions ------------------------------------------------ + + +def test_prepare_child_proportions(): + result = prepare_child_proportions([(0.3, DEST_SS58), (0.7, ALT_HOTKEY_SS58)]) + assert len(result) == 2 + assert sum(p for p, _ in result) <= 2**64 - 1 From 601da7b553c38c597b0edc078ea1dfa6c0df41ba Mon Sep 17 00:00:00 2001 From: eureka928 Date: Wed, 8 Apr 2026 15:37:52 +0200 Subject: [PATCH 3/3] test: add e2e tests for children hotkeys (set, get, revoke) 5 e2e tests with shared helpers to reduce boilerplate: - test_set_children_single_child - test_set_children_multiple_proportions - test_get_children_json_output - test_get_children_table_output - test_revoke_children (new) --- tests/e2e_tests/test_children_hotkeys.py | 293 ++++++++++++++++++++++- 1 file changed, 282 insertions(+), 11 deletions(-) diff --git a/tests/e2e_tests/test_children_hotkeys.py b/tests/e2e_tests/test_children_hotkeys.py index f286012fd..a99a8a7bf 100644 --- a/tests/e2e_tests/test_children_hotkeys.py +++ b/tests/e2e_tests/test_children_hotkeys.py @@ -1,15 +1,286 @@ +import json import pytest -from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -from bittensor_cli.src.commands.stake.children_hotkeys import ( - get_childkey_completion_block, -) +""" +Verify commands: -@pytest.mark.asyncio -async def test_get_childkey_completion_block(local_chain): - async with SubtensorInterface("ws://127.0.0.1:9945") as subtensor: - current_block, completion_block = await get_childkey_completion_block( - subtensor, 1 - ) - assert (completion_block - current_block) >= 7200 +* btcli stake child get +* btcli stake child set +* btcli stake child revoke +""" + +NETWORK = "ws://127.0.0.1:9945" +NETUID = 2 + + +def _setup_subnet_and_stake(wallet_setup): + """Create Alice wallet, subnet, start emissions, add stake. Returns helpers.""" + kp_alice, w_alice, wp_alice, exec_alice = wallet_setup("//Alice") + + # Create subnet + result = exec_alice( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + wp_alice, + "--network", + NETWORK, + "--wallet-name", + w_alice.name, + "--wallet-hotkey", + w_alice.hotkey_str, + "--subnet-name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", + "--no-prompt", + "--json-output", + "--no-mev-protection", + ], + ) + assert json.loads(result.stdout)["success"] is True + + # Start emissions + exec_alice( + command="subnets", + sub_command="start", + extra_args=[ + "--netuid", + NETUID, + "--wallet-path", + wp_alice, + "--wallet-name", + w_alice.name, + "--hotkey", + w_alice.hotkey_str, + "--network", + NETWORK, + "--no-prompt", + ], + ) + + # Add stake + result = exec_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + NETUID, + "--wallet-path", + wp_alice, + "--wallet-name", + w_alice.name, + "--hotkey", + w_alice.hotkey_str, + "--network", + NETWORK, + "--amount", + "1", + "--unsafe", + "--no-prompt", + "--era", + "144", + "--no-mev-protection", + ], + ) + assert "✅ Finalized" in result.stdout + + return w_alice, wp_alice, exec_alice + + +def _register_on_subnet(wallet, wallet_path, exec_cmd): + result = exec_cmd( + command="subnets", + sub_command="register", + extra_args=[ + "--wallet-path", + wallet_path, + "--wallet-name", + wallet.name, + "--hotkey", + wallet.hotkey_str, + "--netuid", + NETUID, + "--network", + NETWORK, + "--no-prompt", + ], + ) + assert "✅ Registered" in result.stdout or "✅ Already Registered" in result.stdout + + +def _set_children(exec_alice, w_alice, wp_alice, child_addresses, proportions): + """Set children and return parsed JSON output.""" + args = ["set"] + for addr in child_addresses: + args += ["--children", addr] + for prop in proportions: + args += ["--proportions", str(prop)] + args += [ + "--netuid", + NETUID, + "--wallet-path", + wp_alice, + "--wallet-name", + w_alice.name, + "--hotkey", + w_alice.hotkey_str, + "--network", + NETWORK, + "--no-prompt", + "--json-output", + ] + result = exec_alice(command="stake", sub_command="child", extra_args=args) + assert result.stdout.strip(), f"Empty stdout. stderr: {result.stderr}" + output = json.loads(result.stdout) + assert output[str(NETUID)]["success"] is True + return output + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_set_children_single_child(local_chain, wallet_setup): + """Set a single child hotkey with 50% proportion.""" + w_alice, wp_alice, exec_alice = _setup_subnet_and_stake(wallet_setup) + _, w_bob, wp_bob, exec_bob = wallet_setup("//Bob") + _register_on_subnet(w_bob, wp_bob, exec_bob) + + output = _set_children( + exec_alice, + w_alice, + wp_alice, + [w_bob.hotkey.ss58_address], + ["0.5"], + ) + assert output[str(NETUID)]["completion_block"] is not None + assert output[str(NETUID)]["set_block"] is not None + assert isinstance(output[str(NETUID)]["extrinsic_identifier"], str) + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_set_children_multiple_proportions(local_chain, wallet_setup): + """Set multiple children (Bob 25%, Charlie 35%, Dave 20%).""" + w_alice, wp_alice, exec_alice = _setup_subnet_and_stake(wallet_setup) + + children = [] + for uri in ["//Bob", "//Charlie", "//Dave"]: + _, w, wp, exc = wallet_setup(uri) + _register_on_subnet(w, wp, exc) + children.append(w.hotkey.ss58_address) + + output = _set_children( + exec_alice, + w_alice, + wp_alice, + children, + ["0.25", "0.35", "0.20"], + ) + assert output[str(NETUID)]["completion_block"] is not None + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_get_children_json_output(local_chain, wallet_setup): + """Set children then get with --json-output.""" + w_alice, wp_alice, exec_alice = _setup_subnet_and_stake(wallet_setup) + _, w_bob, wp_bob, exec_bob = wallet_setup("//Bob") + _register_on_subnet(w_bob, wp_bob, exec_bob) + _set_children(exec_alice, w_alice, wp_alice, [w_bob.hotkey.ss58_address], ["0.5"]) + + result = exec_alice( + command="stake", + sub_command="child", + extra_args=[ + "get", + "--netuid", + NETUID, + "--wallet-path", + wp_alice, + "--wallet-name", + w_alice.name, + "--hotkey", + w_alice.hotkey_str, + "--network", + NETWORK, + "--json-output", + ], + ) + output = json.loads(result.stdout) + assert isinstance(output, list) + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_get_children_table_output(local_chain, wallet_setup): + """Get children without JSON — verify table output is non-empty.""" + w_alice, wp_alice, exec_alice = _setup_subnet_and_stake(wallet_setup) + _, w_bob, wp_bob, exec_bob = wallet_setup("//Bob") + _register_on_subnet(w_bob, wp_bob, exec_bob) + _set_children(exec_alice, w_alice, wp_alice, [w_bob.hotkey.ss58_address], ["0.5"]) + + result = exec_alice( + command="stake", + sub_command="child", + extra_args=[ + "get", + "--netuid", + NETUID, + "--wallet-path", + wp_alice, + "--wallet-name", + w_alice.name, + "--hotkey", + w_alice.hotkey_str, + "--network", + NETWORK, + ], + ) + assert len(result.stdout) > 0 + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_revoke_children(local_chain, wallet_setup): + """Set children then revoke and verify success.""" + w_alice, wp_alice, exec_alice = _setup_subnet_and_stake(wallet_setup) + _, w_bob, wp_bob, exec_bob = wallet_setup("//Bob") + _register_on_subnet(w_bob, wp_bob, exec_bob) + _set_children(exec_alice, w_alice, wp_alice, [w_bob.hotkey.ss58_address], ["0.5"]) + + result = exec_alice( + command="stake", + sub_command="child", + extra_args=[ + "revoke", + "--netuid", + NETUID, + "--wallet-path", + wp_alice, + "--wallet-name", + w_alice.name, + "--hotkey", + w_alice.hotkey_str, + "--network", + NETWORK, + "--no-prompt", + "--json-output", + ], + ) + output = json.loads(result.stdout) + assert output[str(NETUID)]["success"] is True + assert output[str(NETUID)]["completion_block"] is not None