diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 47c005c3..69293a4d 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -1243,6 +1243,12 @@ def __init__(self): "execute", rich_help_panel=HELP_PANELS["PROXY"]["MGMT"], )(self.proxy_execute_announced) + self.proxy_app.command("list", rich_help_panel=HELP_PANELS["PROXY"]["MGMT"])( + self.proxy_list + ) + self.proxy_app.command("reject", rich_help_panel=HELP_PANELS["PROXY"]["MGMT"])( + self.proxy_reject_announced + ) # Sub command aliases # Wallet @@ -9817,15 +9823,23 @@ def proxy_add( def proxy_remove( self, delegate: Annotated[ - str, + Optional[str], typer.Option( callback=is_valid_ss58_address_param, - prompt="Enter the SS58 address of the delegate to remove, e.g. 5dxds...", - help="The SS58 address of the delegate to remove", + help="The SS58 address of the delegate to remove. Mutually exclusive with --all.", ), - ] = "", + ] = None, + remove_all: bool = typer.Option( + False, + "--all", + help="Remove all proxies for the account at once.", + ), network: Optional[list[str]] = Options.network, - proxy_type: ProxyType = Options.proxy_type, + proxy_type: ProxyType = typer.Option( + ProxyType.Any.value, + "--proxy-type", + help="Type of proxy (only used with --delegate, not --all)", + ), delay: int = typer.Option(0, help="Delay, in number of blocks"), wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, @@ -9842,24 +9856,21 @@ def proxy_remove( """ Unregisters a proxy from an account. - Revokes proxy permissions previously granted to another account. This prevents the delegate account from executing any further transactions on your behalf. - - - [bold]Example:[/bold] - Revoke proxy permissions from a single proxy account - [green]$[/green] btcli proxy remove --delegate 5GDel... --proxy-type Transfer - + Revokes proxy permissions previously granted to another account. Use --delegate for a single proxy or --all to remove every proxy. """ - # TODO should add a --all flag to call Proxy.remove_proxies ? + if remove_all and delegate: + print_error("Cannot use --all and --delegate together. Choose one.") + raise typer.Exit(1) + if not remove_all and not delegate: + print_error("Either --delegate or --all is required.") + raise typer.Exit(1) logger.debug( "args:\n" f"delegate: {delegate}\n" + f"remove_all: {remove_all}\n" f"network: {network}\n" f"proxy_type: {proxy_type}\n" f"delay: {delay}\n" - f"wait_for_finalization: {wait_for_finalization}\n" - f"wait_for_inclusion: {wait_for_inclusion}\n" - f"era: {period}\n" ) self.verbosity_handler(quiet, verbose, json_output, prompt) wallet = self.wallet_ask( @@ -9869,9 +9880,24 @@ def proxy_remove( ask_for=[WO.NAME, WO.PATH], validate=WV.WALLET, ) + subtensor = self.initialize_chain(network) + if remove_all: + return self._run_command( + proxy_commands.remove_all_proxies( + subtensor=subtensor, + wallet=wallet, + prompt=prompt, + decline=decline, + quiet=quiet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + json_output=json_output, + ) + ) return self._run_command( proxy_commands.remove_proxy( - subtensor=self.initialize_chain(network), + subtensor=subtensor, wallet=wallet, delegate=delegate, proxy_type=proxy_type, @@ -10195,6 +10221,176 @@ def proxy_execute_announced( with ProxyAnnouncements.get_db() as (conn, cursor): ProxyAnnouncements.mark_as_executed(conn, cursor, got_call_from_db) + def proxy_list( + self, + address: Optional[str] = typer.Option( + None, + "--address", + help="SS58 address to list proxies for. Defaults to the selected wallet.", + callback=is_valid_ss58_address_param, + ), + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """List proxies configured for an account (chain Proxy.Proxies storage).""" + self.verbosity_handler(quiet, verbose, json_output, prompt=False) + if address is None: + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + address = wallet.coldkeypub.ss58_address + return self._run_command( + proxy_commands.list_proxies( + subtensor=self.initialize_chain(network), + address=address, + prompt=False, + json_output=json_output, + ) + ) + + def proxy_reject_announced( + self, + delegate: Optional[str] = typer.Option( + None, + "--delegate", + help="SS58 address of the delegate who made the announcement. Required with --no-prompt.", + callback=is_valid_ss58_address_param, + ), + call_hash: Optional[str] = typer.Option( + None, + "--call-hash", + help="Hash of the announced call to reject. Resolved from ProxyAnnouncements table if available.", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + decline: bool = Options.decline, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + period: int = Options.period, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Reject a previously announced proxy call (Proxy.reject_announcement). + + If --call-hash is provided, the command attempts to resolve it from the + ProxyAnnouncements table and marks it as executed on success (same flow + as btcli proxy execute). + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + real_address = wallet.coldkeypub.ss58_address + if delegate is None: + if not prompt: + print_error("--delegate is required when using --no-prompt.") + raise typer.Exit(1) + while True: + delegate = Prompt.ask("Enter the delegate SS58 address") + if is_valid_ss58_address(delegate): + break + print_error(f"Invalid SS58 address: {delegate}") + + got_call_from_db: Optional[int] = None + if call_hash is not None: + with ProxyAnnouncements.get_db() as (conn, cursor): + announcements = ProxyAnnouncements.read_rows(conn, cursor) + potential_call_matches = [] + for row in announcements: + ( + id_, + address, + epoch_time, + block_, + call_hash_, + call_hex_, + call_serialized, + executed_int, + ) = row + if ( + (call_hash_ == call_hash or f"0x{call_hash_}" == call_hash) + and not bool(executed_int) + and address == real_address + ): + potential_call_matches.append(row) + if len(potential_call_matches) == 1: + got_call_from_db = potential_call_matches[0][0] + elif len(potential_call_matches) > 1: + if prompt: + console.print( + f"The call hash you provided matches {len(potential_call_matches)} possible " + "entries for this wallet. The results will be iterated for you to select your intended call." + ) + for row in potential_call_matches: + ( + id_, + address, + epoch_time, + block_, + call_hash_, + call_hex_, + call_serialized, + executed_int, + ) = row + console.print( + f"Time: {datetime.datetime.fromtimestamp(epoch_time)}\nCall:\n" + ) + console.print_json(call_serialized) + if confirm_action( + "Is this the intended call?", + decline=decline, + quiet=quiet, + ): + got_call_from_db = id_ + break + else: + verbose_console.print( + f"Call hash '{call_hash}' matched {len(potential_call_matches)} unexecuted entries " + "for this wallet. Skipping local mark_as_executed due to ambiguity." + ) + else: + if not prompt: + print_error("--call-hash is required when using --no-prompt.") + raise typer.Exit(1) + call_hash = Prompt.ask("Enter the call hash to reject") + + success = self._run_command( + proxy_commands.reject_announcement( + subtensor=self.initialize_chain(network), + wallet=wallet, + delegate=delegate, + call_hash=call_hash, + prompt=prompt, + decline=decline, + quiet=quiet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + json_output=json_output, + ) + ) + if success and got_call_from_db is not None: + with ProxyAnnouncements.get_db() as (conn, cursor): + ProxyAnnouncements.mark_as_executed(conn, cursor, got_call_from_db) + @staticmethod def convert( from_rao: Optional[str] = typer.Option( diff --git a/bittensor_cli/src/bittensor/extrinsics/mev_shield.py b/bittensor_cli/src/bittensor/extrinsics/mev_shield.py index 81750f2a..d4331b80 100644 --- a/bittensor_cli/src/bittensor/extrinsics/mev_shield.py +++ b/bittensor_cli/src/bittensor/extrinsics/mev_shield.py @@ -52,6 +52,25 @@ async def encrypt_extrinsic( return encrypted_call +async def extract_mev_shield_id(response: "AsyncExtrinsicReceipt") -> Optional[str]: + """ + Extract the MEV Shield wrapper ID from an extrinsic response. + + After submitting a MEV Shield encrypted call, the EncryptedSubmitted event + contains the wrapper ID needed to track execution. + + Args: + response: The extrinsic receipt from submit_extrinsic. + + Returns: + The wrapper ID (hex string) or None if not found. + """ + for event in await response.triggered_events: + if event["event_id"] == "EncryptedSubmitted": + return event["attributes"]["id"] + return None + + async def wait_for_extrinsic_by_hash( subtensor: "SubtensorInterface", extrinsic_hash: str, diff --git a/bittensor_cli/src/commands/proxy.py b/bittensor_cli/src/commands/proxy.py index 8852fedf..15b4aef4 100644 --- a/bittensor_cli/src/commands/proxy.py +++ b/bittensor_cli/src/commands/proxy.py @@ -1,14 +1,16 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional import sys from async_substrate_interface.errors import StateDiscardedError from rich.prompt import Prompt, FloatPrompt, IntPrompt +from rich.table import Table from scalecodec import GenericCall, ScaleBytes from bittensor_cli.src import COLORS from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.utils import ( confirm_action, + decode_account_id, print_extrinsic_id, json_console, console, @@ -58,6 +60,259 @@ class ProxyType(StrEnum): # TODO add announce with also --reject and --remove +def _parse_proxy_storage(raw: Any) -> tuple[list[dict[str, Any]], Any]: + """Parse Proxy.Proxies storage value: (Vec<(AccountId, ProxyType, BlockNumber)>, Balance).""" + if raw is None: + return [], None + if isinstance(raw, (list, tuple)) and len(raw) >= 1: + proxies_raw = raw[0] + deposit = raw[1] if len(raw) > 1 else None + else: + return [], None + if not isinstance(proxies_raw, (list, tuple)): + return [], deposit + rows = [] + for item in proxies_raw: + try: + # Unwrap single-element tuple/list wrappers (substrate nesting) + while ( + isinstance(item, (list, tuple)) + and len(item) == 1 + and isinstance(item[0], (dict, list, tuple)) + ): + item = item[0] + if isinstance(item, dict): + delegate_raw = item.get("delegate") or item.get("delegatee") + ptype = item.get("proxy_type", "") + delay = item.get("delay", 0) + elif isinstance(item, (list, tuple)) and len(item) >= 3: + delegate_raw, ptype, delay = item[0], item[1], item[2] + else: + continue + # Unwrap nested delegate tuple, e.g. ((48, 103, ...),) -> (48, 103, ...) + while ( + isinstance(delegate_raw, (list, tuple)) + and len(delegate_raw) == 1 + and isinstance(delegate_raw[0], (list, tuple)) + ): + delegate_raw = delegate_raw[0] + if isinstance(delegate_raw, list): + delegate_raw = tuple(delegate_raw) + if isinstance(delegate_raw, str) and delegate_raw.startswith("5"): + delegate_ss58 = delegate_raw + else: + delegate_ss58 = decode_account_id( + delegate_raw if isinstance(delegate_raw, tuple) else (delegate_raw,) + ) + if isinstance(ptype, dict): + proxy_type_str = next(iter(ptype), "") + elif isinstance(ptype, str): + proxy_type_str = ptype + else: + proxy_type_str = getattr(ptype, "value", str(ptype)) + rows.append( + { + "delegate": delegate_ss58, + "proxy_type": proxy_type_str, + "delay": int(delay) if delay is not None else 0, + } + ) + except (KeyError, TypeError, ValueError, IndexError): + continue + return rows, deposit + + +async def list_proxies( + subtensor: "SubtensorInterface", + address: str, + prompt: bool, + json_output: bool, +) -> None: + """Query Proxy.Proxies storage for an account and display the list.""" + try: + raw = await subtensor.query( + module="Proxy", + storage_function="Proxies", + params=[address], + ) + except Exception as e: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": str(e), + "proxies": [], + "deposit": None, + } + ) + else: + print_error(f"Failed to query proxies: {e}") + return + if not raw and raw is not None: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": "Query returned empty result", + "proxies": [], + "deposit": None, + } + ) + else: + console.print("No proxies configured for this account.") + return + rows, deposit = _parse_proxy_storage(raw) + if json_output: + deposit_val = deposit.value if hasattr(deposit, "value") else deposit + json_console.print_json( + data={ + "success": True, + "proxies": rows, + "deposit": deposit_val, + } + ) + return + if not rows: + console.print("No proxies configured for this account.") + return + table = Table() + for col in ("Delegate", "Proxy Type", "Delay"): + table.add_column(col) + for r in rows: + table.add_row(r["delegate"], r["proxy_type"], str(r["delay"])) + console.print(table) + + +async def reject_announcement( + subtensor: "SubtensorInterface", + wallet: "Wallet", + delegate: str, + call_hash: str, + prompt: bool, + decline: bool, + quiet: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + period: int, + json_output: bool, +) -> bool: + """Submit Proxy.reject_announcement. Returns True on success.""" + if prompt: + if not confirm_action( + f"Reject the announced call from delegate {delegate}?", + decline=decline, + quiet=quiet, + ): + if json_output: + json_console.print_json( + data={ + "success": False, + "message": "Cancelled", + "extrinsic_identifier": None, + } + ) + return False + if not (ulw := unlock_key(wallet, print_out=not json_output)).success: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": ulw.message, + "extrinsic_identifier": None, + } + ) + else: + print_error(ulw.message) + return False + call = await subtensor.substrate.compose_call( + call_module="Proxy", + call_function="reject_announcement", + call_params={"delegate": delegate, "call_hash": call_hash}, + ) + success, msg, receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + era={"period": period}, + ) + if success: + if json_output: + json_console.print_json( + data={ + "success": True, + "message": msg, + "extrinsic_identifier": await receipt.get_extrinsic_identifier(), + } + ) + else: + await print_extrinsic_id(receipt) + print_success("Success!") + else: + if json_output: + json_console.print_json( + data={"success": False, "message": msg, "extrinsic_identifier": None} + ) + else: + print_error(f"Failed: {msg}") + return success + + +async def remove_all_proxies( + subtensor: "SubtensorInterface", + wallet: "Wallet", + prompt: bool, + decline: bool, + quiet: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + period: int, + json_output: bool, +) -> None: + """Remove all proxies for the account via Proxy.remove_proxies.""" + if prompt: + if not confirm_action( + "This will remove all proxies for this account. Do you want to proceed?", + decline=decline, + quiet=quiet, + ): + if json_output: + json_console.print_json( + data={ + "success": False, + "message": "Cancelled", + "extrinsic_identifier": None, + } + ) + return + if not (ulw := unlock_key(wallet, print_out=not json_output)).success: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": ulw.message, + "extrinsic_identifier": None, + } + ) + else: + print_error(ulw.message) + return + call = await subtensor.substrate.compose_call( + call_module="Proxy", + call_function="remove_proxies", + call_params={}, + ) + await submit_proxy( + subtensor=subtensor, + wallet=wallet, + call=call, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + json_output=json_output, + ) + + async def submit_proxy( subtensor: "SubtensorInterface", wallet: "Wallet", diff --git a/tests/e2e_tests/test_proxy.py b/tests/e2e_tests/test_proxy.py index e5e76724..f1b1a7b4 100644 --- a/tests/e2e_tests/test_proxy.py +++ b/tests/e2e_tests/test_proxy.py @@ -684,3 +684,314 @@ def test_add_proxy(local_chain, wallet_setup): os.environ["BTCLI_PROXIES_PATH"] = "" if os.path.exists(testing_db_loc): os.remove(testing_db_loc) + + +def test_proxy_list_after_add(local_chain, wallet_setup): + """After adding a proxy, proxy list shows it; list with --address works.""" + testing_db_loc = "/tmp/btcli-test.db" + os.environ["BTCLI_PROXIES_PATH"] = testing_db_loc + wallet_path_alice = "//Alice" + wallet_path_dave = "//Dave" + + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_dave, wallet_dave, wallet_path_dave, exec_command_dave = wallet_setup( + wallet_path_dave + ) + proxy_type = "Any" + delay = 0 + + try: + add_result = exec_command_alice( + command="proxy", + sub_command="add", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--delegate", + wallet_dave.coldkeypub.ss58_address, + "--proxy-type", + proxy_type, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + add_result_output = json.loads(add_result.stdout) + assert add_result_output["success"] is True + + list_result = exec_command_alice( + command="proxy", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--json-output", + ], + ) + list_output = json.loads(list_result.stdout) + assert list_output.get("success") is True + proxies = list_output.get("proxies") or [] + assert len(proxies) >= 1 + delegates = [p["delegate"] for p in proxies] + assert wallet_dave.coldkeypub.ss58_address in delegates + + list_by_addr = exec_command_alice( + command="proxy", + sub_command="list", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--address", + wallet_alice.coldkeypub.ss58_address, + "--json-output", + ], + ) + list_by_addr_output = json.loads(list_by_addr.stdout) + assert list_by_addr_output.get("success") is True + assert len(list_by_addr_output.get("proxies") or []) >= 1 + + exec_command_alice( + command="proxy", + sub_command="remove", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--delegate", + wallet_dave.coldkeypub.ss58_address, + "--proxy-type", + proxy_type, + "--delay", + str(delay), + "--no-prompt", + "--json-output", + ], + ) + finally: + os.environ["BTCLI_PROXIES_PATH"] = "" + if os.path.exists(testing_db_loc): + os.remove(testing_db_loc) + + +def test_proxy_remove_all(local_chain, wallet_setup): + """proxy remove --all removes every proxy for the account.""" + testing_db_loc = "/tmp/btcli-test.db" + os.environ["BTCLI_PROXIES_PATH"] = testing_db_loc + wallet_path_alice = "//Alice" + wallet_path_dave = "//Dave" + + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_dave, wallet_dave, wallet_path_dave, exec_command_dave = wallet_setup( + wallet_path_dave + ) + proxy_type = "Any" + delay = 0 + + try: + exec_command_alice( + command="proxy", + sub_command="add", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--delegate", + wallet_dave.coldkeypub.ss58_address, + "--proxy-type", + proxy_type, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + + remove_all_result = exec_command_alice( + command="proxy", + sub_command="remove", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--all", + "--no-prompt", + "--json-output", + ], + ) + remove_all_output = json.loads(remove_all_result.stdout) + assert remove_all_output["success"] is True + + list_result = exec_command_alice( + command="proxy", + sub_command="list", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--address", + wallet_alice.coldkeypub.ss58_address, + "--json-output", + ], + ) + list_output = json.loads(list_result.stdout) + assert list_output.get("success") is True + assert len(list_output.get("proxies") or []) == 0 + finally: + os.environ["BTCLI_PROXIES_PATH"] = "" + if os.path.exists(testing_db_loc): + os.remove(testing_db_loc) + + +def test_proxy_reject_announced(local_chain, wallet_setup): + """proxy reject removes announcement and prevents execution of that call.""" + testing_db_loc = "/tmp/btcli-test.db" + os.environ["BTCLI_PROXIES_PATH"] = testing_db_loc + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + wallet_path_dave = "//Dave" + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, _, _, _ = wallet_setup(wallet_path_bob) + _, wallet_dave, wallet_path_dave, exec_command_dave = wallet_setup(wallet_path_dave) + proxy_type = "Any" + delay = 1 + + try: + add_result = exec_command_alice( + command="proxy", + sub_command="add", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--delegate", + wallet_dave.coldkeypub.ss58_address, + "--proxy-type", + proxy_type, + "--delay", + str(delay), + "--period", + "128", + "--no-prompt", + "--json-output", + ], + ) + add_output = json.loads(add_result.stdout) + assert add_output["success"] is True + + announce_result = exec_command_dave( + command="wallet", + sub_command="transfer", + extra_args=[ + "--wallet-path", + wallet_path_dave, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + wallet_alice.coldkeypub.ss58_address, + "--dest", + keypair_bob.ss58_address, + "--amount", + "100", + "--no-prompt", + "--json-output", + "--announce-only", + ], + ) + announce_output = json.loads(announce_result.stdout) + assert announce_output["success"] is True + + with ProxyAnnouncements.get_db() as (conn, cursor): + rows = ProxyAnnouncements.read_rows(conn, cursor, include_header=False) + latest = next(iter(sorted(rows, key=lambda row: row[2], reverse=True))) + _, address, _, _, call_hash, call_hex, _, executed_int = latest + assert address == wallet_alice.coldkeypub.ss58_address + assert executed_int == 0 + + reject_result = exec_command_alice( + command="proxy", + sub_command="reject", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--delegate", + wallet_dave.coldkeypub.ss58_address, + "--call-hash", + call_hash, + "--no-prompt", + "--json-output", + ], + ) + reject_output = json.loads(reject_result.stdout) + assert reject_output["success"] is True + + with ProxyAnnouncements.get_db() as (conn, cursor): + rows = ProxyAnnouncements.read_rows(conn, cursor, include_header=False) + matched_rows = [ + row for row in rows if row[4] == call_hash and row[1] == address + ] + assert len(matched_rows) >= 1 + assert any(bool(row[7]) for row in matched_rows) + + execute_result = exec_command_dave( + command="proxy", + sub_command="execute", + extra_args=[ + "--wallet-path", + wallet_path_dave, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--proxy", + wallet_alice.coldkeypub.ss58_address, + "--call-hash", + call_hash, + "--call-hex", + call_hex, + "--no-prompt", + "--json-output", + ], + ) + execute_output = json.loads(execute_result.stdout) + assert execute_output["success"] is False + finally: + os.environ["BTCLI_PROXIES_PATH"] = "" + if os.path.exists(testing_db_loc): + os.remove(testing_db_loc) diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py index fbd4130d..d1feb3f7 100644 --- a/tests/unit_tests/test_cli.py +++ b/tests/unit_tests/test_cli.py @@ -1,3 +1,4 @@ +import asyncio import numpy as np import pytest import typer @@ -9,6 +10,8 @@ get_current_weights_for_uid, set_root_weights_extrinsic, ) +from bittensor_cli.src.commands import proxy as proxy_commands +from bittensor_cli.src.commands.proxy import _parse_proxy_storage from unittest.mock import AsyncMock, patch, MagicMock, Mock from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface @@ -768,6 +771,578 @@ async def test_set_root_weights_skips_current_weights_without_prompt(): mock_get_current.assert_not_called() +# ============================================================================ +# Tests for proxy list, reject, remove --all (issue #742) +# ============================================================================ + + +def test_parse_proxy_storage_empty(): + """_parse_proxy_storage returns empty list for None or empty input.""" + rows, dep = _parse_proxy_storage(None) + assert rows == [] + assert dep is None + rows, dep = _parse_proxy_storage([]) + assert rows == [] + assert dep is None + + +def test_parse_proxy_storage_one_row(): + """_parse_proxy_storage decodes one proxy row when decode_account_id works.""" + with patch( + "bittensor_cli.src.commands.proxy.decode_account_id", + return_value="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + ): + raw = ([(tuple(range(32)), "Any", 0)], 100) + rows, dep = _parse_proxy_storage(raw) + assert len(rows) == 1 + assert rows[0]["delegate"] == "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + assert rows[0]["proxy_type"] == "Any" + assert rows[0]["delay"] == 0 + assert dep == 100 + + +def test_parse_proxy_storage_delegate_as_list(): + """_parse_proxy_storage converts list delegate to tuple for decode_account_id.""" + with patch( + "bittensor_cli.src.commands.proxy.decode_account_id", + return_value="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + ) as mock_decode: + raw = ([(list(range(32)), "Transfer", 1)], None) + rows, dep = _parse_proxy_storage(raw) + assert len(rows) == 1 + assert rows[0]["delegate"] == "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + assert rows[0]["proxy_type"] == "Transfer" + assert rows[0]["delay"] == 1 + mock_decode.assert_called_once() + call_arg = mock_decode.call_args[0][0] + assert isinstance(call_arg, tuple) + assert len(call_arg) == 32 + + +def test_parse_proxy_storage_substrate_nested_format(): + """_parse_proxy_storage handles actual substrate response with nested tuples and dict proxy_type.""" + with patch( + "bittensor_cli.src.commands.proxy.decode_account_id", + return_value="5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy", + ) as mock_decode: + # Actual substrate format: items wrapped in extra tuples, delegate nested, proxy_type as dict + raw = ( + ( + ( + { + "delegate": (tuple(range(32)),), + "proxy_type": {"Any": ()}, + "delay": 0, + }, + ), + ), + 93000000, + ) + rows, dep = _parse_proxy_storage(raw) + assert len(rows) == 1 + assert rows[0]["delegate"] == "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" + assert rows[0]["proxy_type"] == "Any" + assert rows[0]["delay"] == 0 + assert dep == 93000000 + mock_decode.assert_called_once() + call_arg = mock_decode.call_args[0][0] + assert isinstance(call_arg, tuple) + assert len(call_arg) == 32 + + +def test_proxy_remove_all_and_delegate_mutually_exclusive(): + """proxy remove: --all and --delegate cannot be used together.""" + cli = CLIManager() + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask") as mock_wallet, + patch("bittensor_cli.cli.print_error"), + ): + mock_wallet.return_value = Mock() + mock_wallet.return_value.coldkeypub = Mock() + mock_wallet.return_value.coldkeypub.ss58_address = ( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + with pytest.raises(typer.Exit): + cli.proxy_remove( + delegate="5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + remove_all=True, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + quiet=True, + verbose=False, + json_output=False, + ) + + +def test_proxy_remove_requires_delegate_or_all(): + """proxy remove: one of --delegate or --all is required.""" + cli = CLIManager() + with ( + patch.object(cli, "verbosity_handler"), + patch("bittensor_cli.cli.print_error"), + ): + with pytest.raises(typer.Exit): + cli.proxy_remove( + delegate=None, + remove_all=False, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + quiet=True, + verbose=False, + json_output=False, + ) + + +def test_proxy_remove_with_all_calls_remove_all_proxies(): + """proxy remove --all invokes remove_all_proxies.""" + cli = CLIManager() + mock_subtensor = Mock() + mock_wallet = Mock() + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=None) as mock_run, + patch.object(proxy_commands, "remove_all_proxies", new_callable=AsyncMock), + ): + cli.proxy_remove( + delegate=None, + remove_all=True, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + quiet=True, + verbose=False, + json_output=False, + ) + mock_run.assert_called_once() + call_arg = mock_run.call_args[0][0] + assert asyncio.iscoroutine(call_arg) + proxy_commands.remove_all_proxies.assert_called_once() + proxy_commands.remove_all_proxies.assert_called_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + prompt=False, + decline=False, + quiet=True, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + json_output=False, + ) + + +def test_proxy_remove_with_delegate_calls_remove_proxy(): + """proxy remove --delegate invokes remove_proxy (single).""" + cli = CLIManager() + mock_subtensor = Mock() + mock_wallet = Mock() + delegate = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=None), + patch.object(proxy_commands, "remove_proxy", new_callable=AsyncMock), + ): + cli.proxy_remove( + delegate=delegate, + remove_all=False, + network=None, + proxy_type=proxy_commands.ProxyType.Any, + delay=0, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + quiet=True, + verbose=False, + json_output=False, + ) + proxy_commands.remove_proxy.assert_called_once_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + delegate=delegate, + proxy_type=proxy_commands.ProxyType.Any, + delay=0, + prompt=False, + decline=False, + quiet=True, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + json_output=False, + ) + + +def test_proxy_list_with_address_calls_list_proxies(): + """proxy list --address calls list_proxies with that address.""" + cli = CLIManager() + mock_subtensor = Mock() + addr = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=None), + patch.object(proxy_commands, "list_proxies", new_callable=AsyncMock), + ): + cli.proxy_list( + address=addr, + network=None, + wallet_name=None, + wallet_path=None, + wallet_hotkey=None, + quiet=True, + verbose=False, + json_output=False, + ) + proxy_commands.list_proxies.assert_called_once_with( + subtensor=mock_subtensor, + address=addr, + prompt=False, + json_output=False, + ) + + +def test_proxy_list_without_address_uses_wallet(): + """proxy list without --address uses wallet coldkey and calls list_proxies.""" + cli = CLIManager() + mock_subtensor = Mock() + mock_wallet = Mock() + mock_wallet.coldkeypub = Mock() + mock_wallet.coldkeypub.ss58_address = ( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=None), + patch.object(proxy_commands, "list_proxies", new_callable=AsyncMock), + ): + cli.proxy_list( + address=None, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey=None, + quiet=True, + verbose=False, + json_output=False, + ) + proxy_commands.list_proxies.assert_called_once_with( + subtensor=mock_subtensor, + address=mock_wallet.coldkeypub.ss58_address, + prompt=False, + json_output=False, + ) + + +def test_proxy_reject_announced_calls_reject_announcement(): + """proxy reject invokes reject_announcement with delegate and call_hash.""" + cli = CLIManager() + mock_subtensor = Mock() + mock_wallet = Mock() + mock_wallet.coldkeypub = Mock() + mock_wallet.coldkeypub.ss58_address = ( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + delegate = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + call_hash = "0x1234abcd" + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=True), + patch.object(proxy_commands, "reject_announcement", new_callable=AsyncMock), + patch("bittensor_cli.cli.ProxyAnnouncements") as mock_pa, + ): + mock_pa.get_db.return_value.__enter__ = Mock(return_value=(Mock(), Mock())) + mock_pa.get_db.return_value.__exit__ = Mock(return_value=False) + mock_pa.read_rows.return_value = [] + cli.proxy_reject_announced( + delegate=delegate, + call_hash=call_hash, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + quiet=True, + verbose=False, + json_output=False, + ) + proxy_commands.reject_announcement.assert_called_once_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + delegate=delegate, + call_hash=call_hash, + prompt=False, + decline=False, + quiet=True, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + json_output=False, + ) + + +def test_proxy_reject_announced_requires_delegate_without_prompt(): + """proxy reject requires --delegate in non-interactive mode.""" + cli = CLIManager() + mock_wallet = Mock() + mock_wallet.coldkeypub = Mock() + mock_wallet.coldkeypub.ss58_address = ( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "_run_command") as mock_run_command, + patch("bittensor_cli.cli.print_error"), + ): + with pytest.raises(typer.Exit): + cli.proxy_reject_announced( + delegate=None, + call_hash="0x1234abcd", + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + quiet=True, + verbose=False, + json_output=False, + ) + mock_run_command.assert_not_called() + + +def test_proxy_reject_announced_prompts_for_delegate_when_missing(): + """proxy reject prompts for delegate in interactive mode when not provided.""" + cli = CLIManager() + mock_subtensor = Mock() + mock_wallet = Mock() + mock_wallet.coldkeypub = Mock() + mock_wallet.coldkeypub.ss58_address = ( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + delegate = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + call_hash = "0x1234abcd" + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=True), + patch.object(proxy_commands, "reject_announcement", new_callable=AsyncMock), + patch("bittensor_cli.cli.Prompt.ask", return_value=delegate), + patch("bittensor_cli.cli.ProxyAnnouncements") as mock_pa, + ): + mock_pa.get_db.return_value.__enter__ = Mock(return_value=(Mock(), Mock())) + mock_pa.get_db.return_value.__exit__ = Mock(return_value=False) + mock_pa.read_rows.return_value = [] + cli.proxy_reject_announced( + delegate=None, + call_hash=call_hash, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=True, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + quiet=True, + verbose=False, + json_output=False, + ) + proxy_commands.reject_announcement.assert_called_once_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + delegate=delegate, + call_hash=call_hash, + prompt=True, + decline=False, + quiet=True, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + json_output=False, + ) + + +def test_proxy_reject_announced_marks_executed_in_db(): + """proxy reject marks announcement as executed in ProxyAnnouncements on success.""" + cli = CLIManager() + mock_subtensor = Mock() + mock_wallet = Mock() + mock_wallet.coldkeypub = Mock() + mock_wallet.coldkeypub.ss58_address = ( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + delegate = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + call_hash = "0xdeadbeef" + other_wallet_row = ( + 41, + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + 1, + 100, + "0xdeadbeef", + "0xCAFE", + "{}", + 0, + ) + matching_wallet_row = ( + 42, + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + 2, + 101, + "0xdeadbeef", + "0xCAFF", + "{}", + 0, + ) + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=True), + patch.object(proxy_commands, "reject_announcement", new_callable=AsyncMock), + patch("bittensor_cli.cli.ProxyAnnouncements") as mock_pa, + ): + mock_conn = Mock() + mock_cursor = Mock() + mock_pa.get_db.return_value.__enter__ = Mock( + return_value=(mock_conn, mock_cursor) + ) + mock_pa.get_db.return_value.__exit__ = Mock(return_value=False) + mock_pa.read_rows.return_value = [other_wallet_row, matching_wallet_row] + cli.proxy_reject_announced( + delegate=delegate, + call_hash=call_hash, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + quiet=True, + verbose=False, + json_output=False, + ) + mock_pa.mark_as_executed.assert_called_once_with(mock_conn, mock_cursor, 42) + + +def test_proxy_reject_announced_ambiguous_db_entries_skip_mark_no_prompt(): + """proxy reject does not mark DB executed when call hash matches multiple rows for same wallet.""" + cli = CLIManager() + mock_subtensor = Mock() + mock_wallet = Mock() + mock_wallet.coldkeypub = Mock() + mock_wallet.coldkeypub.ss58_address = ( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + delegate = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + call_hash = "0xdeadbeef" + db_rows = [ + ( + 41, + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + 1, + 100, + "0xdeadbeef", + "0xCAFE", + "{}", + 0, + ), + ( + 42, + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + 2, + 101, + "0xdeadbeef", + "0xCAFF", + "{}", + 0, + ), + ] + with ( + patch.object(cli, "verbosity_handler"), + patch.object(cli, "wallet_ask", return_value=mock_wallet), + patch.object(cli, "initialize_chain", return_value=mock_subtensor), + patch.object(cli, "_run_command", return_value=True), + patch.object(proxy_commands, "reject_announcement", new_callable=AsyncMock), + patch("bittensor_cli.cli.ProxyAnnouncements") as mock_pa, + ): + mock_conn = Mock() + mock_cursor = Mock() + mock_pa.get_db.return_value.__enter__ = Mock( + return_value=(mock_conn, mock_cursor) + ) + mock_pa.get_db.return_value.__exit__ = Mock(return_value=False) + mock_pa.read_rows.return_value = db_rows + cli.proxy_reject_announced( + delegate=delegate, + call_hash=call_hash, + network=None, + wallet_name="default", + wallet_path="/tmp", + wallet_hotkey="default", + prompt=False, + decline=False, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + quiet=True, + verbose=False, + json_output=False, + ) + proxy_commands.reject_announcement.assert_called_once_with( + subtensor=mock_subtensor, + wallet=mock_wallet, + delegate=delegate, + call_hash=call_hash, + prompt=False, + decline=False, + quiet=True, + wait_for_inclusion=False, + wait_for_finalization=False, + period=16, + json_output=False, + ) + mock_pa.mark_as_executed.assert_not_called() + + # HYPERPARAMS / HYPERPARAMS_METADATA (issue #826) NEW_HYPERPARAMS_826 = {"sn_owner_hotkey", "subnet_owner_hotkey", "recycle_or_burn"}