diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py index 2195ecda9..f8172bf10 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -11,6 +11,7 @@ from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.commands.crowd.utils import ( + get_effective_actor_ss58, get_constant, prompt_custom_call_params, ) @@ -586,7 +587,8 @@ async def finalize_crowdloan( print_error(error_msg) return False, error_msg - if wallet.coldkeypub.ss58_address != crowdloan.creator: + creator_address = get_effective_actor_ss58(wallet=wallet, proxy=proxy) + if creator_address != crowdloan.creator: error_msg = ( f"Only the creator can finalize a crowdloan. Creator: {crowdloan.creator}" ) diff --git a/bittensor_cli/src/commands/crowd/dissolve.py b/bittensor_cli/src/commands/crowd/dissolve.py index de134d964..cb90331f9 100644 --- a/bittensor_cli/src/commands/crowd/dissolve.py +++ b/bittensor_cli/src/commands/crowd/dissolve.py @@ -7,6 +7,7 @@ from bittensor_cli.src import COLORS from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.commands.crowd.utils import get_effective_actor_ss58 from bittensor_cli.src.commands.crowd.view import show_crowdloan_details from bittensor_cli.src.bittensor.utils import ( blocks_to_duration, @@ -50,7 +51,7 @@ async def dissolve_crowdloan( tuple[bool, str]: Success status and message. """ - creator_ss58 = wallet.coldkeypub.ss58_address + creator_ss58 = get_effective_actor_ss58(wallet=wallet, proxy=proxy) crowdloan, current_block = await asyncio.gather( subtensor.get_single_crowdloan(crowdloan_id), diff --git a/bittensor_cli/src/commands/crowd/update.py b/bittensor_cli/src/commands/crowd/update.py index 6abefed55..ecb2702f9 100644 --- a/bittensor_cli/src/commands/crowd/update.py +++ b/bittensor_cli/src/commands/crowd/update.py @@ -19,7 +19,10 @@ print_extrinsic_id, ) from bittensor_cli.src.commands.crowd.view import show_crowdloan_details -from bittensor_cli.src.commands.crowd.utils import get_constant +from bittensor_cli.src.commands.crowd.utils import ( + get_constant, + get_effective_actor_ss58, +) async def update_crowdloan( @@ -88,7 +91,7 @@ async def update_crowdloan( print_error(f"[red]{error_msg}[/red]") return False, f"Crowdloan #{crowdloan_id} is already finalized." - creator_address = wallet.coldkeypub.ss58_address + creator_address = get_effective_actor_ss58(wallet=wallet, proxy=proxy) if creator_address != crowdloan.creator: error_msg = "Only the creator can update this crowdloan." if json_output: diff --git a/bittensor_cli/src/commands/crowd/utils.py b/bittensor_cli/src/commands/crowd/utils.py index 22aa109c4..384b7b275 100644 --- a/bittensor_cli/src/commands/crowd/utils.py +++ b/bittensor_cli/src/commands/crowd/utils.py @@ -2,12 +2,18 @@ from typing import Optional from async_substrate_interface.types import Runtime +from bittensor_wallet import Wallet from rich.prompt import Prompt from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.bittensor.utils import console, json_console, print_error +def get_effective_actor_ss58(wallet: Wallet, proxy: Optional[str]) -> str: + """Return the account address whose permissions apply for this call.""" + return proxy or wallet.coldkeypub.ss58_address + + async def prompt_custom_call_params( subtensor: SubtensorInterface, json_output: bool = False, diff --git a/tests/unit_tests/test_crowd_proxy_creator_checks.py b/tests/unit_tests/test_crowd_proxy_creator_checks.py new file mode 100644 index 000000000..70bcccf6b --- /dev/null +++ b/tests/unit_tests/test_crowd_proxy_creator_checks.py @@ -0,0 +1,148 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bittensor_cli.src.bittensor.balances import Balance +from tests.unit_tests.conftest import COLDKEY_SS58, PROXY_SS58 + + +def _make_crowdloan( + creator: str, + *, + finalized: bool = False, + raised_tao: float = 5.0, + cap_tao: float = 10.0, +) -> MagicMock: + crowdloan = MagicMock() + crowdloan.creator = creator + crowdloan.finalized = finalized + crowdloan.raised = Balance.from_tao(raised_tao) + crowdloan.cap = Balance.from_tao(cap_tao) + return crowdloan + + +def test_get_effective_actor_ss58_prefers_proxy(mock_wallet): + from bittensor_cli.src.commands.crowd.utils import get_effective_actor_ss58 + + assert get_effective_actor_ss58(wallet=mock_wallet, proxy=PROXY_SS58) == PROXY_SS58 + + +def test_get_effective_actor_ss58_uses_wallet_when_proxy_missing(mock_wallet): + from bittensor_cli.src.commands.crowd.utils import get_effective_actor_ss58 + + assert ( + get_effective_actor_ss58(wallet=mock_wallet, proxy=None) + == mock_wallet.coldkeypub.ss58_address + ) + + +@pytest.mark.asyncio +async def test_finalize_crowdloan_allows_proxy_creator_actor( + mock_wallet, mock_subtensor +): + from bittensor_cli.src.commands.crowd.create import finalize_crowdloan + + mock_subtensor.get_single_crowdloan = AsyncMock( + return_value=_make_crowdloan(creator=PROXY_SS58) + ) + mock_subtensor.substrate.get_block_number = AsyncMock(return_value=12345) + + result = await finalize_crowdloan( + subtensor=mock_subtensor, + wallet=mock_wallet, + proxy=PROXY_SS58, + crowdloan_id=7, + wait_for_inclusion=True, + wait_for_finalization=False, + prompt=False, + json_output=False, + ) + + assert result == (False, "Crowdloan has not reached its cap.") + + +@pytest.mark.asyncio +async def test_finalize_crowdloan_rejects_non_creator_proxy_actor( + mock_wallet, mock_subtensor +): + from bittensor_cli.src.commands.crowd.create import finalize_crowdloan + + mock_subtensor.get_single_crowdloan = AsyncMock( + return_value=_make_crowdloan(creator=COLDKEY_SS58) + ) + mock_subtensor.substrate.get_block_number = AsyncMock(return_value=12345) + + result = await finalize_crowdloan( + subtensor=mock_subtensor, + wallet=mock_wallet, + proxy=PROXY_SS58, + crowdloan_id=7, + wait_for_inclusion=True, + wait_for_finalization=False, + prompt=False, + json_output=False, + ) + + assert result == (False, "Only the creator can finalize a crowdloan.") + + +@pytest.mark.asyncio +async def test_update_crowdloan_allows_proxy_creator_actor(mock_wallet, mock_subtensor): + from bittensor_cli.src.commands.crowd.update import update_crowdloan + + mock_subtensor.get_single_crowdloan = AsyncMock( + return_value=_make_crowdloan(creator=PROXY_SS58) + ) + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0xhead") + mock_subtensor.substrate.get_block_number = AsyncMock(return_value=12345) + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=MagicMock()) + + with ( + patch( + "bittensor_cli.src.commands.crowd.update.get_constant", + new_callable=AsyncMock, + side_effect=[Balance.from_tao(1).rao, 1, 1000], + ), + patch( + "bittensor_cli.src.commands.crowd.update.show_crowdloan_details", + new_callable=AsyncMock, + ), + ): + result = await update_crowdloan( + subtensor=mock_subtensor, + wallet=mock_wallet, + proxy=PROXY_SS58, + crowdloan_id=9, + min_contribution=None, + end=None, + cap=None, + prompt=False, + json_output=False, + ) + + assert result == (False, "No update parameter specified.") + + +@pytest.mark.asyncio +async def test_dissolve_crowdloan_allows_proxy_creator_actor( + mock_wallet, mock_subtensor +): + from bittensor_cli.src.commands.crowd.dissolve import dissolve_crowdloan + + crowdloan = _make_crowdloan(creator=PROXY_SS58, raised_tao=12.0, cap_tao=20.0) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=crowdloan) + mock_subtensor.substrate.get_block_number = AsyncMock(return_value=12345) + mock_subtensor.get_crowdloan_contribution = AsyncMock( + return_value=Balance.from_tao(1.0) + ) + + result = await dissolve_crowdloan( + subtensor=mock_subtensor, + wallet=mock_wallet, + proxy=PROXY_SS58, + crowdloan_id=11, + prompt=False, + json_output=False, + ) + + assert result == (False, "Crowdloan not ready to dissolve.")