diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c10dfeef..0d57dac3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,13 +53,13 @@ jobs: path: dist/ approve-and-publish: + name: Approve and publish to PyPI needs: build runs-on: ubuntu-latest environment: release permissions: contents: read id-token: write - steps: - name: Download artifact uses: actions/download-artifact@v8 @@ -67,6 +67,14 @@ jobs: name: dist path: dist/ + - name: Verify artifact checksums + run: | + echo "Artifacts to be published:" + ls -la dist/ + echo "" + echo "SHA256 checksums:" + sha256sum dist/* + - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c726e2c..15760626c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 9.20.2rc2 /2026-04-06 + +## What's Changed +* Fix/e2e tests for stake locked as owner alpha feat by @ibraheem-abe in https://github.com/latent-to/btcli/pull/900 +* fix: update max_burn to owner/sudo settable and align no-prompt routing by @bitloi in https://github.com/latent-to/btcli/pull/902 +* fix: `stake add` operation mapping for multi-hotkey and multi-netuid by @bitloi in https://github.com/latent-to/btcli/pull/897 +* Update/swap coldkey restriction by @ibraheem-abe in https://github.com/latent-to/btcli/pull/905 +* Update: Log SHA & other info during release by @ibraheem-abe in https://github.com/latent-to/btcli/pull/906 + +**Full Changelog**: https://github.com/latent-to/btcli/compare/v9.20.1...v9.20.2 + ## 9.20.1 /2026-04-02 ## What's Changed diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 95a4d3c39..50ab2e35a 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -609,7 +609,7 @@ class RootSudoOnly(Enum): RootSudoOnly.TRUE, ), "min_burn": ("sudo_set_min_burn", RootSudoOnly.FALSE), - "max_burn": ("sudo_set_max_burn", RootSudoOnly.TRUE), + "max_burn": ("sudo_set_max_burn", RootSudoOnly.COMPLICATED), "bonds_moving_avg": ("sudo_set_bonds_moving_average", RootSudoOnly.FALSE), "max_regs_per_block": ("sudo_set_max_registrations_per_block", RootSudoOnly.TRUE), "serving_rate_limit": ("sudo_set_serving_rate_limit", RootSudoOnly.FALSE), @@ -745,7 +745,7 @@ class RootSudoOnly(Enum): "max_burn": { "description": "Maximum TAO burn amount cap for subnet registration.", "side_effects": "Caps registration costs, ensuring registration remains accessible even as difficulty increases.", - "owner_settable": False, + "owner_settable": True, "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#maxburn", }, "bonds_moving_avg": { diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index d2bd7407d..f165bf3d6 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -339,13 +339,7 @@ async def stake_extrinsic( ) # Determine the amount we are staking. - rows = [] - amounts_to_stake = [] - current_stake_balances = [] - prices_with_tolerance = [] - remaining_wallet_balance = current_wallet_balance - max_slippage = 0.0 - + operation_targets = [] for hotkey in hotkeys_to_stake_to: for netuid in netuids: # Check that the subnet exists. @@ -353,105 +347,126 @@ async def stake_extrinsic( if not subnet_info: print_error(f"Subnet with netuid: {netuid} does not exist.") continue - current_stake_balances.append(hotkey_stake_map[hotkey[1]][netuid]) - - # Get the amount. - amount_to_stake = Balance(0) - if amount: - amount_to_stake = Balance.from_tao(amount) - elif stake_all: - amount_to_stake = current_wallet_balance / len(netuids) - elif not amount: - amount_to_stake, _ = _prompt_stake_amount( - current_balance=remaining_wallet_balance, - netuid=netuid, - action_name="stake", - ) - amounts_to_stake.append(amount_to_stake) + operation_targets.append( + (hotkey, netuid, subnet_info, hotkey_stake_map[hotkey[1]][netuid]) + ) - # Check enough to stake. - if amount_to_stake > remaining_wallet_balance: - print_error( - f"Not enough stake:[bold white]\n wallet balance:{remaining_wallet_balance} < " - f"staking amount: {amount_to_stake}[/bold white]" - ) - return - remaining_wallet_balance -= amount_to_stake - - # Calculate slippage - # TODO: Update for V3, slippage calculation is significantly different in v3 - # try: - # received_amount, slippage_pct, slippage_pct_float, rate = ( - # _calculate_slippage(subnet_info, amount_to_stake, stake_fee) - # ) - # except ValueError: - # return False - # - # max_slippage = max(slippage_pct_float, max_slippage) - - # Temporary workaround - calculations without slippage - current_price_float = float(subnet_info.price.tao) - rate = _safe_inverse_rate(current_price_float) - - # If we are staking safe, add price tolerance - if safe_staking: - if subnet_info.is_dynamic: - price_with_tolerance = current_price_float * (1 + rate_tolerance) - _rate_with_tolerance = _safe_inverse_rate( - price_with_tolerance - ) # Rate only for display - rate_with_tolerance = f"{_rate_with_tolerance:.4f}" - price_with_tolerance = Balance.from_tao( - price_with_tolerance - ) # Actual price to pass to extrinsic - else: - rate_with_tolerance = "1" - price_with_tolerance = Balance.from_rao(1) - extrinsic_fee = await get_stake_extrinsic_fee( - netuid_=netuid, - amount_=amount_to_stake, - staking_address_=hotkey[1], - safe_staking_=safe_staking, - price_limit=price_with_tolerance, - ) - prices_with_tolerance.append(price_with_tolerance) - row_extension = [ - f"{rate_with_tolerance} {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", - f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]" - # safe staking - f"{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", - ] + if stake_all and not operation_targets: + print_error("No valid staking operations to perform.") + return + + rows = [] + operations = [] + remaining_wallet_balance = current_wallet_balance + max_slippage = 0.0 + + for hotkey, netuid, subnet_info, current_stake_balance in operation_targets: + staking_address = hotkey[1] + + # Get the amount. + amount_to_stake = Balance(0) + if amount: + amount_to_stake = Balance.from_tao(amount) + elif stake_all: + amount_to_stake = current_wallet_balance / len(operation_targets) + elif not amount: + amount_to_stake, _ = _prompt_stake_amount( + current_balance=remaining_wallet_balance, + netuid=netuid, + action_name="stake", + ) + + # Check enough to stake. + if amount_to_stake > remaining_wallet_balance: + print_error( + f"Not enough stake:[bold white]\n wallet balance:{remaining_wallet_balance} < " + f"staking amount: {amount_to_stake}[/bold white]" + ) + return + remaining_wallet_balance -= amount_to_stake + + # Calculate slippage + # TODO: Update for V3, slippage calculation is significantly different in v3 + # try: + # received_amount, slippage_pct, slippage_pct_float, rate = ( + # _calculate_slippage(subnet_info, amount_to_stake, stake_fee) + # ) + # except ValueError: + # return False + # + # max_slippage = max(slippage_pct_float, max_slippage) + + # Temporary workaround - calculations without slippage + current_price_float = float(subnet_info.price.tao) + rate = _safe_inverse_rate(current_price_float) + price_with_tolerance = None + + # If we are staking safe, add price tolerance + if safe_staking: + if subnet_info.is_dynamic: + price_with_tolerance = current_price_float * (1 + rate_tolerance) + _rate_with_tolerance = _safe_inverse_rate( + price_with_tolerance + ) # Rate only for display + rate_with_tolerance = f"{_rate_with_tolerance:.4f}" + price_with_tolerance = Balance.from_tao( + price_with_tolerance + ) # Actual price to pass to extrinsic else: - extrinsic_fee = await get_stake_extrinsic_fee( - netuid_=netuid, - amount_=amount_to_stake, - staking_address_=hotkey[1], - safe_staking_=safe_staking, - ) - row_extension = [] - # TODO this should be asyncio gathered before the for loop - amount_minus_fee = ( - (amount_to_stake - extrinsic_fee) if not proxy else amount_to_stake + rate_with_tolerance = "1" + price_with_tolerance = Balance.from_rao(1) + extrinsic_fee = await get_stake_extrinsic_fee( + netuid_=netuid, + amount_=amount_to_stake, + staking_address_=staking_address, + safe_staking_=safe_staking, + price_limit=price_with_tolerance, + ) + row_extension = [ + f"{rate_with_tolerance} {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", + f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]" + # safe staking + f"{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", + ] + else: + extrinsic_fee = await get_stake_extrinsic_fee( + netuid_=netuid, + amount_=amount_to_stake, + staking_address_=staking_address, + safe_staking_=safe_staking, ) - sim_swap = await subtensor.sim_swap( - origin_netuid=0, - destination_netuid=netuid, - amount=amount_minus_fee.rao, + row_extension = [] + # TODO this should be asyncio gathered before the for loop + amount_minus_fee = ( + (amount_to_stake - extrinsic_fee) if not proxy else amount_to_stake + ) + sim_swap = await subtensor.sim_swap( + origin_netuid=0, + destination_netuid=netuid, + amount=amount_minus_fee.rao, + ) + received_amount = sim_swap.alpha_amount + # Add rows for the table + base_row = [ + str(netuid), # netuid + f"{staking_address}", # hotkey + str(amount_to_stake), # amount + str(rate) + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate + str(received_amount.set_unit(netuid)), # received + str(sim_swap.tao_fee), # fee + str(extrinsic_fee), + # str(slippage_pct), # slippage + ] + row_extension + rows.append(tuple(base_row)) + operations.append( + ( + netuid, + staking_address, + amount_to_stake, + current_stake_balance, + price_with_tolerance, ) - received_amount = sim_swap.alpha_amount - # Add rows for the table - base_row = [ - str(netuid), # netuid - f"{hotkey[1]}", # hotkey - str(amount_to_stake), # amount - str(rate) - + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate - str(received_amount.set_unit(netuid)), # received - str(sim_swap.tao_fee), # fee - str(extrinsic_fee), - # str(slippage_pct), # slippage - ] + row_extension - rows.append(tuple(base_row)) + ) # Define and print stake table + slippage warning table = _define_stake_table(wallet, subtensor, safe_staking, rate_tolerance) @@ -467,23 +482,6 @@ async def stake_extrinsic( if not unlock_key(wallet).success: return - # Build the list of (netuid, hotkey, amount, current_stake, price_limit) tuples - # that describe each staking operation we need to perform. - # The zip aligns netuids with amounts/balances (which are populated per - # hotkey-netuid pair, but the zip truncates to len(netuids), matching the - # original execution order). Each netuid's amount/price applies to all hotkeys. - operations = [] - if safe_staking: - for ni, am, curr, price in zip( - netuids, amounts_to_stake, current_stake_balances, prices_with_tolerance - ): - for _, staking_address in hotkeys_to_stake_to: - operations.append((ni, staking_address, am, curr, price)) - else: - for ni, am, curr in zip(netuids, amounts_to_stake, current_stake_balances): - for _, staking_address in hotkeys_to_stake_to: - operations.append((ni, staking_address, am, curr, None)) - total_ops = len(operations) use_batch = total_ops > 1 diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 4f37d1613..9a7136729 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -614,7 +614,9 @@ async def set_hyperparameter_extrinsic( ) elif sudo_ is RootSudoOnly.COMPLICATED: if not prompt: - to_sudo_or_not_to_sudo = True # default to sudo true when no-prompt is set + # In no-prompt mode, owners should take the owner path; non-owners + # should default to sudo. + to_sudo_or_not_to_sudo = subnet_owner != coldkey_ss58 else: to_sudo_or_not_to_sudo = confirm_action( "This hyperparam can be executed as sudo or not. Do you want to execute as sudo [y] or not [n]?", diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 68da4a5fa..5c16b9733 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1874,23 +1874,29 @@ async def swap_hotkey( return result -def create_key_value_table(title: str = "Details") -> Table: +def create_key_value_table( + title: str = "Details", + key_label: str = "Item", + value_label: str = "Value", +) -> Table: """Creates a key-value table for displaying information for various cmds. Args: title: The title shown above the table. + key_label: The header for the key column. + value_label: The header for the value column. Returns: A Rich Table for key-value display. """ return Table( Column( - "Item", + key_label, justify="right", style=COLOR_PALETTE["GENERAL"]["SUBHEADING_MAIN"], no_wrap=True, ), - Column("Value", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]), + Column(value_label, style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]), title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{title}", show_footer=True, show_edge=False, @@ -2232,11 +2238,34 @@ async def announce_coldkey_swap( return False # Proceed with the announcement - swap_cost, delay = await asyncio.gather( + swap_cost, delay, dest_staking_hotkeys = await asyncio.gather( subtensor.get_coldkey_swap_cost(block_hash=block_hash), subtensor.get_coldkey_swap_announcement_delay(block_hash=block_hash), + subtensor.get_staking_hotkeys(new_coldkey_ss58, block_hash=block_hash), ) + if dest_staking_hotkeys: + print_error( + "Destination coldkey cannot have any staking hotkeys. " + "Please use a new coldkey for the swap." + ) + identity_map = await subtensor.fetch_coldkey_hotkey_identities( + block_hash=block_hash + ) + hk_table = create_key_value_table( + f"Staking Hotkeys Associated with Destination Coldkey \n Count: ({len(dest_staking_hotkeys)})\n", + key_label="Hotkey", + value_label="Identity", + ) + for hk_ss58 in dest_staking_hotkeys: + hk_name = get_hotkey_identity_name(identity_map, hk_ss58) or "~" + hk_table.add_row( + f"[{COLORS.G.HK}]{hk_ss58}[/{COLORS.G.HK}]", + f"[{COLORS.G.CK}]{hk_name}[/{COLORS.G.CK}]", + ) + console.print(hk_table) + return False + table = create_key_value_table("Announcing Coldkey Swap\n") table.add_row( "Current Coldkey", diff --git a/pyproject.toml b/pyproject.toml index cf15c659d..1381c8716 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.20.1" +version = "9.20.2rc1" description = "Bittensor CLI" readme = "README.md" authors = [ diff --git a/tests/e2e_tests/test_stake_movement.py b/tests/e2e_tests/test_stake_movement.py index 5a34aec4f..16ba309a2 100644 --- a/tests/e2e_tests/test_stake_movement.py +++ b/tests/e2e_tests/test_stake_movement.py @@ -530,8 +530,7 @@ def test_stake_movement(local_chain, wallet_setup): "--chain", "ws://127.0.0.1:9945", "--no-prompt", - "--rate-tolerance", - "0.1", + "--unsafe", ], ) assert "✅ Sent" in swap_with_limit_result.stdout, swap_with_limit_result.stderr diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index e76ff1627..6f27a5bf7 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -1,6 +1,5 @@ import asyncio import json -import re import pytest from typing import Union @@ -397,14 +396,7 @@ def test_staking(local_chain, wallet_setup): "--verbose", ], ) - - # Assert correct stake is added - cleaned_stake = [ - re.sub(r"\s+", " ", line) - for line in show_stake_adding_single.stdout.splitlines() - ] - stake_added = cleaned_stake[8].split("│")[3].strip().split()[0] - assert Balance.from_tao(float(stake_added)) >= Balance.from_tao(87) + assert str(netuid) in show_stake_adding_single.stdout show_stake_json = exec_command_alice( command="stake", @@ -420,8 +412,17 @@ def test_staking(local_chain, wallet_setup): ], ) show_stake_json_output = json.loads(show_stake_json.stdout) - alice_stake = show_stake_json_output["stake_info"][keypair_alice.ss58_address][0] - assert Balance.from_tao(alice_stake["stake_value"]) >= Balance.from_tao(87.0) + alice_stakes = show_stake_json_output["stake_info"][keypair_alice.ss58_address] + alice_on_netuid = next( + (stake for stake in alice_stakes if stake["netuid"] == netuid), None + ) + assert alice_on_netuid is not None, ( + f"No stake row for netuid {netuid} in JSON output" + ) + assert Balance.from_tao(float(alice_on_netuid["stake_value"])) >= Balance.from_tao( + 87.0 + ) + remove_amount = float(alice_on_netuid["stake_value"]) - 1.0 # Execute remove_stake command and remove all alpha stakes from Alice's wallet remove_stake = exec_command_alice( @@ -439,7 +440,7 @@ def test_staking(local_chain, wallet_setup): "--chain", "ws://127.0.0.1:9945", "--amount", - str(float(stake_added) - 1), + str(remove_amount), "--tolerance", "0.1", "--partial", @@ -542,6 +543,7 @@ def line(key: str) -> Union[str, bool]: assert "description" in max_burn_param, "Missing description for max_burn" assert "side_effects" in max_burn_param, "Missing side_effects for max_burn" assert "owner_settable" in max_burn_param, "Missing owner_settable for max_burn" + assert max_burn_param["owner_settable"] is True assert "docs_link" in max_burn_param, "Missing docs_link for max_burn" max_burn_tao_from_json = max_burn_param["value"] assert Balance.from_rao(max_burn_tao_from_json) == Balance.from_tao(100.0) @@ -625,6 +627,7 @@ def line(key: str) -> Union[str, bool]: assert "owner_settable" in max_burn_updated, ( "Missing owner_settable for max_burn after update" ) + assert max_burn_updated["owner_settable"] is True assert "docs_link" in max_burn_updated, ( "Missing docs_link for max_burn after update" ) diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index 9f65759ce..b67ae0a63 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -202,7 +202,7 @@ def validate_wallet_overview( pattern += rf"{hotkey}\s+" # HOTKEY pattern += rf"{uid}\s+" # UID pattern += r"True\s+" # ACTIVE - pattern += r"[\d.]+\s+" # STAKE + pattern += r"[\d.,]+[k]?\s+" # STAKE pattern += r"[\d.]+\s+" # RANK pattern += r"[\d.]+\s+" # TRUST pattern += r"[\d.]+\s+" # CONSENSUS diff --git a/tests/unit_tests/test_hyperparams.py b/tests/unit_tests/test_hyperparams.py index 8f6b42647..43f23a599 100644 --- a/tests/unit_tests/test_hyperparams.py +++ b/tests/unit_tests/test_hyperparams.py @@ -38,3 +38,12 @@ def test_new_hyperparams_have_metadata(): def test_new_hyperparams_owner_settable_true(): for key in NEW_HYPERPARAMS_826: assert HYPERPARAMS_METADATA[key]["owner_settable"] is True + + +def test_max_burn_is_owner_or_root_settable(): + _, root_only = HYPERPARAMS["max_burn"] + assert root_only is RootSudoOnly.COMPLICATED + + +def test_max_burn_metadata_owner_settable_true(): + assert HYPERPARAMS_METADATA["max_burn"]["owner_settable"] is True diff --git a/tests/unit_tests/test_stake_add.py b/tests/unit_tests/test_stake_add.py index 5537a1fa9..974f88353 100644 --- a/tests/unit_tests/test_stake_add.py +++ b/tests/unit_tests/test_stake_add.py @@ -1,12 +1,15 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.commands.stake.add import stake_add -from tests.unit_tests.conftest import COLDKEY_SS58 as TEST_SS58 +from tests.unit_tests.conftest import ( + ALT_HOTKEY_SS58, + COLDKEY_SS58 as TEST_SS58, +) class MockSubnetInfo: @@ -105,3 +108,138 @@ async def test_stake_add_mixed_prices_including_zero_does_not_raise( assert mock_subtensor.substrate.compose_call.await_count == 2 assert mock_subtensor.sim_swap.await_count == 2 + + +@pytest.mark.asyncio +async def test_stake_add_multi_hotkey_multi_netuid_preserves_operation_mapping( + mock_wallet, + mock_subtensor, +): + mock_subtensor.sim_swap = _sim_swap_side_effect() + mock_subtensor.all_subnets.return_value = [ + MockSubnetInfo(netuid=427, price_tao=1.5), + MockSubnetInfo(netuid=1, price_tao=2.0), + ] + mock_subtensor.sign_and_send_batch_extrinsic = AsyncMock( + return_value=( + True, + "", + MagicMock(get_extrinsic_identifier=AsyncMock(return_value="0x1")), + ) + ) + + prompt_amounts = [ + (Balance.from_tao(1.0), False), + (Balance.from_tao(2.0), False), + (Balance.from_tao(3.0), False), + (Balance.from_tao(4.0), False), + ] + + with ( + patch( + "bittensor_cli.src.commands.stake.add._prompt_stake_amount", + side_effect=prompt_amounts, + ), + patch( + "bittensor_cli.src.commands.stake.add.unlock_key", + return_value=MagicMock(success=True), + ), + ): + await stake_add( + wallet=mock_wallet, + subtensor=mock_subtensor, + netuids=[427, 1], + stake_all=False, + amount=0, + prompt=False, + decline=False, + quiet=True, + all_hotkeys=False, + include_hotkeys=[TEST_SS58, ALT_HOTKEY_SS58], + exclude_hotkeys=[], + safe_staking=False, + rate_tolerance=0.05, + allow_partial_stake=True, + json_output=True, + era=16, + mev_protection=False, + proxy=None, + ) + + batched_stake_calls = [ + call + for call in mock_subtensor.substrate.compose_call.await_args_list + if call.kwargs.get("block_hash") == "0xabc123" + ] + + assert len(batched_stake_calls) == 4 + assert [ + ( + call.kwargs["call_params"]["hotkey"], + call.kwargs["call_params"]["netuid"], + call.kwargs["call_params"]["amount_staked"], + ) + for call in batched_stake_calls + ] == [ + (TEST_SS58, 427, Balance.from_tao(1.0).rao), + (TEST_SS58, 1, Balance.from_tao(2.0).rao), + (ALT_HOTKEY_SS58, 427, Balance.from_tao(3.0).rao), + (ALT_HOTKEY_SS58, 1, Balance.from_tao(4.0).rao), + ] + + +@pytest.mark.asyncio +async def test_stake_add_stake_all_distributes_across_all_operations( + mock_wallet, + mock_subtensor, +): + mock_subtensor.sim_swap = _sim_swap_side_effect() + mock_subtensor.all_subnets.return_value = [ + MockSubnetInfo(netuid=427, price_tao=1.5), + MockSubnetInfo(netuid=1, price_tao=2.0), + ] + mock_subtensor.sign_and_send_batch_extrinsic = AsyncMock( + return_value=( + True, + "", + MagicMock(get_extrinsic_identifier=AsyncMock(return_value="0x1")), + ) + ) + + with patch( + "bittensor_cli.src.commands.stake.add.unlock_key", + return_value=MagicMock(success=True), + ): + await stake_add( + wallet=mock_wallet, + subtensor=mock_subtensor, + netuids=[427, 1], + stake_all=True, + amount=0, + prompt=False, + decline=False, + quiet=True, + all_hotkeys=False, + include_hotkeys=[TEST_SS58, ALT_HOTKEY_SS58], + exclude_hotkeys=[], + safe_staking=False, + rate_tolerance=0.05, + allow_partial_stake=True, + json_output=True, + era=16, + mev_protection=False, + proxy=None, + ) + + batched_stake_calls = [ + call + for call in mock_subtensor.substrate.compose_call.await_args_list + if call.kwargs.get("block_hash") == "0xabc123" + ] + expected_amount = (Balance.from_tao(100) / 4).rao + + assert len(batched_stake_calls) == 4 + assert all( + call.kwargs["call_params"]["amount_staked"] == expected_amount + for call in batched_stake_calls + ) diff --git a/tests/unit_tests/test_sudo_hyperparameter_permissions.py b/tests/unit_tests/test_sudo_hyperparameter_permissions.py new file mode 100644 index 000000000..f975e5446 --- /dev/null +++ b/tests/unit_tests/test_sudo_hyperparameter_permissions.py @@ -0,0 +1,218 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from tests.unit_tests.conftest import COLDKEY_SS58 + + +MODULE = "bittensor_cli.src.commands.sudo" +NON_OWNER_SS58 = "5FLSigC9H8M5Xo6z8xN7f6cXnHboRcgk4v6R7zDNz6w5jN3q" + + +@pytest.mark.asyncio +async def test_max_burn_no_prompt_owner_uses_owner_path( + mock_wallet, mock_subtensor, successful_receipt +): + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + mock_subtensor.query = AsyncMock(return_value=COLDKEY_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "max_burn"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock(return_value=direct_call) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="max_burn", + value="10000000000", + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=False, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + mock_subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="AdminUtils", + call_function="sudo_set_max_burn", + call_params={"netuid": 1, "max_burn": "10000000000"}, + ) + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + direct_call, + mock_wallet, + False, + False, + proxy=None, + ) + + +@pytest.mark.asyncio +async def test_max_burn_no_prompt_non_owner_uses_sudo_path( + mock_wallet, mock_subtensor, successful_receipt +): + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + sudo_call = MagicMock(name="sudo_call") + mock_subtensor.query = AsyncMock(return_value=NON_OWNER_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "max_burn"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock( + side_effect=[direct_call, sudo_call] + ) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="max_burn", + value="10000000000", + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=False, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + assert mock_subtensor.substrate.compose_call.await_count == 2 + assert mock_subtensor.substrate.compose_call.await_args_list[0].kwargs == { + "call_module": "AdminUtils", + "call_function": "sudo_set_max_burn", + "call_params": {"netuid": 1, "max_burn": "10000000000"}, + } + assert mock_subtensor.substrate.compose_call.await_args_list[1].kwargs == { + "call_module": "Sudo", + "call_function": "sudo", + "call_params": {"call": direct_call}, + } + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + sudo_call, + mock_wallet, + False, + False, + proxy=None, + ) + + +@pytest.mark.asyncio +async def test_max_burn_interactive_owner_chooses_non_sudo_path( + mock_wallet, mock_subtensor, successful_receipt +): + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + mock_subtensor.query = AsyncMock(return_value=COLDKEY_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "max_burn"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock(return_value=direct_call) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.confirm_action", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="max_burn", + value="10000000000", + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=True, + decline=False, + quiet=True, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + mock_subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="AdminUtils", + call_function="sudo_set_max_burn", + call_params={"netuid": 1, "max_burn": "10000000000"}, + ) + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + direct_call, + mock_wallet, + False, + False, + proxy=None, + ) + + +@pytest.mark.asyncio +async def test_max_burn_interactive_non_owner_chooses_non_sudo_errors( + mock_wallet, mock_subtensor +): + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + mock_subtensor.query = AsyncMock(return_value=NON_OWNER_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "max_burn"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock(return_value=direct_call) + mock_subtensor.sign_and_send_extrinsic = AsyncMock() + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.confirm_action", return_value=False), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="max_burn", + value="10000000000", + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=True, + decline=False, + quiet=True, + ) + + assert success is False + assert err_msg == "This wallet doesn't own the specified subnet." + assert ext_id is None + mock_subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="AdminUtils", + call_function="sudo_set_max_burn", + call_params={"netuid": 1, "max_burn": "10000000000"}, + ) + mock_subtensor.sign_and_send_extrinsic.assert_not_awaited()