diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index ca4b56099..76a2af968 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -43,7 +43,7 @@ MEV_SHIELD_PUBLIC_KEY_SIZE = 1184 console = Console() -json_console = Console() +json_console = Console(no_color=True) err_console = Console(stderr=True) verbose_console = Console(quiet=True) diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 526037bc6..de27b185b 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -44,6 +44,7 @@ print_extrinsic_id, check_img_mimetype, ) +from async_substrate_interface.types import Runtime if TYPE_CHECKING: from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface @@ -53,6 +54,86 @@ # helpers and extrinsics +async def get_subtensor_constant( + subtensor: "SubtensorInterface", + constant_name: str, + runtime: Optional[Runtime] = None, + block_hash: Optional[str] = None, +) -> int: + """ + Get a constant from the SubtensorModule. + + Args: + subtensor: SubtensorInterface object for chain interaction + constant_name: Name of the constant to get + runtime: Runtime object (optional) + block_hash: Block hash (optional) + + Returns: + The value of the constant in RAO + """ + runtime = runtime or await subtensor.substrate.init_runtime(block_hash=block_hash) + + result = await subtensor.substrate.get_constant( + module_name="SubtensorModule", + constant_name=constant_name, + block_hash=block_hash, + runtime=runtime, + ) + return getattr(result, "value", result) + + +async def get_identity_deposit( + subtensor: "SubtensorInterface", + block_hash: Optional[str] = None, +) -> Balance: + """ + Get the deposit required for subnet identity operations. + + Args: + subtensor: SubtensorInterface object for chain interaction + block_hash: Block hash (optional) + + Returns: + The identity deposit amount as a Balance object + """ + try: + # Try common constant names for identity deposit + constant_names = [ + "SubnetIdentityDeposit", + "IdentityDeposit", + "SubnetIdentityFee", + "IdentityFee", + "SubnetIdentityDepositAmount", + "IdentityDepositAmount", + ] + + runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) + + for constant_name in constant_names: + try: + deposit_raw = await get_subtensor_constant( + subtensor, constant_name, runtime=runtime, block_hash=block_hash + ) + print_verbose( + f"Found identity deposit constant: {constant_name} = {deposit_raw} RAO" + ) + return Balance.from_rao(deposit_raw) + except Exception as e: + print_verbose(f"Constant {constant_name} not found: {e}") + continue + + # If no constant found, return 0 and log a warning + print_verbose( + f"Warning: Could not find identity deposit constant. " + f"Tried: {', '.join(constant_names)}" + ) + return Balance.from_rao(0) + except Exception as e: + print_verbose(f"Error retrieving identity deposit: {e}") + return Balance.from_rao(0) + + def format_claim_type_for_root(claim_info: dict, total_subnets: int) -> str: """ Format claim type for root network metagraph. @@ -120,6 +201,7 @@ async def register_subnetwork_extrinsic( wait_for_finalization: bool = True, prompt: bool = False, mev_protection: bool = True, + json_output: bool = False, ) -> tuple[bool, Optional[int], Optional[str]]: """Registers a new subnetwork. @@ -192,6 +274,9 @@ async def _find_event_attributes_in_extrinsic_receipt( has_identity = any(subnet_identity.values()) if has_identity: + # Query identity deposit amount when creating subnet with identity + identity_deposit = await get_identity_deposit(subtensor) + identity_data = { "subnet_name": subnet_identity["subnet_name"].encode() if subnet_identity.get("subnet_name") @@ -229,6 +314,27 @@ async def _find_event_attributes_in_extrinsic_receipt( ) return False, None, None + # Display identity deposit information (only if not in JSON output mode) + if not json_output: + if identity_deposit.rao > 0: + console.print( + f"Identity deposit required: [{COLOR_PALETTE['POOLS']['TAO']}]{identity_deposit}[/{COLOR_PALETTE['POOLS']['TAO']}]" + ) + # Check if user has enough balance for identity deposit + if identity_deposit > your_balance: + err_console.print( + f"Your balance of: [{COLOR_PALETTE.POOLS.TAO}]{your_balance}[/{COLOR_PALETTE.POOLS.TAO}] " + f"is not enough to cover the identity deposit of " + f"[{COLOR_PALETTE.POOLS.TAO}]{identity_deposit}[/{COLOR_PALETTE.POOLS.TAO}]." + ) + return False, None, None + else: + # Deposit is 0 or unknown - still inform user that identity creation may have a fee + console.print( + f"[yellow]Note:[/yellow] Creating subnet with identity may require a deposit. " + f"Deposit amount could not be determined from chain constants." + ) + if not unlock_key(wallet).success: return False, None, None @@ -1599,7 +1705,8 @@ async def show_subnet( "uids": json_out_rows, } if json_output: - json_console.print(json.dumps(output_dict)) + # Use print() directly for JSON output to avoid Rich Console adding ANSI codes + print(json.dumps(output_dict)) mech_line = ( f"\n Mechanism ID: [{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]#{selected_mechanism_id}" @@ -1738,11 +1845,13 @@ async def create( prompt=prompt, proxy=proxy, mev_protection=mev_protection, + json_output=json_output, ) if json_output: # technically, netuid can be `None`, but only if not wait for finalization/inclusion. However, as of present # (2025/04/03), we always use the default `wait_for_finalization=True`, so it will always have a netuid. - json_console.print( + # Use print() directly for JSON output to avoid Rich Console adding ANSI codes + print( json.dumps( {"success": success, "netuid": netuid, "extrinsic_identifier": ext_id} ) @@ -2519,9 +2628,18 @@ async def set_identity( if not unlock_key(wallet).success: return False, None + # Query identity deposit amount + identity_deposit = await get_identity_deposit(subtensor) + if prompt: + deposit_msg = "" + if identity_deposit.rao > 0: + deposit_msg = f" This requires a deposit of [{COLOR_PALETTE['POOLS']['TAO']}]{identity_deposit}[/{COLOR_PALETTE['POOLS']['TAO']}]." + else: + deposit_msg = " This is subject to a fee." + if not Confirm.ask( - "Are you sure you want to set subnet's identity? This is subject to a fee." + f"Are you sure you want to set subnet's identity?{deposit_msg}" ): return False, None @@ -2618,7 +2736,8 @@ async def get_identity( table.add_row(key, str(value) if value else "~") dict_out[key] = value if json_output: - json_console.print(json.dumps(dict_out)) + # Use print() directly for JSON output to avoid Rich Console adding ANSI codes + print(json.dumps(dict_out)) else: console.print(table) return identity diff --git a/tests/e2e_tests/test_set_identity.py b/tests/e2e_tests/test_set_identity.py index 345927785..b16dc7dce 100644 --- a/tests/e2e_tests/test_set_identity.py +++ b/tests/e2e_tests/test_set_identity.py @@ -58,6 +58,9 @@ def test_set_id(local_chain, wallet_setup): "--no-mev-protection", ], ) + print("=======================================") + print(result.stdout) + print("=======================================") result_output = json.loads(result.stdout) assert result_output["success"] is True @@ -107,10 +110,18 @@ def test_set_id(local_chain, wallet_setup): ], inputs=["Y", "Y"], ) + assert ( f"Are you sure you want to use {sn_logo_url} as your image URL?" in set_identity.stdout ) + # Verify that deposit information is shown in the prompt + assert "Are you sure you want to set subnet's identity?" in set_identity.stdout + # Check for either deposit amount or "subject to a fee" message + assert ( + "deposit" in set_identity.stdout.lower() + or "subject to a fee" in set_identity.stdout.lower() + ) get_identity = exec_command_alice( "subnets", "get-identity", @@ -181,6 +192,13 @@ def test_set_id(local_chain, wallet_setup): f"Are you sure you want to use {sn_logo_url} as your image URL?" not in set_identity.stdout ) + # Verify that deposit information is shown in the prompt + assert "Are you sure you want to set subnet's identity?" in set_identity.stdout + # Check for either deposit amount or "subject to a fee" message + assert ( + "deposit" in set_identity.stdout.lower() + or "subject to a fee" in set_identity.stdout.lower() + ) get_identity = exec_command_alice( "subnets", "get-identity", @@ -192,6 +210,7 @@ def test_set_id(local_chain, wallet_setup): "--json-output", ], ) + get_identity_output = json.loads(get_identity.stdout) assert get_identity_output["subnet_name"] == sn_name assert get_identity_output["github_repo"] == sn_github diff --git a/tests/unit_tests/test_subnets.py b/tests/unit_tests/test_subnets.py new file mode 100644 index 000000000..2a7b5218a --- /dev/null +++ b/tests/unit_tests/test_subnets.py @@ -0,0 +1,172 @@ +""" +Unit tests for subnet-related functions, particularly identity deposit queries. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.commands.subnets.subnets import ( + get_subtensor_constant, + get_identity_deposit, +) + + +@pytest.mark.asyncio +async def test_get_subtensor_constant_success(): + """Test successful retrieval of a SubtensorModule constant.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock runtime initialization + mock_runtime = MagicMock() + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + # Mock constant retrieval + mock_constant_result = MagicMock() + mock_constant_result.value = 1000000000 # 1 TAO in RAO + mock_subtensor.substrate.get_constant = AsyncMock(return_value=mock_constant_result) + + result = await get_subtensor_constant(mock_subtensor, "TestConstant") + + assert result == 1000000000 + mock_subtensor.substrate.get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="TestConstant", + block_hash=None, + runtime=mock_runtime, + ) + + +@pytest.mark.asyncio +async def test_get_subtensor_constant_with_block_hash(): + """Test constant retrieval with a specific block hash.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + mock_runtime = MagicMock() + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + mock_constant_result = MagicMock() + mock_constant_result.value = 2000000000 + mock_subtensor.substrate.get_constant = AsyncMock(return_value=mock_constant_result) + + block_hash = "0x1234567890abcdef" + result = await get_subtensor_constant( + mock_subtensor, "TestConstant", block_hash=block_hash + ) + + assert result == 2000000000 + mock_subtensor.substrate.get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="TestConstant", + block_hash=block_hash, + runtime=mock_runtime, + ) + + +@pytest.mark.asyncio +async def test_get_identity_deposit_success_first_constant(): + """Test successful retrieval of identity deposit using the first constant name.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + mock_runtime = MagicMock() + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + # Mock successful constant retrieval for SubnetIdentityDeposit + mock_constant_result = MagicMock() + mock_constant_result.value = 5000000000 # 5 TAO in RAO + mock_subtensor.substrate.get_constant = AsyncMock(return_value=mock_constant_result) + + # Mock get_subtensor_constant to return the value + with patch( + "bittensor_cli.src.commands.subnets.subnets.get_subtensor_constant", + new_callable=AsyncMock, + ) as mock_get_constant: + mock_get_constant.return_value = 5000000000 + + result = await get_identity_deposit(mock_subtensor) + + assert isinstance(result, Balance) + assert result.rao == 5000000000 + assert result.tao == 5.0 + + +@pytest.mark.asyncio +async def test_get_identity_deposit_tries_multiple_constants(): + """Test that get_identity_deposit tries multiple constant names.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + mock_runtime = MagicMock() + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + # Mock get_subtensor_constant to fail on first attempts, succeed on third + call_count = 0 + + async def mock_get_constant(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise Exception("Constant not found") + return 3000000000 # 3 TAO in RAO + + with patch( + "bittensor_cli.src.commands.subnets.subnets.get_subtensor_constant", + side_effect=mock_get_constant, + ): + result = await get_identity_deposit(mock_subtensor) + + assert isinstance(result, Balance) + assert result.rao == 3000000000 + assert call_count == 3 # Should have tried 3 constants + + +@pytest.mark.asyncio +async def test_get_identity_deposit_no_constant_found(): + """Test that get_identity_deposit returns 0 when no constant is found.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + mock_runtime = MagicMock() + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + # Mock get_subtensor_constant to always fail + with patch( + "bittensor_cli.src.commands.subnets.subnets.get_subtensor_constant", + side_effect=Exception("Constant not found"), + ), patch( + "bittensor_cli.src.commands.subnets.subnets.print_verbose" + ) as mock_print_verbose: + result = await get_identity_deposit(mock_subtensor) + + assert isinstance(result, Balance) + assert result.rao == 0 + # Should log a warning + mock_print_verbose.assert_called() + + +@pytest.mark.asyncio +async def test_get_identity_deposit_with_block_hash(): + """Test get_identity_deposit with a specific block hash.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + mock_runtime = MagicMock() + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + block_hash = "0xabcdef1234567890" + + with patch( + "bittensor_cli.src.commands.subnets.subnets.get_subtensor_constant", + new_callable=AsyncMock, + ) as mock_get_constant: + mock_get_constant.return_value = 1000000000 + + result = await get_identity_deposit(mock_subtensor, block_hash=block_hash) + + assert isinstance(result, Balance) + # Verify block_hash was passed through + mock_get_constant.assert_called() + # Check that init_runtime was called with block_hash + mock_subtensor.substrate.init_runtime.assert_called_with(block_hash=block_hash) +