From e0879e7a3ce2d7faf124a9933c5ef6525c4ad29d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 19 Dec 2025 14:04:12 +0200 Subject: [PATCH 01/19] Added crowdloan enhancements --- bittensor_cli/cli.py | 158 +++++- .../src/commands/crowd/contributors.py | 265 +++++++++ bittensor_cli/src/commands/crowd/create.py | 187 +++++- bittensor_cli/src/commands/crowd/view.py | 533 +++++++++++++++++- pyproject.toml | 3 + tests/e2e_tests/test_crowd_contributors.py | 260 +++++++++ .../e2e_tests/test_crowd_identity_display.py | 150 +++++ tests/unit_tests/test_crowd_contributors.py | 496 ++++++++++++++++ .../test_crowd_create_custom_call.py | 180 ++++++ 9 files changed, 2201 insertions(+), 31 deletions(-) create mode 100644 bittensor_cli/src/commands/crowd/contributors.py create mode 100644 tests/e2e_tests/test_crowd_contributors.py create mode 100644 tests/e2e_tests/test_crowd_identity_display.py create mode 100644 tests/unit_tests/test_crowd_contributors.py create mode 100644 tests/unit_tests/test_crowd_create_custom_call.py diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 302e56beb..0a37364c8 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -86,6 +86,7 @@ view as view_crowdloan, update as crowd_update, refund as crowd_refund, + contributors as crowd_contributors, ) from bittensor_cli.src.commands.liquidity.utils import ( prompt_liquidity, @@ -1334,6 +1335,9 @@ def __init__(self): self.crowd_app.command("info", rich_help_panel=HELP_PANELS["CROWD"]["INFO"])( self.crowd_info ) + self.crowd_app.command( + "contributors", rich_help_panel=HELP_PANELS["CROWD"]["INFO"] + )(self.crowd_contributors) self.crowd_app.command( "create", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] )(self.crowd_create) @@ -2904,6 +2908,7 @@ def wallet_inspect( ), wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, netuids: str = Options.netuids, quiet: bool = Options.quiet, @@ -2911,7 +2916,7 @@ def wallet_inspect( json_output: bool = Options.json_output, ): """ - Displays the details of the user's wallet (coldkey) on the Bittensor network. + Displays the details of the user's wallet pairs (coldkey, hotkey) on the Bittensor network. The output is presented as a table with the below columns: @@ -2956,7 +2961,7 @@ def wallet_inspect( ask_for = [WO.NAME, WO.PATH] if not all_wallets else [WO.PATH] validate = WV.WALLET if not all_wallets else WV.NONE wallet = self.wallet_ask( - wallet_name, wallet_path, None, ask_for=ask_for, validate=validate + wallet_name, wallet_path, wallet_hotkey, ask_for=ask_for, validate=validate ) self.initialize_chain(network) @@ -8745,6 +8750,36 @@ def crowd_list( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + show_identities: Optional[str] = typer.Option( + None, + "--show-identities", + help="Show identity names for creators and target addresses. Use 'true' or 'false', or omit for default (true).", + ), + status: Optional[str] = typer.Option( + None, + "--status", + help="Filter by status: active, funded, closed, finalized", + ), + type_filter: Optional[str] = typer.Option( + None, + "--type", + help="Filter by type: subnet, fundraising", + ), + sort_by: Optional[str] = typer.Option( + None, + "--sort-by", + help="Sort by: raised, end, contributors, id", + ), + sort_order: Optional[str] = typer.Option( + None, + "--sort-order", + help="Sort order: asc, desc (default: desc for raised, asc for id)", + ), + search_creator: Optional[str] = typer.Option( + None, + "--search-creator", + help="Search by creator address or identity name", + ), ): """ List crowdloans together with their funding progress and key metadata. @@ -8754,19 +8789,42 @@ def crowd_list( or a general fundraising crowdloan. Use `--verbose` for full-precision amounts and longer addresses. + Use `--show-identities` to show identity names (default: true). + Use `--status` to filter by status (active, funded, closed, finalized). + Use `--type` to filter by type (subnet, fundraising). + Use `--sort-by` and `--sort-order` to sort results. + Use `--search-creator` to search by creator address or identity name. EXAMPLES [green]$[/green] btcli crowd list [green]$[/green] btcli crowd list --verbose + + [green]$[/green] btcli crowd list --show-identities true + + [green]$[/green] btcli crowd list --status active --type subnet + + [green]$[/green] btcli crowd list --sort-by raised --sort-order desc + + [green]$[/green] btcli crowd list --search-creator "5D..." """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) + # Parse show_identities: None or "true" -> True, "false" -> False + show_identities_bool = True # default + if show_identities is not None: + show_identities_bool = show_identities.lower() in ("true", "1", "yes") return self._run_command( view_crowdloan.list_crowdloans( subtensor=self.initialize_chain(network), verbose=verbose, json_output=json_output, + show_identities=show_identities_bool, + status_filter=status, + type_filter=type_filter, + sort_by=sort_by, + sort_order=sort_order, + search_creator=search_creator, ) ) @@ -8786,17 +8844,33 @@ def crowd_info( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + show_identities: Optional[str] = typer.Option( + None, + "--show-identities", + help="Show identity names for creator and target address. Use 'true' or 'false', or omit for default (true).", + ), + show_contributors: Optional[str] = typer.Option( + None, + "--show-contributors", + help="Show contributor list with identities. Use 'true' or 'false', or omit for default (false).", + ), ): """ Display detailed information about a specific crowdloan. Includes funding progress, target account, and call details among other information. + Use `--show-identities` to show identity names (default: true). + Use `--show-contributors` to display the list of contributors (default: false). EXAMPLES [green]$[/green] btcli crowd info --id 0 [green]$[/green] btcli crowd info --id 1 --verbose + + [green]$[/green] btcli crowd info --id 0 --show-identities true + + [green]$[/green] btcli crowd info --id 0 --show-identities true --show-contributors true """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) @@ -8817,6 +8891,16 @@ def crowd_info( validate=WV.WALLET, ) + # Parse show_identities: None or "true" -> True, "false" -> False + show_identities_bool = True # default + if show_identities is not None: + show_identities_bool = show_identities.lower() in ("true", "1", "yes") + + # Parse show_contributors: None or "false" -> False, "true" -> True + show_contributors_bool = False # default + if show_contributors is not None: + show_contributors_bool = show_contributors.lower() in ("true", "1", "yes") + return self._run_command( view_crowdloan.show_crowdloan_details( subtensor=self.initialize_chain(network), @@ -8824,6 +8908,54 @@ def crowd_info( wallet=wallet, verbose=verbose, json_output=json_output, + show_identities=show_identities_bool, + show_contributors=show_contributors_bool, + ) + ) + + def crowd_contributors( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to list contributors for", + ), + network: Optional[list[str]] = Options.network, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + List all contributors to a specific crowdloan. + + Shows contributor addresses, contribution amounts, identity names, and percentages. + Contributors are sorted by contribution amount (highest first). + + EXAMPLES + + [green]$[/green] btcli crowd contributors --id 0 + + [green]$[/green] btcli crowd contributors --id 1 --verbose + + [green]$[/green] btcli crowd contributors --id 2 --json-output + """ + self.verbosity_handler(quiet, verbose, json_output, prompt=False) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + return self._run_command( + crowd_contributors.list_contributors( + subtensor=self.initialize_chain(network), + crowdloan_id=crowdloan_id, + verbose=verbose, + json_output=json_output, ) ) @@ -8886,6 +9018,21 @@ def crowd_create( help="Block number when subnet lease ends (omit for perpetual lease).", min=1, ), + custom_call_pallet: Optional[str] = typer.Option( + None, + "--custom-call-pallet", + help="Pallet name for custom Substrate call to attach to crowdloan.", + ), + custom_call_method: Optional[str] = typer.Option( + None, + "--custom-call-method", + help="Method name for custom Substrate call to attach to crowdloan.", + ), + custom_call_args: Optional[str] = typer.Option( + None, + "--custom-call-args", + help='JSON string of arguments for custom call (e.g., \'{"arg1": "value1", "arg2": 123}\').', + ), prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, @@ -8898,6 +9045,7 @@ def crowd_create( Create a crowdloan that can either: 1. Raise funds for a specific address (general fundraising) 2. Create a new leased subnet where contributors receive emissions + 3. Attach any custom Substrate call (using --custom-call-pallet, --custom-call-method, --custom-call-args) EXAMPLES @@ -8909,6 +9057,9 @@ def crowd_create( Subnet lease ending at block 500000: [green]$[/green] btcli crowd create --subnet-lease --emissions-share 25 --lease-end-block 500000 + + Custom call: + [green]$[/green] btcli crowd create --deposit 10 --cap 1000 --duration 1000 --min-contribution 1 --custom-call-pallet "SomeModule" --custom-call-method "some_method" --custom-call-args '{"param1": "value", "param2": 42}' """ self.verbosity_handler(quiet, verbose, json_output, prompt) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) @@ -8933,6 +9084,9 @@ def crowd_create( subnet_lease=subnet_lease, emissions_share=emissions_share, lease_end_block=lease_end_block, + custom_call_pallet=custom_call_pallet, + custom_call_method=custom_call_method, + custom_call_args=custom_call_args, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, prompt=prompt, diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py new file mode 100644 index 000000000..c64151e07 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -0,0 +1,265 @@ +from typing import Optional +import asyncio +import json +from rich.table import Table + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + console, + json_console, + print_error, + millify_tao, + decode_account_id, +) + + +def _shorten(account: Optional[str]) -> str: + """Shorten an account address for display.""" + if not account: + return "-" + return f"{account[:6]}…{account[-6:]}" + + +def _get_identity_name(identity: dict) -> str: + """Extract identity name from identity dict.""" + if not identity: + return "-" + info = identity.get("info", {}) + display = info.get("display", {}) + if isinstance(display, dict): + return display.get("Raw", "") or display.get("value", "") or "-" + return str(display) if display else "-" + + +async def list_contributors( + subtensor: SubtensorInterface, + crowdloan_id: int, + verbose: bool = False, + json_output: bool = False, +) -> bool: + """List all contributors to a specific crowdloan. + + Args: + subtensor: SubtensorInterface object for chain interaction + crowdloan_id: ID of the crowdloan to list contributors for + verbose: Show full addresses and precise amounts + json_output: Output as JSON + + Returns: + bool: True if successful, False otherwise + """ + # First verify the crowdloan exists + crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False + + # Query contributors from Contributions storage (double map) + # Query map with first key fixed to crowdloan_id to get all contributors + contributors_data = await subtensor.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + fully_exhaust=True, + ) + + # Extract contributors and their contributions from the map + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + # Extract contributor address from the storage key + # For double maps queried with first key fixed, the key is a tuple: ((account_bytes_tuple,),) + # where account_bytes_tuple is a tuple of integers representing the account ID + try: + # The key structure is: ((account_bytes_tuple,),) + # where account_bytes_tuple is a tuple of integers (32 bytes = 32 ints) + if isinstance(contributor_key, tuple) and len(contributor_key) > 0: + inner_tuple = contributor_key[0] + if isinstance(inner_tuple, tuple): + # Decode the account ID from the tuple of integers + # decode_account_id handles both tuple[int] and tuple[tuple[int]] formats + contributor_address = decode_account_id(contributor_key) + else: + # Fallback: try to decode directly + contributor_address = decode_account_id(contributor_key) + else: + # Fallback: try to decode the key directly + contributor_address = decode_account_id(contributor_key) + + # Store contribution amount + # The value is a BittensorScaleType object, access .value to get the integer + contribution_value = ( + contribution_amount.value + if hasattr(contribution_amount, "value") + else contribution_amount + ) + contribution_balance = ( + Balance.from_rao(int(contribution_value)) + if contribution_value + else Balance.from_tao(0) + ) + contributor_contributions[contributor_address] = contribution_balance + except Exception as e: + # Skip invalid entries - uncomment for debugging + # print(f"Error processing contributor: {e}, key: {contributor_key}") + continue + + if not contributor_contributions: + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "error": None, + "data": { + "crowdloan_id": crowdloan_id, + "contributors": [], + "total_count": 0, + "total_contributed": 0, + }, + } + ) + ) + else: + console.print( + f"[yellow]No contributors found for crowdloan #{crowdloan_id}.[/yellow]" + ) + return True + + # Fetch identities for all contributors + contributors_list = list(contributor_contributions.keys()) + identity_tasks = [ + subtensor.query_identity(contributor) for contributor in contributors_list + ] + identities = await asyncio.gather(*identity_tasks) + + # Build contributor data list + contributor_data = [] + total_contributed = Balance.from_tao(0) + + for contributor_address, identity in zip(contributors_list, identities): + contribution_amount = contributor_contributions[contributor_address] + total_contributed += contribution_amount + identity_name = _get_identity_name(identity) + + contributor_data.append( + { + "address": contributor_address, + "identity": identity_name, + "contribution": contribution_amount, + } + ) + + # Sort by contribution amount (descending) + contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) + + # Calculate percentages + for data in contributor_data: + if total_contributed.rao > 0: + percentage = (data["contribution"].rao / total_contributed.rao) * 100 + else: + percentage = 0.0 + data["percentage"] = percentage + + if json_output: + contributors_json = [] + for rank, data in enumerate(contributor_data, start=1): + contributors_json.append( + { + "rank": rank, + "address": data["address"], + "identity": data["identity"], + "contribution_tao": data["contribution"].tao, + "contribution_rao": data["contribution"].rao, + "percentage": data["percentage"], + } + ) + + output_dict = { + "success": True, + "error": None, + "data": { + "crowdloan_id": crowdloan_id, + "contributors": contributors_json, + "total_count": len(contributor_data), + "total_contributed_tao": total_contributed.tao, + "total_contributed_rao": total_contributed.rao, + "network": subtensor.network, + }, + } + json_console.print(json.dumps(output_dict)) + return True + + # Display table + table = Table( + title=f"\n[{COLORS.G.HEADER}]Contributors for Crowdloan #{crowdloan_id}" + f"\nNetwork: [{COLORS.G.SUBHEAD}]{subtensor.network}\n\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column( + "[bold white]Rank", + style="grey89", + justify="center", + footer=str(len(contributor_data)), + ) + table.add_column( + "[bold white]Contributor Address", + style=COLORS.G.TEMPO, + justify="left", + overflow="fold", + ) + table.add_column( + "[bold white]Identity Name", + style=COLORS.G.SUBHEAD, + justify="left", + overflow="fold", + ) + table.add_column( + f"[bold white]Contribution\n({Balance.get_unit(0)})", + style="dark_sea_green2", + justify="right", + footer=f"τ {millify_tao(total_contributed.tao)}" + if not verbose + else f"τ {total_contributed.tao:,.4f}", + ) + table.add_column( + "[bold white]Percentage", + style=COLORS.P.EMISSION, + justify="right", + footer="100.00%", + ) + + for rank, data in enumerate(contributor_data, start=1): + address_cell = data["address"] if verbose else _shorten(data["address"]) + identity_cell = data["identity"] if data["identity"] != "-" else "[dim]-[/dim]" + + if verbose: + contribution_cell = f"τ {data['contribution'].tao:,.4f}" + else: + contribution_cell = f"τ {millify_tao(data['contribution'].tao)}" + + percentage_cell = f"{data['percentage']:.2f}%" + + table.add_row( + str(rank), + address_cell, + identity_cell, + contribution_cell, + percentage_cell, + ) + + console.print(table) + return True diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py index f14d4aae3..5f6ed5208 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -25,6 +25,105 @@ ) +async def validate_and_compose_custom_call( + subtensor: SubtensorInterface, + pallet_name: str, + method_name: str, + args_json: str, +) -> tuple[Optional[GenericCall], Optional[str]]: + """ + Validate and compose a custom Substrate call. + + Args: + subtensor: SubtensorInterface instance + pallet_name: Name of the pallet/module + method_name: Name of the method/function + args_json: JSON string of call arguments + + Returns: + Tuple of (GenericCall or None, error_message or None) + """ + try: + # Parse JSON arguments + try: + call_params = json.loads(args_json) if args_json else {} + except json.JSONDecodeError as e: + return None, f"Invalid JSON in custom call args: {e}" + + # Get metadata to validate call exists + block_hash = await subtensor.substrate.get_chain_head() + runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) + metadata = runtime.metadata + + # Check if pallet exists + try: + # Try using get_metadata_pallet if available (cleaner approach) + if hasattr(metadata, "get_metadata_pallet"): + pallet = metadata.get_metadata_pallet(pallet_name) + else: + # Fallback to iteration + pallet = None + for pallet_item in metadata.pallets: + if pallet_item.name == pallet_name: + pallet = pallet_item + break + except (AttributeError, ValueError): + # Pallet not found + pallet = None + + if pallet is None: + available_pallets = [p.name for p in metadata.pallets] + return None, ( + f"Pallet '{pallet_name}' not found in runtime metadata. " + f"Available pallets: {', '.join(available_pallets[:10])}" + + ( + f" and {len(available_pallets) - 10} more..." + if len(available_pallets) > 10 + else "" + ) + ) + + # Check if method exists in pallet + call_index = None + call_type = None + for call_item in pallet.calls: + if call_item.name == method_name: + call_index = call_item.index + call_type = call_item.type + break + + if call_index is None: + available_methods = [c.name for c in pallet.calls] + return None, ( + f"Method '{method_name}' not found in pallet '{pallet_name}'. " + f"Available methods: {', '.join(available_methods[:10])}" + + ( + f" and {len(available_methods) - 10} more..." + if len(available_methods) > 10 + else "" + ) + ) + + # Validate and compose the call + # The compose_call method will validate the parameters match expected types + try: + call = await subtensor.substrate.compose_call( + call_module=pallet_name, + call_function=method_name, + call_params=call_params, + ) + return call, None + except Exception as e: + error_msg = str(e) + # Try to provide more helpful error messages + if "parameter" in error_msg.lower() or "type" in error_msg.lower(): + return None, f"Invalid call parameters: {error_msg}" + return None, f"Failed to compose call: {error_msg}" + + except Exception as e: + return None, f"Error validating custom call: {str(e)}" + + async def create_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, @@ -37,6 +136,9 @@ async def create_crowdloan( subnet_lease: Optional[bool], emissions_share: Optional[int], lease_end_block: Optional[int], + custom_call_pallet: Optional[str], + custom_call_method: Optional[str], + custom_call_args: Optional[str], wait_for_inclusion: bool, wait_for_finalization: bool, prompt: bool, @@ -59,9 +161,35 @@ async def create_crowdloan( print_error(f"[red]{unlock_status.message}[/red]") return False, unlock_status.message + # Check for custom call options + has_custom_call = any([custom_call_pallet, custom_call_method, custom_call_args]) + if has_custom_call: + if not all([custom_call_pallet, custom_call_method]): + error_msg = "Both --custom-call-pallet and --custom-call-method must be provided when using custom call." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + # Custom call args can be empty JSON object if method has no parameters + if custom_call_args is None: + custom_call_args = "{}" + + # Check mutual exclusivity with subnet_lease + if subnet_lease is not None: + error_msg = "--custom-call-pallet/--custom-call-method cannot be used together with --subnet-lease. Use one or the other." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + crowdloan_type: str if subnet_lease is not None: crowdloan_type = "subnet" if subnet_lease else "fundraising" + elif has_custom_call: + crowdloan_type = "custom" elif prompt: type_choice = IntPrompt.ask( "\n[bold cyan]What type of crowdloan would you like to create?[/bold cyan]\n" @@ -80,6 +208,12 @@ async def create_crowdloan( " • You will become the subnet operator\n" f" • [yellow]Note: Ensure cap covers subnet registration cost (currently {current_burn_cost.tao:,.2f} TAO)[/yellow]\n" ) + elif crowdloan_type == "custom": + console.print( + "\n[yellow]Custom Call Crowdloan Selected[/yellow]\n" + " • A custom Substrate call will be executed when the crowdloan is finalized\n" + " • Ensure the call parameters are correct before proceeding\n" + ) else: console.print( "\n[cyan]General Fundraising Crowdloan Selected[/cyan]\n" @@ -218,7 +352,31 @@ async def create_crowdloan( current_block = await subtensor.substrate.get_block_number(None) call_to_attach: Optional[GenericCall] lease_perpetual = None - if crowdloan_type == "subnet": + custom_call_info: Optional[dict] = None + + if crowdloan_type == "custom": + # Validate and compose custom call + call_to_attach, error_msg = await validate_and_compose_custom_call( + subtensor=subtensor, + pallet_name=custom_call_pallet, + method_name=custom_call_method, + args_json=custom_call_args or "{}", + ) + + if call_to_attach is None: + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg or "Failed to validate custom call." + + custom_call_info = { + "pallet": custom_call_pallet, + "method": custom_call_method, + "args": json.loads(custom_call_args or "{}"), + } + target_address = None # Custom calls don't use target_address + elif crowdloan_type == "subnet": target_address = None if emissions_share is None: @@ -325,6 +483,16 @@ async def create_crowdloan( table.add_row("Lease Ends", f"Block {lease_end_block}") else: table.add_row("Lease Duration", "[green]Perpetual[/green]") + elif crowdloan_type == "custom": + table.add_row("Type", "[yellow]Custom Call[/yellow]") + table.add_row("Pallet", f"[cyan]{custom_call_info['pallet']}[/cyan]") + table.add_row("Method", f"[cyan]{custom_call_info['method']}[/cyan]") + args_str = ( + json.dumps(custom_call_info["args"], indent=2) + if custom_call_info["args"] + else "{}" + ) + table.add_row("Call Arguments", f"[dim]{args_str}[/dim]") else: table.add_row("Type", "[cyan]General Fundraising[/cyan]") target_text = ( @@ -403,6 +571,8 @@ async def create_crowdloan( output_dict["data"]["emissions_share"] = emissions_share output_dict["data"]["lease_end_block"] = lease_end_block output_dict["data"]["perpetual_lease"] = lease_end_block is None + elif crowdloan_type == "custom": + output_dict["data"]["custom_call"] = custom_call_info else: output_dict["data"]["target_address"] = target_address @@ -424,6 +594,21 @@ async def create_crowdloan( console.print(f" Lease ends at block: [bold]{lease_end_block}[/bold]") else: console.print(" Lease: [green]Perpetual[/green]") + elif crowdloan_type == "custom": + message = "Custom call crowdloan created successfully." + console.print( + f"\n:white_check_mark: [green]{message}[/green]\n" + f" Type: [yellow]Custom Call[/yellow]\n" + f" Pallet: [cyan]{custom_call_info['pallet']}[/cyan]\n" + f" Method: [cyan]{custom_call_info['method']}[/cyan]\n" + f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" + f" Min contribution: [{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]\n" + f" Cap: [{COLORS.P.TAO}]{cap}[/{COLORS.P.TAO}]\n" + f" Ends at block: [bold]{end_block}[/bold]" + ) + if custom_call_info["args"]: + args_str = json.dumps(custom_call_info["args"], indent=2) + console.print(f" Call Arguments:\n{args_str}") else: message = "Fundraising crowdloan created successfully." print_success(message) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 9a248d18f..03932e8eb 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -25,6 +25,47 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" +def _get_identity_name(identity: dict) -> str: + """Extract identity name from identity dict. + + Handles both flat structure (from decode_hex_identity) and nested structure. + """ + if not identity: + return "" + + # Try direct display/name fields first (flat structure from decode_hex_identity) + if identity.get("display"): + display = identity.get("display") + if isinstance(display, str): + return display + if isinstance(display, dict): + return display.get("Raw", "") or display.get("value", "") or "" + + if identity.get("name"): + name = identity.get("name") + if isinstance(name, str): + return name + if isinstance(name, dict): + return name.get("Raw", "") or name.get("value", "") or "" + + # Try nested structure (info.display.Raw) + info = identity.get("info", {}) + if info: + display = info.get("display", {}) + if isinstance(display, dict): + return display.get("Raw", "") or display.get("value", "") or "" + if isinstance(display, str): + return display + + name = info.get("name", {}) + if isinstance(name, dict): + return name.get("Raw", "") or name.get("value", "") or "" + if isinstance(name, str): + return name + + return "" + + def _status(loan: CrowdloanData, current_block: int) -> str: if loan.finalized: return "Finalized" @@ -44,12 +85,45 @@ def _time_remaining(loan: CrowdloanData, current_block: int) -> str: return f"Closed {blocks_to_duration(abs(diff))} ago" +def _get_loan_type(loan: CrowdloanData) -> str: + """Determine if a loan is subnet leasing or fundraising.""" + if loan.call_details: + pallet = loan.call_details.get("pallet", "") + method = loan.call_details.get("method", "") + if pallet == "SubtensorModule" and method == "register_leased_network": + return "subnet" + # If has_call is True, it likely indicates a subnet loan + # (subnet loans have calls attached, fundraising loans typically don't) + if loan.has_call: + return "subnet" + # Default to fundraising if no call attached + return "fundraising" + + async def list_crowdloans( subtensor: SubtensorInterface, verbose: bool = False, json_output: bool = False, + show_identities: bool = True, + status_filter: Optional[str] = None, + type_filter: Optional[str] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + search_creator: Optional[str] = None, ) -> bool: - """List all crowdloans in a tabular format or JSON output.""" + """List all crowdloans in a tabular format or JSON output. + + Args: + subtensor: SubtensorInterface object for chain interaction + verbose: Show full addresses and precise amounts + json_output: Output as JSON + show_identities: Show identity names for creators and targets + status_filter: Filter by status (active, funded, closed, finalized) + type_filter: Filter by type (subnet, fundraising) + sort_by: Sort by field (raised, end, contributors, id) + sort_order: Sort order (asc, desc) + search_creator: Search by creator address or identity name + """ current_block, loans = await asyncio.gather( subtensor.substrate.get_block_number(None), @@ -76,10 +150,80 @@ async def list_crowdloans( console.print("[yellow]No crowdloans found.[/yellow]") return True - total_raised = sum(loan.raised.tao for loan in loans.values()) - total_cap = sum(loan.cap.tao for loan in loans.values()) - total_loans = len(loans) - total_contributors = sum(loan.contributors_count for loan in loans.values()) + # Batch fetch identities early if needed for filtering/searching + identity_map = {} + if show_identities or search_creator: + addresses_to_fetch = set() + for loan in loans.values(): + addresses_to_fetch.add(loan.creator) + if loan.target_address: + addresses_to_fetch.add(loan.target_address) + + identity_tasks = [ + subtensor.query_identity(address) for address in addresses_to_fetch + ] + identities = await asyncio.gather(*identity_tasks) + + for address, identity in zip(addresses_to_fetch, identities): + identity_name = _get_identity_name(identity) + if identity_name: + identity_map[address] = identity_name + + # Apply filters + filtered_loans = {} + for loan_id, loan in loans.items(): + # Filter by status + if status_filter: + loan_status = _status(loan, current_block) + if loan_status.lower() != status_filter.lower(): + continue + + # Filter by type + if type_filter: + loan_type = _get_loan_type(loan) + if loan_type.lower() != type_filter.lower(): + continue + + # Filter by creator search + if search_creator: + search_term = search_creator.lower() + creator_match = loan.creator.lower().find(search_term) != -1 + identity_match = False + if loan.creator in identity_map: + identity_name = identity_map[loan.creator].lower() + identity_match = identity_name.find(search_term) != -1 + if not creator_match and not identity_match: + continue + + filtered_loans[loan_id] = loan + + if not filtered_loans: + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "error": None, + "data": { + "crowdloans": [], + "total_count": 0, + "total_raised": 0, + "total_cap": 0, + "total_contributors": 0, + }, + } + ) + ) + else: + console.print("[yellow]No crowdloans found matching the filters.[/yellow]") + return True + + total_raised = sum(loan.raised.tao for loan in filtered_loans.values()) + total_cap = sum(loan.cap.tao for loan in filtered_loans.values()) + total_loans = len(filtered_loans) + total_contributors = sum( + loan.contributors_count for loan in filtered_loans.values() + ) funding_percentage = (total_raised / total_cap * 100) if total_cap > 0 else 0 percentage_color = "dark_sea_green" if funding_percentage < 100 else "red" @@ -89,7 +233,7 @@ async def list_crowdloans( if json_output: crowdloans_list = [] - for loan_id, loan in loans.items(): + for loan_id, loan in filtered_loans.items(): status = _status(loan, current_block) time_remaining = _time_remaining(loan, current_block) @@ -119,19 +263,47 @@ async def list_crowdloans( "time_remaining": time_remaining, "contributors_count": loan.contributors_count, "creator": loan.creator, + "creator_identity": identity_map.get(loan.creator) + if show_identities + else None, "target_address": loan.target_address, + "target_identity": identity_map.get(loan.target_address) + if show_identities and loan.target_address + else None, "funds_account": loan.funds_account, "call": call_info, "finalized": loan.finalized, } crowdloans_list.append(crowdloan_data) - crowdloans_list.sort( - key=lambda x: ( - x["status"] != "Active", - -x["raised"], + # Apply sorting + if sort_by: + reverse_order = True + if sort_order: + reverse_order = sort_order.lower() == "desc" + elif sort_by.lower() == "id": + reverse_order = False + + if sort_by.lower() == "raised": + crowdloans_list.sort(key=lambda x: x["raised"], reverse=reverse_order) + elif sort_by.lower() == "end": + crowdloans_list.sort( + key=lambda x: x["end_block"], reverse=reverse_order + ) + elif sort_by.lower() == "contributors": + crowdloans_list.sort( + key=lambda x: x["contributors_count"], reverse=reverse_order + ) + elif sort_by.lower() == "id": + crowdloans_list.sort(key=lambda x: x["id"], reverse=reverse_order) + else: + # Default sorting: Active first, then by raised amount descending + crowdloans_list.sort( + key=lambda x: ( + x["status"] != "Active", + -x["raised"], + ) ) - ) output_dict = { "success": True, @@ -221,13 +393,56 @@ async def list_crowdloans( ) table.add_column("[bold white]Call", style="grey89", justify="center") - sorted_loans = sorted( - loans.items(), - key=lambda x: ( - _status(x[1], current_block) != "Active", # Active loans first - -x[1].raised.tao, # Then by raised amount (descending) - ), - ) + # Apply sorting for table display + if sort_by: + reverse_order = True + if sort_order: + reverse_order = sort_order.lower() == "desc" + elif sort_by.lower() == "id": + reverse_order = False + + if sort_by.lower() == "raised": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[1].raised.tao, + reverse=reverse_order, + ) + elif sort_by.lower() == "end": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[1].end, + reverse=reverse_order, + ) + elif sort_by.lower() == "contributors": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[1].contributors_count, + reverse=reverse_order, + ) + elif sort_by.lower() == "id": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[0], + reverse=reverse_order, + ) + else: + # Default sorting + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: ( + _status(x[1], current_block) != "Active", + -x[1].raised.tao, + ), + ) + else: + # Default sorting: Active loans first, then by raised amount (descending) + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: ( + _status(x[1], current_block) != "Active", # Active loans first + -x[1].raised.tao, # Then by raised amount (descending) + ), + ) for loan_id, loan in sorted_loans: status = _status(loan, current_block) @@ -267,14 +482,32 @@ async def list_crowdloans( else: time_cell = time_label - creator_cell = loan.creator if verbose else _shorten(loan.creator) - target_cell = ( - loan.target_address - if loan.target_address - else f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" - ) - if not verbose and loan.target_address: - target_cell = _shorten(loan.target_address) + # Format creator cell with identity if available + if show_identities and loan.creator in identity_map: + creator_identity = identity_map[loan.creator] + if verbose: + creator_cell = f"{creator_identity} ({loan.creator})" + else: + creator_cell = f"{creator_identity} ({_shorten(loan.creator)})" + else: + creator_cell = loan.creator if verbose else _shorten(loan.creator) + + # Format target cell with identity if available + if loan.target_address: + if show_identities and loan.target_address in identity_map: + target_identity = identity_map[loan.target_address] + if verbose: + target_cell = f"{target_identity} ({loan.target_address})" + else: + target_cell = f"{target_identity} ({_shorten(loan.target_address)})" + else: + target_cell = ( + loan.target_address if verbose else _shorten(loan.target_address) + ) + else: + target_cell = ( + f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) funds_account_cell = ( loan.funds_account if verbose else _shorten(loan.funds_account) @@ -327,6 +560,8 @@ async def show_crowdloan_details( wallet: Optional[Wallet] = None, verbose: bool = False, json_output: bool = False, + show_identities: bool = True, + show_contributors: bool = False, ) -> tuple[bool, str]: """Display detailed information about a specific crowdloan.""" @@ -349,6 +584,23 @@ async def show_crowdloan_details( crowdloan_id, wallet.coldkeypub.ss58_address ) + # Fetch identities if show_identities is enabled + identity_map = {} + if show_identities: + addresses_to_fetch = [crowdloan.creator] + if crowdloan.target_address: + addresses_to_fetch.append(crowdloan.target_address) + + identity_tasks = [ + subtensor.query_identity(address) for address in addresses_to_fetch + ] + identities = await asyncio.gather(*identity_tasks) + + for address, identity in zip(addresses_to_fetch, identities): + identity_name = _get_identity_name(identity) + if identity_name: + identity_map[address] = identity_name + status = _status(crowdloan, current_block) status_color_map = { "Finalized": COLORS.G.SUCCESS, @@ -417,6 +669,9 @@ async def show_crowdloan_details( "status": status, "finalized": crowdloan.finalized, "creator": crowdloan.creator, + "creator_identity": identity_map.get(crowdloan.creator) + if show_identities + else None, "funds_account": crowdloan.funds_account, "raised": crowdloan.raised.tao, "cap": crowdloan.cap.tao, @@ -431,12 +686,104 @@ async def show_crowdloan_details( "contributors_count": crowdloan.contributors_count, "average_contribution": avg_contribution, "target_address": crowdloan.target_address, + "target_identity": identity_map.get(crowdloan.target_address) + if show_identities and crowdloan.target_address + else None, "has_call": crowdloan.has_call, "call_details": call_info, "user_contribution": user_contribution_info, "network": subtensor.network, }, } + + # Add contributors list if requested + if show_contributors: + from bittensor_cli.src.commands.crowd.contributors import list_contributors + + # We'll fetch contributors separately and add to output + contributors_data = await subtensor.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + fully_exhaust=True, + ) + + from bittensor_cli.src.bittensor.utils import decode_account_id + + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + try: + contributor_address = decode_account_id(contributor_key) + contribution_value = ( + contribution_amount.value + if hasattr(contribution_amount, "value") + else contribution_amount + ) + contribution_balance = ( + Balance.from_rao(int(contribution_value)) + if contribution_value + else Balance.from_tao(0) + ) + contributor_contributions[contributor_address] = ( + contribution_balance + ) + except Exception: + continue + + # Fetch identities for contributors + contributors_list = list(contributor_contributions.keys()) + if contributors_list: + contributor_identity_tasks = [ + subtensor.query_identity(contributor) + for contributor in contributors_list + ] + contributor_identities = await asyncio.gather( + *contributor_identity_tasks + ) + + contributors_json = [] + total_contributed = Balance.from_tao(0) + for ( + contributor_address, + contribution_amount, + ) in contributor_contributions.items(): + total_contributed += contribution_amount + + contributor_data = [] + for contributor_address, identity in zip( + contributors_list, contributor_identities + ): + contribution_amount = contributor_contributions[contributor_address] + identity_name = _get_identity_name(identity) + contributor_data.append( + { + "address": contributor_address, + "identity": identity_name if identity_name else None, + "contribution": contribution_amount, + } + ) + + contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) + + for rank, data in enumerate(contributor_data, start=1): + percentage = ( + (data["contribution"].rao / total_contributed.rao * 100) + if total_contributed.rao > 0 + else 0 + ) + contributors_json.append( + { + "rank": rank, + "address": data["address"], + "identity": data["identity"], + "contribution_tao": data["contribution"].tao, + "contribution_rao": data["contribution"].rao, + "percentage": percentage, + } + ) + + output_dict["data"]["contributors"] = contributors_json + json_console.print(json.dumps(output_dict)) return True, f"Displayed info for crowdloan #{crowdloan_id}" @@ -474,9 +821,20 @@ async def show_crowdloan_details( status_detail = " [green](successfully completed)[/green]" table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") + + # Display creator with identity if available + creator_display = crowdloan.creator + if show_identities and crowdloan.creator in identity_map: + creator_identity = identity_map[crowdloan.creator] + if verbose: + creator_display = f"{creator_identity} ({crowdloan.creator})" + else: + creator_display = f"{creator_identity} ({_shorten(crowdloan.creator)})" + elif not verbose: + creator_display = _shorten(crowdloan.creator) table.add_row( "Creator", - f"[{COLORS.G.TEMPO}]{crowdloan.creator}[/{COLORS.G.TEMPO}]", + f"[{COLORS.G.TEMPO}]{creator_display}[/{COLORS.G.TEMPO}]", ) table.add_row( "Funds Account", @@ -582,7 +940,20 @@ async def show_crowdloan_details( table.add_section() if crowdloan.target_address: - target_display = crowdloan.target_address + if show_identities and crowdloan.target_address in identity_map: + target_identity = identity_map[crowdloan.target_address] + if verbose: + target_display = f"{target_identity} ({crowdloan.target_address})" + else: + target_display = ( + f"{target_identity} ({_shorten(crowdloan.target_address)})" + ) + else: + target_display = ( + crowdloan.target_address + if verbose + else _shorten(crowdloan.target_address) + ) else: target_display = ( f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" @@ -637,5 +1008,111 @@ async def show_crowdloan_details( else: table.add_row(arg_name, str(display_value)) + # CONTRIBUTORS Section (if requested) + if show_contributors: + table.add_section() + table.add_row("[cyan underline]CONTRIBUTORS[/cyan underline]", "") + table.add_section() + + # Fetch contributors + contributors_data = await subtensor.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + fully_exhaust=True, + ) + + from bittensor_cli.src.bittensor.utils import decode_account_id + + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + try: + contributor_address = decode_account_id(contributor_key) + contribution_value = ( + contribution_amount.value + if hasattr(contribution_amount, "value") + else contribution_amount + ) + contribution_balance = ( + Balance.from_rao(int(contribution_value)) + if contribution_value + else Balance.from_tao(0) + ) + contributor_contributions[contributor_address] = contribution_balance + except Exception: + continue + + if contributor_contributions: + # Fetch identities for contributors + contributors_list = list(contributor_contributions.keys()) + contributor_identity_tasks = [ + subtensor.query_identity(contributor) + for contributor in contributors_list + ] + contributor_identities = await asyncio.gather(*contributor_identity_tasks) + + # Build contributor data list + contributor_data = [] + total_contributed = Balance.from_tao(0) + + for contributor_address, identity in zip( + contributors_list, contributor_identities + ): + contribution_amount = contributor_contributions[contributor_address] + total_contributed += contribution_amount + identity_name = _get_identity_name(identity) + + contributor_data.append( + { + "address": contributor_address, + "identity": identity_name, + "contribution": contribution_amount, + } + ) + + # Sort by contribution amount (descending) + contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) + + # Display contributors in table + for rank, data in enumerate(contributor_data[:10], start=1): # Show top 10 + address_display = ( + data["address"] if verbose else _shorten(data["address"]) + ) + identity_display = ( + data["identity"] if data["identity"] else "[dim]-[/dim]" + ) + + if data["identity"]: + if verbose: + contributor_display = f"{identity_display} ({address_display})" + else: + contributor_display = f"{identity_display} ({address_display})" + else: + contributor_display = address_display + + if verbose: + contribution_display = f"τ {data['contribution'].tao:,.4f}" + else: + contribution_display = f"τ {millify_tao(data['contribution'].tao)}" + + percentage = ( + (data["contribution"].rao / total_contributed.rao * 100) + if total_contributed.rao > 0 + else 0 + ) + + table.add_row( + f"#{rank}", + f"{contributor_display} - {contribution_display} ({percentage:.2f}%)", + ) + + if len(contributor_data) > 10: + table.add_row( + "", + f"[dim]... and {len(contributor_data) - 10} more contributors[/dim]", + ) + else: + table.add_row("", "[dim]No contributors yet[/dim]") + console.print(table) return True, f"Displayed info for crowdloan #{crowdloan_id}" diff --git a/pyproject.toml b/pyproject.toml index faa1b37d8..d24cd16aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,3 +65,6 @@ dev = [ # more details can be found here homepage = "https://github.com/opentensor/btcli" Repository = "https://github.com/opentensor/btcli" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/tests/e2e_tests/test_crowd_contributors.py b/tests/e2e_tests/test_crowd_contributors.py new file mode 100644 index 000000000..ebfb842a9 --- /dev/null +++ b/tests/e2e_tests/test_crowd_contributors.py @@ -0,0 +1,260 @@ +""" +E2E tests for crowd contributors command. + +Verify command: +* btcli crowd contributors --id +""" + +import json +import pytest + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_contributors_command(local_chain, wallet_setup): + """ + Test crowd contributors command and inspect its output. + + Steps: + 1. Create a crowdloan (if needed) or use existing one + 2. Make contributions to the crowdloan + 3. Execute contributors command and verify output + 4. Test with --verbose flag + 5. Test with --json-output flag + + Note: This test requires an existing crowdloan with contributors. + For a full e2e test, you would need to: + - Create a crowdloan + - Make contributions + - Then list contributors + """ + wallet_path_alice = "//Alice" + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + + # Test 1: List contributors for an existing crowdloan (assuming crowdloan #0 exists) + # This will work if there's a crowdloan with contributors on the test chain + result = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + # Parse JSON output + try: + result_output = json.loads(result.stdout) + # If crowdloan exists and has contributors + if result_output.get("success") is True: + assert "data" in result_output + assert "contributors" in result_output["data"] + assert "crowdloan_id" in result_output["data"] + assert result_output["data"]["crowdloan_id"] == 0 + assert isinstance(result_output["data"]["contributors"], list) + assert "total_count" in result_output["data"] + assert "total_contributed_tao" in result_output["data"] + + # If there are contributors, verify structure + if result_output["data"]["total_count"] > 0: + contributor = result_output["data"]["contributors"][0] + assert "rank" in contributor + assert "address" in contributor + assert "identity" in contributor + assert "contribution_tao" in contributor + assert "contribution_rao" in contributor + assert "percentage" in contributor + assert contributor["rank"] == 1 # First contributor should be rank 1 + assert contributor["contribution_tao"] >= 0 + assert 0 <= contributor["percentage"] <= 100 + + # If crowdloan doesn't exist or has no contributors + elif result_output.get("success") is False: + assert "error" in result_output + except json.JSONDecodeError: + # If output is not JSON (shouldn't happen with --json-output) + pytest.fail("Expected JSON output but got non-JSON response") + + # Test 2: Test with verbose flag + result_verbose = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--verbose", + ], + ) + + # Verify verbose output (should show full addresses) + assert result_verbose.exit_code == 0 or result_verbose.exit_code is None + + # Test 3: Test with non-existent crowdloan + result_not_found = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + "99999", + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + try: + result_output = json.loads(result_not_found.stdout) + # Should return error for non-existent crowdloan + assert result_output.get("success") is False + assert "error" in result_output + assert "not found" in result_output["error"].lower() + except json.JSONDecodeError: + # If output is not JSON, that's also acceptable for error cases + pass + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_contributors_with_real_crowdloan(local_chain, wallet_setup): + """ + Full e2e test: Create crowdloan, contribute, then list contributors. + + Steps: + 1. Create a crowdloan + 2. Make contributions from multiple wallets + 3. List contributors and verify all are present + 4. Verify sorting by contribution amount + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + # Create wallets + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( + wallet_path_bob + ) + + # Step 1: Create a crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--no-prompt", + "--json-output", + ], + ) + + try: + create_output = json.loads(create_result.stdout) + if create_output.get("success") is True: + crowdloan_id = create_output.get("crowdloan_id") or create_output.get( + "data", {} + ).get("crowdloan_id") + + if crowdloan_id is not None: + # Step 2: Make contributions + # Alice contributes + contribute_alice = exec_command_alice( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--amount", + "20", + "--no-prompt", + "--json-output", + ], + ) + + # Bob contributes + contribute_bob = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "30", + "--no-prompt", + "--json-output", + ], + ) + + # Step 3: List contributors + contributors_result = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + contributors_output = json.loads(contributors_result.stdout) + assert contributors_output.get("success") is True + assert contributors_output["data"]["crowdloan_id"] == crowdloan_id + assert contributors_output["data"]["total_count"] >= 2 + + # Verify contributors are sorted by contribution (descending) + contributors_list = contributors_output["data"]["contributors"] + if len(contributors_list) >= 2: + # Bob should be first (30 TAO > 20 TAO) + assert ( + contributors_list[0]["contribution_tao"] + >= contributors_list[1]["contribution_tao"] + ) + + # Verify percentages sum to 100% + total_percentage = sum(c["percentage"] for c in contributors_list) + assert ( + abs(total_percentage - 100.0) < 0.01 + ) # Allow small floating point errors + + except (json.JSONDecodeError, KeyError, AssertionError) as e: + # Skip test if prerequisites aren't met (e.g., insufficient balance, chain not ready) + pytest.skip(f"Test prerequisites not met: {e}") diff --git a/tests/e2e_tests/test_crowd_identity_display.py b/tests/e2e_tests/test_crowd_identity_display.py new file mode 100644 index 000000000..7953082d9 --- /dev/null +++ b/tests/e2e_tests/test_crowd_identity_display.py @@ -0,0 +1,150 @@ +""" +E2E tests for crowd identity display functionality. + +Verify commands: +* btcli crowd list --show-identities +* btcli crowd info --id --show-identities --show-contributors +""" + +import json +import pytest + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_list_with_identities(local_chain, wallet_setup): + """ + Test crowd list command with identity display. + + Steps: + 1. Execute crowd list with --show-identities (default) + 2. Execute crowd list with --no-show-identities + 3. Verify identity information is displayed when enabled + """ + wallet_path_alice = "//Alice" + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + + # Test 1: List with identities (default) + result = exec_command_alice( + command="crowd", + sub_command="list", + extra_args=[ + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + try: + result_output = json.loads(result.stdout) + if result_output.get("success") is True: + assert "data" in result_output + assert "crowdloans" in result_output["data"] + + # Check if identity fields are present + if result_output["data"]["crowdloans"]: + crowdloan = result_output["data"]["crowdloans"][0] + # Identity fields should be present (may be None if no identity) + assert "creator_identity" in crowdloan + assert "target_identity" in crowdloan + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output") + + # Test 2: List without identities + result_no_identities = exec_command_alice( + command="crowd", + sub_command="list", + extra_args=[ + "--network", + "ws://127.0.0.1:9945", + "--show-identities", + "false", + "--json-output", + ], + ) + + try: + result_output = json.loads(result_no_identities.stdout) + if result_output.get("success") is True: + if result_output["data"]["crowdloans"]: + crowdloan = result_output["data"]["crowdloans"][0] + # Identity fields should still be present but None + assert "creator_identity" in crowdloan + assert crowdloan.get("creator_identity") is None + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output") + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_info_with_identities(local_chain, wallet_setup): + """ + Test crowd info command with identity display and contributors. + + Steps: + 1. Execute crowd info with --show-identities + 2. Execute crowd info with --show-contributors + 3. Verify identity and contributor information is displayed + """ + wallet_path_alice = "//Alice" + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + + # Test 1: Info with identities (default) + result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + try: + result_output = json.loads(result.stdout) + if result_output.get("success") is True: + assert "data" in result_output + # Identity fields should be present + assert "creator_identity" in result_output["data"] + assert "target_identity" in result_output["data"] + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output or crowdloan not found") + + # Test 2: Info with identities and contributors + result_with_contributors = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--show-identities", + "true", + "--show-contributors", + "true", + "--json-output", + ], + ) + + try: + result_output = json.loads(result_with_contributors.stdout) + if result_output.get("success") is True: + assert "data" in result_output + # Contributors should be present if flag is set + assert "contributors" in result_output["data"] + if result_output["data"]["contributors"]: + contributor = result_output["data"]["contributors"][0] + assert "identity" in contributor + assert "address" in contributor + assert "contribution_tao" in contributor + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output or crowdloan not found") diff --git a/tests/unit_tests/test_crowd_contributors.py b/tests/unit_tests/test_crowd_contributors.py new file mode 100644 index 000000000..201164420 --- /dev/null +++ b/tests/unit_tests/test_crowd_contributors.py @@ -0,0 +1,496 @@ +""" +Unit tests for crowd contributors command. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.chain_data import CrowdloanData +from bittensor_cli.src.commands.crowd.contributors import list_contributors + + +class TestListContributors: + """Tests for list_contributors function.""" + + @pytest.mark.asyncio + async def test_list_contributors_success(self): + """Test successful listing of contributors.""" + # Setup mocks + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + # Mock crowdloan exists + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(30.0), + raised=Balance.from_tao(30.0), + end=1000000, + finalized=False, + contributors_count=3, + target_address="5GduHCP9UdBY", + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + # Mock contributors data from query_map + # The key structure is ((account_bytes_tuple,),) where account_bytes_tuple is tuple of ints + mock_contributor1_key = ( + ( + 74, + 51, + 88, + 161, + 161, + 215, + 144, + 145, + 231, + 175, + 227, + 146, + 149, + 109, + 220, + 180, + 12, + 58, + 121, + 233, + 152, + 50, + 211, + 15, + 242, + 187, + 103, + 2, + 198, + 131, + 177, + 118, + ), + ) + mock_contributor2_key = ( + ( + 202, + 66, + 124, + 47, + 131, + 219, + 1, + 26, + 137, + 169, + 17, + 112, + 182, + 39, + 163, + 162, + 72, + 150, + 208, + 58, + 179, + 235, + 238, + 242, + 150, + 177, + 219, + 0, + 2, + 76, + 172, + 171, + ), + ) + mock_contributor3_key = ( + ( + 224, + 56, + 146, + 238, + 201, + 170, + 157, + 255, + 58, + 77, + 190, + 94, + 17, + 231, + 15, + 217, + 15, + 134, + 147, + 100, + 174, + 45, + 31, + 132, + 21, + 200, + 40, + 185, + 176, + 209, + 247, + 54, + ), + ) + + mock_contribution1 = MagicMock() + mock_contribution1.value = 10000000000 # 10 TAO in rao + mock_contribution2 = MagicMock() + mock_contribution2.value = 10000000000 # 10 TAO in rao + mock_contribution3 = MagicMock() + mock_contribution3.value = 10000000000 # 10 TAO in rao + + # Create async generator for query_map results + async def mock_query_map_generator(): + yield (mock_contributor1_key, mock_contribution1) + yield (mock_contributor2_key, mock_contribution2) + yield (mock_contributor3_key, mock_contribution3) + + # Create a proper async iterable + class MockQueryMapResult: + def __aiter__(self): + return mock_query_map_generator() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockQueryMapResult() + ) + + # Mock identities + mock_subtensor.query_identity = AsyncMock( + side_effect=[ + {"info": {"display": {"Raw": "Alice"}}}, # Contributor 1 + {"info": {"display": {"Raw": "Bob"}}}, # Contributor 2 + {}, # Contributor 3 (no identity) + ] + ) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=False, + json_output=False, + ) + + # Verify + assert result is True + mock_subtensor.get_single_crowdloan.assert_called_once_with(0) + mock_subtensor.substrate.query_map.assert_called_once_with( + module="Crowdloan", + storage_function="Contributions", + params=[0], + fully_exhaust=True, + ) + assert mock_subtensor.query_identity.call_count == 3 + + @pytest.mark.asyncio + async def test_list_contributors_crowdloan_not_found(self): + """Test listing contributors when crowdloan doesn't exist.""" + mock_subtensor = MagicMock() + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=None) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=999, + verbose=False, + json_output=False, + ) + + # Verify + assert result is False + mock_subtensor.get_single_crowdloan.assert_called_once_with(999) + mock_subtensor.substrate.query_map.assert_not_called() + + @pytest.mark.asyncio + async def test_list_contributors_no_contributors(self): + """Test listing contributors when there are no contributors.""" + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(100.0), + raised=Balance.from_tao(10.0), + end=1000000, + finalized=False, + contributors_count=0, + target_address=None, + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + # Mock empty contributors data + async def mock_empty_query_map(): + if False: # Never yield anything + yield + + class MockEmptyQueryMapResult: + def __aiter__(self): + return mock_empty_query_map() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockEmptyQueryMapResult() + ) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=False, + json_output=False, + ) + + # Verify + assert result is True + mock_subtensor.query_identity.assert_not_called() + + @pytest.mark.asyncio + async def test_list_contributors_json_output(self): + """Test listing contributors with JSON output.""" + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(20.0), + raised=Balance.from_tao(20.0), + end=1000000, + finalized=False, + contributors_count=2, + target_address=None, + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + # Mock contributors data + mock_contributor1_key = ( + ( + 74, + 51, + 88, + 161, + 161, + 215, + 144, + 145, + 231, + 175, + 227, + 146, + 149, + 109, + 220, + 180, + 12, + 58, + 121, + 233, + 152, + 50, + 211, + 15, + 242, + 187, + 103, + 2, + 198, + 131, + 177, + 118, + ), + ) + mock_contributor2_key = ( + ( + 202, + 66, + 124, + 47, + 131, + 219, + 1, + 26, + 137, + 169, + 17, + 112, + 182, + 39, + 163, + 162, + 72, + 150, + 208, + 58, + 179, + 235, + 238, + 242, + 150, + 177, + 219, + 0, + 2, + 76, + 172, + 171, + ), + ) + + mock_contribution1 = MagicMock() + mock_contribution1.value = 10000000000 # 10 TAO + mock_contribution2 = MagicMock() + mock_contribution2.value = 10000000000 # 10 TAO + + async def mock_query_map_generator(): + yield (mock_contributor1_key, mock_contribution1) + yield (mock_contributor2_key, mock_contribution2) + + class MockQueryMapResult: + def __aiter__(self): + return mock_query_map_generator() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockQueryMapResult() + ) + mock_subtensor.query_identity = AsyncMock( + side_effect=[ + {"info": {"display": {"Raw": "Alice"}}}, + {"info": {"display": {"Raw": "Bob"}}}, + ] + ) + + # Mock json_console + with patch( + "bittensor_cli.src.commands.crowd.contributors.json_console" + ) as mock_json_console: + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=False, + json_output=True, + ) + + # Verify + assert result is True + mock_json_console.print.assert_called_once() + call_args = mock_json_console.print.call_args[0][0] + import json + + output_data = json.loads(call_args) + assert output_data["success"] is True + assert output_data["data"]["crowdloan_id"] == 0 + assert len(output_data["data"]["contributors"]) == 2 + assert output_data["data"]["total_count"] == 2 + assert output_data["data"]["total_contributed_tao"] == 20.0 + assert output_data["data"]["network"] == "finney" + # Verify contributors are sorted by rank + assert output_data["data"]["contributors"][0]["rank"] == 1 + assert output_data["data"]["contributors"][1]["rank"] == 2 + + @pytest.mark.asyncio + async def test_list_contributors_verbose_mode(self): + """Test listing contributors with verbose mode.""" + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(10.0), + raised=Balance.from_tao(10.0), + end=1000000, + finalized=False, + contributors_count=1, + target_address=None, + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + mock_contributor_key = ( + ( + 74, + 51, + 88, + 161, + 161, + 215, + 144, + 145, + 231, + 175, + 227, + 146, + 149, + 109, + 220, + 180, + 12, + 58, + 121, + 233, + 152, + 50, + 211, + 15, + 242, + 187, + 103, + 2, + 198, + 131, + 177, + 118, + ), + ) + mock_contribution = MagicMock() + mock_contribution.value = 10000000000 # 10 TAO + + async def mock_query_map_generator(): + yield (mock_contributor_key, mock_contribution) + + class MockQueryMapResult: + def __aiter__(self): + return mock_query_map_generator() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockQueryMapResult() + ) + mock_subtensor.query_identity = AsyncMock(return_value={}) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=True, + json_output=False, + ) + + # Verify + assert result is True diff --git a/tests/unit_tests/test_crowd_create_custom_call.py b/tests/unit_tests/test_crowd_create_custom_call.py new file mode 100644 index 000000000..8aa0fddfa --- /dev/null +++ b/tests/unit_tests/test_crowd_create_custom_call.py @@ -0,0 +1,180 @@ +""" +Unit tests for crowd create custom call functionality. +""" + +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from scalecodec import GenericCall + +from bittensor_cli.src.commands.crowd.create import validate_and_compose_custom_call + + +class TestValidateAndComposeCustomCall: + """Tests for validate_and_compose_custom_call function.""" + + @pytest.mark.asyncio + async def test_invalid_json_args(self): + """Test that invalid JSON in args is caught.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="test_method", + args_json='{"invalid": json}', + ) + + assert result_call is None + assert "Invalid JSON" in error_msg + + @pytest.mark.asyncio + async def test_pallet_not_found(self): + """Test that missing pallet is detected.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_pallet = MagicMock() + mock_pallet.name = "OtherPallet" + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock( + side_effect=ValueError("Pallet not found") + ) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="NonExistentPallet", + method_name="test_method", + args_json="{}", + ) + + assert result_call is None + assert "not found" in error_msg.lower() + + @pytest.mark.asyncio + async def test_method_not_found(self): + """Test that missing method is detected.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_call = MagicMock() + mock_call.name = "other_method" + + mock_pallet = MagicMock() + mock_pallet.name = "TestPallet" + mock_pallet.calls = [mock_call] + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="non_existent_method", + args_json="{}", + ) + + assert result_call is None + assert "not found" in error_msg.lower() + + @pytest.mark.asyncio + async def test_successful_validation(self): + """Test successful validation and call composition.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_call = MagicMock() + mock_call.name = "test_method" + mock_call.index = 0 + + mock_pallet = MagicMock() + mock_pallet.name = "TestPallet" + mock_pallet.calls = [mock_call] + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + # Mock compose_call to return a GenericCall + mock_generic_call = MagicMock(spec=GenericCall) + mock_subtensor.substrate.compose_call = AsyncMock( + return_value=mock_generic_call + ) + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="test_method", + args_json='{"param1": "value1"}', + ) + + assert result_call is not None + assert error_msg is None + mock_subtensor.substrate.compose_call.assert_called_once_with( + call_module="TestPallet", + call_function="test_method", + call_params={"param1": "value1"}, + ) + + @pytest.mark.asyncio + async def test_compose_call_failure(self): + """Test handling of compose_call failures.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_call = MagicMock() + mock_call.name = "test_method" + + mock_pallet = MagicMock() + mock_pallet.name = "TestPallet" + mock_pallet.calls = [mock_call] + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + # Mock compose_call to raise an error + mock_subtensor.substrate.compose_call = AsyncMock( + side_effect=Exception("Invalid parameter type") + ) + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="test_method", + args_json='{"param1": "value1"}', + ) + + assert result_call is None + assert error_msg is not None + assert "Invalid parameter" in error_msg or "Failed to compose" in error_msg From ea504bc286faa77e676d287859908cb33d74f480 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:37:27 -0800 Subject: [PATCH 02/19] remove identity args --- bittensor_cli/cli.py | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 0a37364c8..3bf6e65c4 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -8750,11 +8750,6 @@ def crowd_list( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, - show_identities: Optional[str] = typer.Option( - None, - "--show-identities", - help="Show identity names for creators and target addresses. Use 'true' or 'false', or omit for default (true).", - ), status: Optional[str] = typer.Option( None, "--status", @@ -8789,7 +8784,6 @@ def crowd_list( or a general fundraising crowdloan. Use `--verbose` for full-precision amounts and longer addresses. - Use `--show-identities` to show identity names (default: true). Use `--status` to filter by status (active, funded, closed, finalized). Use `--type` to filter by type (subnet, fundraising). Use `--sort-by` and `--sort-order` to sort results. @@ -8801,8 +8795,6 @@ def crowd_list( [green]$[/green] btcli crowd list --verbose - [green]$[/green] btcli crowd list --show-identities true - [green]$[/green] btcli crowd list --status active --type subnet [green]$[/green] btcli crowd list --sort-by raised --sort-order desc @@ -8810,16 +8802,11 @@ def crowd_list( [green]$[/green] btcli crowd list --search-creator "5D..." """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) - # Parse show_identities: None or "true" -> True, "false" -> False - show_identities_bool = True # default - if show_identities is not None: - show_identities_bool = show_identities.lower() in ("true", "1", "yes") return self._run_command( view_crowdloan.list_crowdloans( subtensor=self.initialize_chain(network), verbose=verbose, json_output=json_output, - show_identities=show_identities_bool, status_filter=status, type_filter=type_filter, sort_by=sort_by, @@ -8844,11 +8831,6 @@ def crowd_info( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, - show_identities: Optional[str] = typer.Option( - None, - "--show-identities", - help="Show identity names for creator and target address. Use 'true' or 'false', or omit for default (true).", - ), show_contributors: Optional[str] = typer.Option( None, "--show-contributors", @@ -8859,7 +8841,6 @@ def crowd_info( Display detailed information about a specific crowdloan. Includes funding progress, target account, and call details among other information. - Use `--show-identities` to show identity names (default: true). Use `--show-contributors` to display the list of contributors (default: false). EXAMPLES @@ -8868,9 +8849,7 @@ def crowd_info( [green]$[/green] btcli crowd info --id 1 --verbose - [green]$[/green] btcli crowd info --id 0 --show-identities true - - [green]$[/green] btcli crowd info --id 0 --show-identities true --show-contributors true + [green]$[/green] btcli crowd info --id 0 --show-contributors true """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) @@ -8891,11 +8870,6 @@ def crowd_info( validate=WV.WALLET, ) - # Parse show_identities: None or "true" -> True, "false" -> False - show_identities_bool = True # default - if show_identities is not None: - show_identities_bool = show_identities.lower() in ("true", "1", "yes") - # Parse show_contributors: None or "false" -> False, "true" -> True show_contributors_bool = False # default if show_contributors is not None: @@ -8908,7 +8882,6 @@ def crowd_info( wallet=wallet, verbose=verbose, json_output=json_output, - show_identities=show_identities_bool, show_contributors=show_contributors_bool, ) ) From 21c8fae57baab05de1bfbd35759e6f1b8a1daaec Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:38:22 -0800 Subject: [PATCH 03/19] update list contributors --- .../src/commands/crowd/contributors.py | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index c64151e07..24eb6eab3 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -22,15 +22,6 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" -def _get_identity_name(identity: dict) -> str: - """Extract identity name from identity dict.""" - if not identity: - return "-" - info = identity.get("info", {}) - display = info.get("display", {}) - if isinstance(display, dict): - return display.get("Raw", "") or display.get("value", "") or "-" - return str(display) if display else "-" async def list_contributors( @@ -131,21 +122,20 @@ async def list_contributors( ) return True - # Fetch identities for all contributors - contributors_list = list(contributor_contributions.keys()) - identity_tasks = [ - subtensor.query_identity(contributor) for contributor in contributors_list - ] - identities = await asyncio.gather(*identity_tasks) + all_identities = await subtensor.query_all_identities() # Build contributor data list + contributors_list = list(contributor_contributions.keys()) contributor_data = [] total_contributed = Balance.from_tao(0) - for contributor_address, identity in zip(contributors_list, identities): + for contributor_address in contributors_list: contribution_amount = contributor_contributions[contributor_address] total_contributed += contribution_amount - identity_name = _get_identity_name(identity) + identity = all_identities.get(contributor_address) + identity_name = None + if identity: + identity_name = identity.get("name") or identity.get("display") contributor_data.append( { From 0f8fdcbfcdef0980083a4c7b8c0409f2e48a998c Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:40:04 -0800 Subject: [PATCH 04/19] update view cmd --- bittensor_cli/src/commands/crowd/view.py | 165 +++++++---------------- 1 file changed, 49 insertions(+), 116 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 03932e8eb..28ae014d0 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -25,47 +25,6 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" -def _get_identity_name(identity: dict) -> str: - """Extract identity name from identity dict. - - Handles both flat structure (from decode_hex_identity) and nested structure. - """ - if not identity: - return "" - - # Try direct display/name fields first (flat structure from decode_hex_identity) - if identity.get("display"): - display = identity.get("display") - if isinstance(display, str): - return display - if isinstance(display, dict): - return display.get("Raw", "") or display.get("value", "") or "" - - if identity.get("name"): - name = identity.get("name") - if isinstance(name, str): - return name - if isinstance(name, dict): - return name.get("Raw", "") or name.get("value", "") or "" - - # Try nested structure (info.display.Raw) - info = identity.get("info", {}) - if info: - display = info.get("display", {}) - if isinstance(display, dict): - return display.get("Raw", "") or display.get("value", "") or "" - if isinstance(display, str): - return display - - name = info.get("name", {}) - if isinstance(name, dict): - return name.get("Raw", "") or name.get("value", "") or "" - if isinstance(name, str): - return name - - return "" - - def _status(loan: CrowdloanData, current_block: int) -> str: if loan.finalized: return "Finalized" @@ -104,7 +63,6 @@ async def list_crowdloans( subtensor: SubtensorInterface, verbose: bool = False, json_output: bool = False, - show_identities: bool = True, status_filter: Optional[str] = None, type_filter: Optional[str] = None, sort_by: Optional[str] = None, @@ -117,7 +75,6 @@ async def list_crowdloans( subtensor: SubtensorInterface object for chain interaction verbose: Show full addresses and precise amounts json_output: Output as JSON - show_identities: Show identity names for creators and targets status_filter: Filter by status (active, funded, closed, finalized) type_filter: Filter by type (subnet, fundraising) sort_by: Sort by field (raised, end, contributors, id) @@ -125,9 +82,10 @@ async def list_crowdloans( search_creator: Search by creator address or identity name """ - current_block, loans = await asyncio.gather( + current_block, loans, all_identities = await asyncio.gather( subtensor.substrate.get_block_number(None), subtensor.get_crowdloans(), + subtensor.query_all_identities(), ) if not loans: if json_output: @@ -150,22 +108,18 @@ async def list_crowdloans( console.print("[yellow]No crowdloans found.[/yellow]") return True - # Batch fetch identities early if needed for filtering/searching + # Build identity map from all identities identity_map = {} - if show_identities or search_creator: - addresses_to_fetch = set() - for loan in loans.values(): - addresses_to_fetch.add(loan.creator) - if loan.target_address: - addresses_to_fetch.add(loan.target_address) - - identity_tasks = [ - subtensor.query_identity(address) for address in addresses_to_fetch - ] - identities = await asyncio.gather(*identity_tasks) - - for address, identity in zip(addresses_to_fetch, identities): - identity_name = _get_identity_name(identity) + addresses_to_check = set() + for loan in loans.values(): + addresses_to_check.add(loan.creator) + if loan.target_address: + addresses_to_check.add(loan.target_address) + + for address in addresses_to_check: + identity = all_identities.get(address) + if identity: + identity_name = identity.get("name") or identity.get("display") if identity_name: identity_map[address] = identity_name @@ -263,12 +217,10 @@ async def list_crowdloans( "time_remaining": time_remaining, "contributors_count": loan.contributors_count, "creator": loan.creator, - "creator_identity": identity_map.get(loan.creator) - if show_identities - else None, + "creator_identity": identity_map.get(loan.creator), "target_address": loan.target_address, "target_identity": identity_map.get(loan.target_address) - if show_identities and loan.target_address + if loan.target_address else None, "funds_account": loan.funds_account, "call": call_info, @@ -483,7 +435,7 @@ async def list_crowdloans( time_cell = time_label # Format creator cell with identity if available - if show_identities and loan.creator in identity_map: + if loan.creator in identity_map: creator_identity = identity_map[loan.creator] if verbose: creator_cell = f"{creator_identity} ({loan.creator})" @@ -494,7 +446,7 @@ async def list_crowdloans( # Format target cell with identity if available if loan.target_address: - if show_identities and loan.target_address in identity_map: + if loan.target_address in identity_map: target_identity = identity_map[loan.target_address] if verbose: target_cell = f"{target_identity} ({loan.target_address})" @@ -560,16 +512,19 @@ async def show_crowdloan_details( wallet: Optional[Wallet] = None, verbose: bool = False, json_output: bool = False, - show_identities: bool = True, show_contributors: bool = False, ) -> tuple[bool, str]: """Display detailed information about a specific crowdloan.""" if not crowdloan or not current_block: - current_block, crowdloan = await asyncio.gather( + current_block, crowdloan, all_identities = await asyncio.gather( subtensor.substrate.get_block_number(None), subtensor.get_single_crowdloan(crowdloan_id), + subtensor.query_all_identities(), ) + else: + all_identities = await subtensor.query_all_identities() + if not crowdloan: error_msg = f"Crowdloan #{crowdloan_id} not found." if json_output: @@ -584,20 +539,16 @@ async def show_crowdloan_details( crowdloan_id, wallet.coldkeypub.ss58_address ) - # Fetch identities if show_identities is enabled + # Build identity map from all identities identity_map = {} - if show_identities: - addresses_to_fetch = [crowdloan.creator] - if crowdloan.target_address: - addresses_to_fetch.append(crowdloan.target_address) - - identity_tasks = [ - subtensor.query_identity(address) for address in addresses_to_fetch - ] - identities = await asyncio.gather(*identity_tasks) - - for address, identity in zip(addresses_to_fetch, identities): - identity_name = _get_identity_name(identity) + addresses_to_check = [crowdloan.creator] + if crowdloan.target_address: + addresses_to_check.append(crowdloan.target_address) + + for address in addresses_to_check: + identity = all_identities.get(address) + if identity: + identity_name = identity.get("name") or identity.get("display") if identity_name: identity_map[address] = identity_name @@ -669,9 +620,7 @@ async def show_crowdloan_details( "status": status, "finalized": crowdloan.finalized, "creator": crowdloan.creator, - "creator_identity": identity_map.get(crowdloan.creator) - if show_identities - else None, + "creator_identity": identity_map.get(crowdloan.creator), "funds_account": crowdloan.funds_account, "raised": crowdloan.raised.tao, "cap": crowdloan.cap.tao, @@ -687,7 +636,7 @@ async def show_crowdloan_details( "average_contribution": avg_contribution, "target_address": crowdloan.target_address, "target_identity": identity_map.get(crowdloan.target_address) - if show_identities and crowdloan.target_address + if crowdloan.target_address else None, "has_call": crowdloan.has_call, "call_details": call_info, @@ -698,8 +647,6 @@ async def show_crowdloan_details( # Add contributors list if requested if show_contributors: - from bittensor_cli.src.commands.crowd.contributors import list_contributors - # We'll fetch contributors separately and add to output contributors_data = await subtensor.substrate.query_map( module="Crowdloan", @@ -730,17 +677,8 @@ async def show_crowdloan_details( except Exception: continue - # Fetch identities for contributors contributors_list = list(contributor_contributions.keys()) if contributors_list: - contributor_identity_tasks = [ - subtensor.query_identity(contributor) - for contributor in contributors_list - ] - contributor_identities = await asyncio.gather( - *contributor_identity_tasks - ) - contributors_json = [] total_contributed = Balance.from_tao(0) for ( @@ -750,15 +688,16 @@ async def show_crowdloan_details( total_contributed += contribution_amount contributor_data = [] - for contributor_address, identity in zip( - contributors_list, contributor_identities - ): + for contributor_address in contributors_list: contribution_amount = contributor_contributions[contributor_address] - identity_name = _get_identity_name(identity) + identity = all_identities.get(contributor_address) + identity_name = None + if identity: + identity_name = identity.get("name") or identity.get("display") contributor_data.append( { "address": contributor_address, - "identity": identity_name if identity_name else None, + "identity": identity_name, "contribution": contribution_amount, } ) @@ -823,14 +762,15 @@ async def show_crowdloan_details( table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") # Display creator with identity if available - creator_display = crowdloan.creator - if show_identities and crowdloan.creator in identity_map: + if crowdloan.creator in identity_map: creator_identity = identity_map[crowdloan.creator] if verbose: creator_display = f"{creator_identity} ({crowdloan.creator})" else: creator_display = f"{creator_identity} ({_shorten(crowdloan.creator)})" - elif not verbose: + elif verbose: + creator_display = crowdloan.creator + else: creator_display = _shorten(crowdloan.creator) table.add_row( "Creator", @@ -940,7 +880,7 @@ async def show_crowdloan_details( table.add_section() if crowdloan.target_address: - if show_identities and crowdloan.target_address in identity_map: + if crowdloan.target_address in identity_map: target_identity = identity_map[crowdloan.target_address] if verbose: target_display = f"{target_identity} ({crowdloan.target_address})" @@ -1043,24 +983,17 @@ async def show_crowdloan_details( continue if contributor_contributions: - # Fetch identities for contributors contributors_list = list(contributor_contributions.keys()) - contributor_identity_tasks = [ - subtensor.query_identity(contributor) - for contributor in contributors_list - ] - contributor_identities = await asyncio.gather(*contributor_identity_tasks) - - # Build contributor data list contributor_data = [] total_contributed = Balance.from_tao(0) - for contributor_address, identity in zip( - contributors_list, contributor_identities - ): + for contributor_address in contributors_list: contribution_amount = contributor_contributions[contributor_address] total_contributed += contribution_amount - identity_name = _get_identity_name(identity) + identity = all_identities.get(contributor_address) + identity_name = None + if identity: + identity_name = identity.get("name") or identity.get("display") contributor_data.append( { From 058b2837839b8198b54910f5b989e3b83b354307 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:50:48 -0800 Subject: [PATCH 05/19] adds get_crowdloan_contributors method --- .../src/bittensor/subtensor_interface.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index bf2f91a23..49be1bf70 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1925,6 +1925,43 @@ async def get_crowdloan_contribution( return Balance.from_rao(contribution) return None + async def get_crowdloan_contributors( + self, + crowdloan_id: int, + block_hash: Optional[str] = None, + ) -> dict[str, Balance]: + """Retrieves all contributors and their contributions for a specific crowdloan. + + Args: + crowdloan_id (int): The ID of the crowdloan. + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + dict[str, Balance]: A dictionary mapping contributor SS58 addresses to their + contribution amounts as Balance objects. + + This function queries the Contributions storage map with the crowdloan_id as the first key + to retrieve all contributors and their contribution amounts. + """ + contributors_data = await self.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + block_hash=block_hash, + fully_exhaust=True, + ) + + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + try: + contributor_address = decode_account_id(contributor_key[0]) + contribution_balance = Balance.from_rao(contribution_amount.value) + contributor_contributions[contributor_address] = contribution_balance + except Exception: + continue + + return contributor_contributions + async def get_coldkey_swap_schedule_duration( self, block_hash: Optional[str] = None, From d6d0ce9c5127cda0a34774f74dcf0c2fa5ecf187 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:51:07 -0800 Subject: [PATCH 06/19] replace manually fetching and parsing contributors --- .../src/commands/crowd/contributors.py | 53 +------------------ 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index 24eb6eab3..89dc9130e 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -1,5 +1,4 @@ from typing import Optional -import asyncio import json from rich.table import Table @@ -11,7 +10,6 @@ json_console, print_error, millify_tao, - decode_account_id, ) @@ -22,8 +20,6 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" - - async def list_contributors( subtensor: SubtensorInterface, crowdloan_id: int, @@ -51,54 +47,7 @@ async def list_contributors( print_error(f"[red]{error_msg}[/red]") return False - # Query contributors from Contributions storage (double map) - # Query map with first key fixed to crowdloan_id to get all contributors - contributors_data = await subtensor.substrate.query_map( - module="Crowdloan", - storage_function="Contributions", - params=[crowdloan_id], - fully_exhaust=True, - ) - - # Extract contributors and their contributions from the map - contributor_contributions = {} - async for contributor_key, contribution_amount in contributors_data: - # Extract contributor address from the storage key - # For double maps queried with first key fixed, the key is a tuple: ((account_bytes_tuple,),) - # where account_bytes_tuple is a tuple of integers representing the account ID - try: - # The key structure is: ((account_bytes_tuple,),) - # where account_bytes_tuple is a tuple of integers (32 bytes = 32 ints) - if isinstance(contributor_key, tuple) and len(contributor_key) > 0: - inner_tuple = contributor_key[0] - if isinstance(inner_tuple, tuple): - # Decode the account ID from the tuple of integers - # decode_account_id handles both tuple[int] and tuple[tuple[int]] formats - contributor_address = decode_account_id(contributor_key) - else: - # Fallback: try to decode directly - contributor_address = decode_account_id(contributor_key) - else: - # Fallback: try to decode the key directly - contributor_address = decode_account_id(contributor_key) - - # Store contribution amount - # The value is a BittensorScaleType object, access .value to get the integer - contribution_value = ( - contribution_amount.value - if hasattr(contribution_amount, "value") - else contribution_amount - ) - contribution_balance = ( - Balance.from_rao(int(contribution_value)) - if contribution_value - else Balance.from_tao(0) - ) - contributor_contributions[contributor_address] = contribution_balance - except Exception as e: - # Skip invalid entries - uncomment for debugging - # print(f"Error processing contributor: {e}, key: {contributor_key}") - continue + contributor_contributions = await subtensor.get_crowdloan_contributors(crowdloan_id) if not contributor_contributions: if json_output: From dc6997fff83c3fee75a01109e379edef35dd000d Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:51:25 -0800 Subject: [PATCH 07/19] replace manually parsing contributors list --- bittensor_cli/src/commands/crowd/view.py | 58 ++---------------------- 1 file changed, 4 insertions(+), 54 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 28ae014d0..defc9e428 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -647,36 +647,9 @@ async def show_crowdloan_details( # Add contributors list if requested if show_contributors: - # We'll fetch contributors separately and add to output - contributors_data = await subtensor.substrate.query_map( - module="Crowdloan", - storage_function="Contributions", - params=[crowdloan_id], - fully_exhaust=True, + contributor_contributions = await subtensor.get_crowdloan_contributors( + crowdloan_id ) - - from bittensor_cli.src.bittensor.utils import decode_account_id - - contributor_contributions = {} - async for contributor_key, contribution_amount in contributors_data: - try: - contributor_address = decode_account_id(contributor_key) - contribution_value = ( - contribution_amount.value - if hasattr(contribution_amount, "value") - else contribution_amount - ) - contribution_balance = ( - Balance.from_rao(int(contribution_value)) - if contribution_value - else Balance.from_tao(0) - ) - contributor_contributions[contributor_address] = ( - contribution_balance - ) - except Exception: - continue - contributors_list = list(contributor_contributions.keys()) if contributors_list: contributors_json = [] @@ -955,33 +928,10 @@ async def show_crowdloan_details( table.add_section() # Fetch contributors - contributors_data = await subtensor.substrate.query_map( - module="Crowdloan", - storage_function="Contributions", - params=[crowdloan_id], - fully_exhaust=True, + contributor_contributions = await subtensor.get_crowdloan_contributors( + crowdloan_id ) - from bittensor_cli.src.bittensor.utils import decode_account_id - - contributor_contributions = {} - async for contributor_key, contribution_amount in contributors_data: - try: - contributor_address = decode_account_id(contributor_key) - contribution_value = ( - contribution_amount.value - if hasattr(contribution_amount, "value") - else contribution_amount - ) - contribution_balance = ( - Balance.from_rao(int(contribution_value)) - if contribution_value - else Balance.from_tao(0) - ) - contributor_contributions[contributor_address] = contribution_balance - except Exception: - continue - if contributor_contributions: contributors_list = list(contributor_contributions.keys()) contributor_data = [] From 887bb5a66bb148e9e249ea6cf36b78e030e8db90 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 17:12:39 -0800 Subject: [PATCH 08/19] simplify contributors list --- .../src/commands/crowd/contributors.py | 82 +++++++++---------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index 89dc9130e..f1bbf1557 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -37,14 +37,13 @@ async def list_contributors( Returns: bool: True if successful, False otherwise """ - # First verify the crowdloan exists crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) if not crowdloan: error_msg = f"Crowdloan #{crowdloan_id} not found." if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(f"{error_msg}") return False contributor_contributions = await subtensor.get_crowdloan_contributors(crowdloan_id) @@ -73,51 +72,45 @@ async def list_contributors( all_identities = await subtensor.query_all_identities() - # Build contributor data list - contributors_list = list(contributor_contributions.keys()) - contributor_data = [] - total_contributed = Balance.from_tao(0) + total_contributed = sum( + contributor_contributions.values(), start=Balance.from_tao(0) + ) - for contributor_address in contributors_list: - contribution_amount = contributor_contributions[contributor_address] - total_contributed += contribution_amount - identity = all_identities.get(contributor_address) - identity_name = None - if identity: - identity_name = identity.get("name") or identity.get("display") + contributor_data = [] + for address, amount in sorted( + contributor_contributions.items(), key=lambda x: x[1].rao, reverse=True + ): + identity = all_identities.get(address) + identity_name = ( + identity.get("name") or identity.get("display") if identity else None + ) + percentage = ( + (amount.rao / total_contributed.rao * 100) + if total_contributed.rao > 0 + else 0.0 + ) contributor_data.append( { - "address": contributor_address, + "address": address, "identity": identity_name, - "contribution": contribution_amount, + "contribution": amount, + "percentage": percentage, } ) - # Sort by contribution amount (descending) - contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) - - # Calculate percentages - for data in contributor_data: - if total_contributed.rao > 0: - percentage = (data["contribution"].rao / total_contributed.rao) * 100 - else: - percentage = 0.0 - data["percentage"] = percentage - if json_output: - contributors_json = [] - for rank, data in enumerate(contributor_data, start=1): - contributors_json.append( - { - "rank": rank, - "address": data["address"], - "identity": data["identity"], - "contribution_tao": data["contribution"].tao, - "contribution_rao": data["contribution"].rao, - "percentage": data["percentage"], - } - ) + contributors_json = [ + { + "rank": rank, + "address": data["address"], + "identity": data["identity"], + "contribution_tao": data["contribution"].tao, + "contribution_rao": data["contribution"].rao, + "percentage": data["percentage"], + } + for rank, data in enumerate(contributor_data, start=1) + ] output_dict = { "success": True, @@ -183,13 +176,12 @@ async def list_contributors( for rank, data in enumerate(contributor_data, start=1): address_cell = data["address"] if verbose else _shorten(data["address"]) - identity_cell = data["identity"] if data["identity"] != "-" else "[dim]-[/dim]" - - if verbose: - contribution_cell = f"τ {data['contribution'].tao:,.4f}" - else: - contribution_cell = f"τ {millify_tao(data['contribution'].tao)}" - + identity_cell = data["identity"] if data["identity"] else "[dim]-[/dim]" + contribution_cell = ( + f"τ {data['contribution'].tao:,.4f}" + if verbose + else f"τ {millify_tao(data['contribution'].tao)}" + ) percentage_cell = f"{data['percentage']:.2f}%" table.add_row( From af8831ead5adfba37f54689e31f999ed89266dd8 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 17:16:16 -0800 Subject: [PATCH 09/19] add console statuses --- bittensor_cli/src/commands/crowd/contributors.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index f1bbf1557..a46db1073 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -1,6 +1,7 @@ from typing import Optional import json from rich.table import Table +import asyncio from bittensor_cli.src import COLORS from bittensor_cli.src.bittensor.balances import Balance @@ -37,7 +38,8 @@ async def list_contributors( Returns: bool: True if successful, False otherwise """ - crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) + with console.status(":satellite: Fetching crowdloan details..."): + crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) if not crowdloan: error_msg = f"Crowdloan #{crowdloan_id} not found." if json_output: @@ -46,7 +48,11 @@ async def list_contributors( print_error(f"{error_msg}") return False - contributor_contributions = await subtensor.get_crowdloan_contributors(crowdloan_id) + with console.status(":satellite: Fetching contributors and identities..."): + contributor_contributions, all_identities = await asyncio.gather( + subtensor.get_crowdloan_contributors(crowdloan_id), + subtensor.query_all_identities(), + ) if not contributor_contributions: if json_output: @@ -70,8 +76,6 @@ async def list_contributors( ) return True - all_identities = await subtensor.query_all_identities() - total_contributed = sum( contributor_contributions.values(), start=Balance.from_tao(0) ) From a27e36d81354a0a472f51a1f284b24530ec9b7c5 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:12:12 -0800 Subject: [PATCH 10/19] add compose_custom_crowdloan_call --- .../src/bittensor/subtensor_interface.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 49be1bf70..bb249be23 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2538,6 +2538,36 @@ async def get_mev_shield_current_key( return public_key_bytes + async def compose_custom_crowdloan_call( + self, + pallet_name: str, + method_name: str, + call_params: dict, + block_hash: Optional[str] = None, + ) -> tuple[Optional[GenericCall], Optional[str]]: + """ + Compose a custom Substrate call. + + Args: + pallet_name: Name of the pallet/module + method_name: Name of the method/function + call_params: Dictionary of call parameters + block_hash: Optional block hash for the query + + Returns: + Tuple of (GenericCall or None, error_message or None) + """ + try: + call = await self.substrate.compose_call( + call_module=pallet_name, + call_function=method_name, + call_params=call_params, + block_hash=block_hash, + ) + return call, None + except Exception as e: + return None, f"Failed to compose call: {str(e)}" + async def best_connection(networks: list[str]): """ From 69ac2c1921029fb647a08d7e78c7cd664bb3a059 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:12:54 -0800 Subject: [PATCH 11/19] improve custom call creation --- bittensor_cli/src/commands/crowd/create.py | 168 +++++---------------- 1 file changed, 40 insertions(+), 128 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py index 5f6ed5208..2ce1dc9df 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -6,12 +6,14 @@ from rich.prompt import IntPrompt, Prompt, FloatPrompt from rich.table import Table, Column, box from scalecodec import GenericCall - from bittensor_cli.src import COLORS from bittensor_cli.src.commands.crowd.view import show_crowdloan_details 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_constant +from bittensor_cli.src.commands.crowd.utils import ( + get_constant, + prompt_custom_call_params, +) from bittensor_cli.src.bittensor.utils import ( blocks_to_duration, confirm_action, @@ -25,105 +27,6 @@ ) -async def validate_and_compose_custom_call( - subtensor: SubtensorInterface, - pallet_name: str, - method_name: str, - args_json: str, -) -> tuple[Optional[GenericCall], Optional[str]]: - """ - Validate and compose a custom Substrate call. - - Args: - subtensor: SubtensorInterface instance - pallet_name: Name of the pallet/module - method_name: Name of the method/function - args_json: JSON string of call arguments - - Returns: - Tuple of (GenericCall or None, error_message or None) - """ - try: - # Parse JSON arguments - try: - call_params = json.loads(args_json) if args_json else {} - except json.JSONDecodeError as e: - return None, f"Invalid JSON in custom call args: {e}" - - # Get metadata to validate call exists - block_hash = await subtensor.substrate.get_chain_head() - runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) - metadata = runtime.metadata - - # Check if pallet exists - try: - # Try using get_metadata_pallet if available (cleaner approach) - if hasattr(metadata, "get_metadata_pallet"): - pallet = metadata.get_metadata_pallet(pallet_name) - else: - # Fallback to iteration - pallet = None - for pallet_item in metadata.pallets: - if pallet_item.name == pallet_name: - pallet = pallet_item - break - except (AttributeError, ValueError): - # Pallet not found - pallet = None - - if pallet is None: - available_pallets = [p.name for p in metadata.pallets] - return None, ( - f"Pallet '{pallet_name}' not found in runtime metadata. " - f"Available pallets: {', '.join(available_pallets[:10])}" - + ( - f" and {len(available_pallets) - 10} more..." - if len(available_pallets) > 10 - else "" - ) - ) - - # Check if method exists in pallet - call_index = None - call_type = None - for call_item in pallet.calls: - if call_item.name == method_name: - call_index = call_item.index - call_type = call_item.type - break - - if call_index is None: - available_methods = [c.name for c in pallet.calls] - return None, ( - f"Method '{method_name}' not found in pallet '{pallet_name}'. " - f"Available methods: {', '.join(available_methods[:10])}" - + ( - f" and {len(available_methods) - 10} more..." - if len(available_methods) > 10 - else "" - ) - ) - - # Validate and compose the call - # The compose_call method will validate the parameters match expected types - try: - call = await subtensor.substrate.compose_call( - call_module=pallet_name, - call_function=method_name, - call_params=call_params, - ) - return call, None - except Exception as e: - error_msg = str(e) - # Try to provide more helpful error messages - if "parameter" in error_msg.lower() or "type" in error_msg.lower(): - return None, f"Invalid call parameters: {error_msg}" - return None, f"Failed to compose call: {error_msg}" - - except Exception as e: - return None, f"Error validating custom call: {str(e)}" - - async def create_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, @@ -161,43 +64,53 @@ async def create_crowdloan( print_error(f"[red]{unlock_status.message}[/red]") return False, unlock_status.message - # Check for custom call options - has_custom_call = any([custom_call_pallet, custom_call_method, custom_call_args]) - if has_custom_call: - if not all([custom_call_pallet, custom_call_method]): - error_msg = "Both --custom-call-pallet and --custom-call-method must be provided when using custom call." + # Determine crowdloan type and validate + crowdloan_type: str + if subnet_lease is not None: + if custom_call_pallet or custom_call_method or custom_call_args: + error_msg = "--custom-call-* cannot be used with --subnet-lease." if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error(f"[red]{error_msg}[/red]") return False, error_msg - - # Custom call args can be empty JSON object if method has no parameters - if custom_call_args is None: - custom_call_args = "{}" - - # Check mutual exclusivity with subnet_lease - if subnet_lease is not None: - error_msg = "--custom-call-pallet/--custom-call-method cannot be used together with --subnet-lease. Use one or the other." + crowdloan_type = "subnet" if subnet_lease else "fundraising" + elif custom_call_pallet or custom_call_method or custom_call_args: + if not (custom_call_pallet and custom_call_method): + error_msg = ( + "Both --custom-call-pallet and --custom-call-method must be provided." + ) if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error(f"[red]{error_msg}[/red]") return False, error_msg - - crowdloan_type: str - if subnet_lease is not None: - crowdloan_type = "subnet" if subnet_lease else "fundraising" - elif has_custom_call: crowdloan_type = "custom" elif prompt: type_choice = IntPrompt.ask( "\n[bold cyan]What type of crowdloan would you like to create?[/bold cyan]\n" "[cyan][1][/cyan] General Fundraising (funds go to address)\n" - "[cyan][2][/cyan] Subnet Leasing (create new subnet)", - choices=["1", "2"], + "[cyan][2][/cyan] Subnet Leasing (create new subnet)\n" + "[cyan][3][/cyan] Custom Call (attach custom Substrate call)", + choices=["1", "2", "3"], ) - crowdloan_type = "subnet" if type_choice == 2 else "fundraising" + + if type_choice == 2: + crowdloan_type = "subnet" + elif type_choice == 3: + crowdloan_type = "custom" + success, pallet, method, args, error_msg = await prompt_custom_call_params( + subtensor=subtensor, json_output=json_output + ) + if not success: + return False, error_msg or "Failed to get custom call parameters." + custom_call_pallet, custom_call_method, custom_call_args = ( + pallet, + method, + args, + ) + else: + crowdloan_type = "fundraising" if crowdloan_type == "subnet": current_burn_cost = await subtensor.burn_cost() @@ -355,12 +268,11 @@ async def create_crowdloan( custom_call_info: Optional[dict] = None if crowdloan_type == "custom": - # Validate and compose custom call - call_to_attach, error_msg = await validate_and_compose_custom_call( - subtensor=subtensor, + call_params = json.loads(custom_call_args or "{}") + call_to_attach, error_msg = await subtensor.compose_custom_crowdloan_call( pallet_name=custom_call_pallet, method_name=custom_call_method, - args_json=custom_call_args or "{}", + call_params=call_params, ) if call_to_attach is None: @@ -368,12 +280,12 @@ async def create_crowdloan( json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error(f"[red]{error_msg}[/red]") - return False, error_msg or "Failed to validate custom call." + return False, error_msg or "Failed to compose custom call." custom_call_info = { "pallet": custom_call_pallet, "method": custom_call_method, - "args": json.loads(custom_call_args or "{}"), + "args": call_params, } target_address = None # Custom calls don't use target_address elif crowdloan_type == "subnet": From b46dfe6c6cf59c31fee988e5569cf12d77ced20a Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:15:04 -0800 Subject: [PATCH 12/19] add prompt_custom_call_params --- bittensor_cli/src/commands/crowd/utils.py | 89 +++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/bittensor_cli/src/commands/crowd/utils.py b/bittensor_cli/src/commands/crowd/utils.py index 4ad7895e5..22aa109c4 100644 --- a/bittensor_cli/src/commands/crowd/utils.py +++ b/bittensor_cli/src/commands/crowd/utils.py @@ -1,8 +1,97 @@ +import json from typing import Optional from async_substrate_interface.types import Runtime +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 + + +async def prompt_custom_call_params( + subtensor: SubtensorInterface, + json_output: bool = False, +) -> tuple[bool, Optional[str], Optional[str], Optional[str], Optional[str]]: + """ + Prompt user for custom call parameters (pallet, method, and JSON args) + and validate that the call can be composed. + + Args: + subtensor: SubtensorInterface instance for call validation + json_output: Whether to output errors as JSON + + Returns: + Tuple of (success, pallet_name, method_name, args_json, error_msg) + On success: (True, pallet, method, args, None) + On failure: (False, None, None, None, error_msg) + """ + if not json_output: + console.print( + "\n[bold cyan]Custom Call Parameters[/bold cyan]\n" + "[dim]You'll need to provide a pallet (module) name, method name, and optional JSON arguments.\n\n" + "[yellow]Examples:[/yellow]\n" + " • Pallet: [cyan]SubtensorModule[/cyan], [cyan]Balances[/cyan], [cyan]System[/cyan]\n" + " • Method: [cyan]transfer_allow_death[/cyan], [cyan]transfer_keep_alive[/cyan], [cyan]transfer_all[/cyan]\n" + ' • Args: [cyan]{"dest": "5D...", "value": 1000000000}[/cyan] or [cyan]{}[/cyan] for empty\n' + ) + + pallet = Prompt.ask("Enter pallet name") + if not pallet.strip(): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Pallet name cannot be empty."}) + ) + else: + print_error("[red]Pallet name cannot be empty.[/red]") + return await prompt_custom_call_params(subtensor, json_output) + + method = Prompt.ask("Enter method name") + if not method.strip(): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Method name cannot be empty."}) + ) + else: + print_error("[red]Method name cannot be empty.[/red]") + return await prompt_custom_call_params(subtensor, json_output) + + args_input = Prompt.ask( + "Enter custom call arguments as JSON [dim](or press Enter for empty: {})[/dim]", + default="{}", + ) + + try: + call_params = json.loads(args_input) + except json.JSONDecodeError as e: + error_msg = f"Invalid JSON: {e}" + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + print_error( + '[yellow]Please try again. Example: {"param1": "value", "param2": 123}[/yellow]' + ) + return await prompt_custom_call_params(subtensor, json_output) + + call, error_msg = await subtensor.compose_custom_crowdloan_call( + pallet_name=pallet, + method_name=method, + call_params=call_params, + ) + if call is None: + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]Failed to compose call: {error_msg}[/red]") + console.print( + "[yellow]Please check:\n" + " • Pallet name exists in runtime\n" + " • Method name exists in the pallet\n" + " • Arguments match the method's expected parameters[/yellow]\n" + ) + return await prompt_custom_call_params(subtensor, json_output) + + return True, pallet, method, args_input, None async def get_constant( From d8436851de8b112932215156efa3b730ac2f6cae Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:32:25 -0800 Subject: [PATCH 13/19] make show_contributors a bool --- bittensor_cli/cli.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 3bf6e65c4..5431ca397 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -8831,10 +8831,10 @@ def crowd_info( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, - show_contributors: Optional[str] = typer.Option( - None, + show_contributors: bool = typer.Option( + False, "--show-contributors", - help="Show contributor list with identities. Use 'true' or 'false', or omit for default (false).", + help="Show contributor list with identities.", ), ): """ @@ -8870,11 +8870,6 @@ def crowd_info( validate=WV.WALLET, ) - # Parse show_contributors: None or "false" -> False, "true" -> True - show_contributors_bool = False # default - if show_contributors is not None: - show_contributors_bool = show_contributors.lower() in ("true", "1", "yes") - return self._run_command( view_crowdloan.show_crowdloan_details( subtensor=self.initialize_chain(network), @@ -8882,7 +8877,7 @@ def crowd_info( wallet=wallet, verbose=verbose, json_output=json_output, - show_contributors=show_contributors_bool, + show_contributors=show_contributors, ) ) From a58f810c87f03f0d392805e3a09bfe8c20ccbcc6 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:32:42 -0800 Subject: [PATCH 14/19] make contributors display consistent --- bittensor_cli/src/commands/crowd/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index defc9e428..813dd07ee 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -986,7 +986,7 @@ async def show_crowdloan_details( table.add_row( f"#{rank}", - f"{contributor_display} - {contribution_display} ({percentage:.2f}%)", + f"{contributor_display:<70} - {contribution_display} ({percentage:.2f}%)", ) if len(contributor_data) > 10: From 2ca50c45ed5181aa7b7adfcb72914fe892fa8051 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:36:21 -0800 Subject: [PATCH 15/19] remove outdated test --- tests/unit_tests/test_crowd_contributors.py | 496 -------------------- 1 file changed, 496 deletions(-) delete mode 100644 tests/unit_tests/test_crowd_contributors.py diff --git a/tests/unit_tests/test_crowd_contributors.py b/tests/unit_tests/test_crowd_contributors.py deleted file mode 100644 index 201164420..000000000 --- a/tests/unit_tests/test_crowd_contributors.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -Unit tests for crowd contributors command. -""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.bittensor.chain_data import CrowdloanData -from bittensor_cli.src.commands.crowd.contributors import list_contributors - - -class TestListContributors: - """Tests for list_contributors function.""" - - @pytest.mark.asyncio - async def test_list_contributors_success(self): - """Test successful listing of contributors.""" - # Setup mocks - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - # Mock crowdloan exists - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(30.0), - raised=Balance.from_tao(30.0), - end=1000000, - finalized=False, - contributors_count=3, - target_address="5GduHCP9UdBY", - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - # Mock contributors data from query_map - # The key structure is ((account_bytes_tuple,),) where account_bytes_tuple is tuple of ints - mock_contributor1_key = ( - ( - 74, - 51, - 88, - 161, - 161, - 215, - 144, - 145, - 231, - 175, - 227, - 146, - 149, - 109, - 220, - 180, - 12, - 58, - 121, - 233, - 152, - 50, - 211, - 15, - 242, - 187, - 103, - 2, - 198, - 131, - 177, - 118, - ), - ) - mock_contributor2_key = ( - ( - 202, - 66, - 124, - 47, - 131, - 219, - 1, - 26, - 137, - 169, - 17, - 112, - 182, - 39, - 163, - 162, - 72, - 150, - 208, - 58, - 179, - 235, - 238, - 242, - 150, - 177, - 219, - 0, - 2, - 76, - 172, - 171, - ), - ) - mock_contributor3_key = ( - ( - 224, - 56, - 146, - 238, - 201, - 170, - 157, - 255, - 58, - 77, - 190, - 94, - 17, - 231, - 15, - 217, - 15, - 134, - 147, - 100, - 174, - 45, - 31, - 132, - 21, - 200, - 40, - 185, - 176, - 209, - 247, - 54, - ), - ) - - mock_contribution1 = MagicMock() - mock_contribution1.value = 10000000000 # 10 TAO in rao - mock_contribution2 = MagicMock() - mock_contribution2.value = 10000000000 # 10 TAO in rao - mock_contribution3 = MagicMock() - mock_contribution3.value = 10000000000 # 10 TAO in rao - - # Create async generator for query_map results - async def mock_query_map_generator(): - yield (mock_contributor1_key, mock_contribution1) - yield (mock_contributor2_key, mock_contribution2) - yield (mock_contributor3_key, mock_contribution3) - - # Create a proper async iterable - class MockQueryMapResult: - def __aiter__(self): - return mock_query_map_generator() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockQueryMapResult() - ) - - # Mock identities - mock_subtensor.query_identity = AsyncMock( - side_effect=[ - {"info": {"display": {"Raw": "Alice"}}}, # Contributor 1 - {"info": {"display": {"Raw": "Bob"}}}, # Contributor 2 - {}, # Contributor 3 (no identity) - ] - ) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=False, - json_output=False, - ) - - # Verify - assert result is True - mock_subtensor.get_single_crowdloan.assert_called_once_with(0) - mock_subtensor.substrate.query_map.assert_called_once_with( - module="Crowdloan", - storage_function="Contributions", - params=[0], - fully_exhaust=True, - ) - assert mock_subtensor.query_identity.call_count == 3 - - @pytest.mark.asyncio - async def test_list_contributors_crowdloan_not_found(self): - """Test listing contributors when crowdloan doesn't exist.""" - mock_subtensor = MagicMock() - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=None) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=999, - verbose=False, - json_output=False, - ) - - # Verify - assert result is False - mock_subtensor.get_single_crowdloan.assert_called_once_with(999) - mock_subtensor.substrate.query_map.assert_not_called() - - @pytest.mark.asyncio - async def test_list_contributors_no_contributors(self): - """Test listing contributors when there are no contributors.""" - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(100.0), - raised=Balance.from_tao(10.0), - end=1000000, - finalized=False, - contributors_count=0, - target_address=None, - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - # Mock empty contributors data - async def mock_empty_query_map(): - if False: # Never yield anything - yield - - class MockEmptyQueryMapResult: - def __aiter__(self): - return mock_empty_query_map() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockEmptyQueryMapResult() - ) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=False, - json_output=False, - ) - - # Verify - assert result is True - mock_subtensor.query_identity.assert_not_called() - - @pytest.mark.asyncio - async def test_list_contributors_json_output(self): - """Test listing contributors with JSON output.""" - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(20.0), - raised=Balance.from_tao(20.0), - end=1000000, - finalized=False, - contributors_count=2, - target_address=None, - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - # Mock contributors data - mock_contributor1_key = ( - ( - 74, - 51, - 88, - 161, - 161, - 215, - 144, - 145, - 231, - 175, - 227, - 146, - 149, - 109, - 220, - 180, - 12, - 58, - 121, - 233, - 152, - 50, - 211, - 15, - 242, - 187, - 103, - 2, - 198, - 131, - 177, - 118, - ), - ) - mock_contributor2_key = ( - ( - 202, - 66, - 124, - 47, - 131, - 219, - 1, - 26, - 137, - 169, - 17, - 112, - 182, - 39, - 163, - 162, - 72, - 150, - 208, - 58, - 179, - 235, - 238, - 242, - 150, - 177, - 219, - 0, - 2, - 76, - 172, - 171, - ), - ) - - mock_contribution1 = MagicMock() - mock_contribution1.value = 10000000000 # 10 TAO - mock_contribution2 = MagicMock() - mock_contribution2.value = 10000000000 # 10 TAO - - async def mock_query_map_generator(): - yield (mock_contributor1_key, mock_contribution1) - yield (mock_contributor2_key, mock_contribution2) - - class MockQueryMapResult: - def __aiter__(self): - return mock_query_map_generator() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockQueryMapResult() - ) - mock_subtensor.query_identity = AsyncMock( - side_effect=[ - {"info": {"display": {"Raw": "Alice"}}}, - {"info": {"display": {"Raw": "Bob"}}}, - ] - ) - - # Mock json_console - with patch( - "bittensor_cli.src.commands.crowd.contributors.json_console" - ) as mock_json_console: - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=False, - json_output=True, - ) - - # Verify - assert result is True - mock_json_console.print.assert_called_once() - call_args = mock_json_console.print.call_args[0][0] - import json - - output_data = json.loads(call_args) - assert output_data["success"] is True - assert output_data["data"]["crowdloan_id"] == 0 - assert len(output_data["data"]["contributors"]) == 2 - assert output_data["data"]["total_count"] == 2 - assert output_data["data"]["total_contributed_tao"] == 20.0 - assert output_data["data"]["network"] == "finney" - # Verify contributors are sorted by rank - assert output_data["data"]["contributors"][0]["rank"] == 1 - assert output_data["data"]["contributors"][1]["rank"] == 2 - - @pytest.mark.asyncio - async def test_list_contributors_verbose_mode(self): - """Test listing contributors with verbose mode.""" - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(10.0), - raised=Balance.from_tao(10.0), - end=1000000, - finalized=False, - contributors_count=1, - target_address=None, - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - mock_contributor_key = ( - ( - 74, - 51, - 88, - 161, - 161, - 215, - 144, - 145, - 231, - 175, - 227, - 146, - 149, - 109, - 220, - 180, - 12, - 58, - 121, - 233, - 152, - 50, - 211, - 15, - 242, - 187, - 103, - 2, - 198, - 131, - 177, - 118, - ), - ) - mock_contribution = MagicMock() - mock_contribution.value = 10000000000 # 10 TAO - - async def mock_query_map_generator(): - yield (mock_contributor_key, mock_contribution) - - class MockQueryMapResult: - def __aiter__(self): - return mock_query_map_generator() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockQueryMapResult() - ) - mock_subtensor.query_identity = AsyncMock(return_value={}) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=True, - json_output=False, - ) - - # Verify - assert result is True From 2d13058a83bbfd29c1767f8b6aa485bd86da8de9 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:37:54 -0800 Subject: [PATCH 16/19] remove outdated test --- .../test_crowd_create_custom_call.py | 180 ------------------ 1 file changed, 180 deletions(-) delete mode 100644 tests/unit_tests/test_crowd_create_custom_call.py diff --git a/tests/unit_tests/test_crowd_create_custom_call.py b/tests/unit_tests/test_crowd_create_custom_call.py deleted file mode 100644 index 8aa0fddfa..000000000 --- a/tests/unit_tests/test_crowd_create_custom_call.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Unit tests for crowd create custom call functionality. -""" - -import json -import pytest -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from scalecodec import GenericCall - -from bittensor_cli.src.commands.crowd.create import validate_and_compose_custom_call - - -class TestValidateAndComposeCustomCall: - """Tests for validate_and_compose_custom_call function.""" - - @pytest.mark.asyncio - async def test_invalid_json_args(self): - """Test that invalid JSON in args is caught.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="test_method", - args_json='{"invalid": json}', - ) - - assert result_call is None - assert "Invalid JSON" in error_msg - - @pytest.mark.asyncio - async def test_pallet_not_found(self): - """Test that missing pallet is detected.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_pallet = MagicMock() - mock_pallet.name = "OtherPallet" - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock( - side_effect=ValueError("Pallet not found") - ) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="NonExistentPallet", - method_name="test_method", - args_json="{}", - ) - - assert result_call is None - assert "not found" in error_msg.lower() - - @pytest.mark.asyncio - async def test_method_not_found(self): - """Test that missing method is detected.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_call = MagicMock() - mock_call.name = "other_method" - - mock_pallet = MagicMock() - mock_pallet.name = "TestPallet" - mock_pallet.calls = [mock_call] - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="non_existent_method", - args_json="{}", - ) - - assert result_call is None - assert "not found" in error_msg.lower() - - @pytest.mark.asyncio - async def test_successful_validation(self): - """Test successful validation and call composition.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_call = MagicMock() - mock_call.name = "test_method" - mock_call.index = 0 - - mock_pallet = MagicMock() - mock_pallet.name = "TestPallet" - mock_pallet.calls = [mock_call] - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - # Mock compose_call to return a GenericCall - mock_generic_call = MagicMock(spec=GenericCall) - mock_subtensor.substrate.compose_call = AsyncMock( - return_value=mock_generic_call - ) - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="test_method", - args_json='{"param1": "value1"}', - ) - - assert result_call is not None - assert error_msg is None - mock_subtensor.substrate.compose_call.assert_called_once_with( - call_module="TestPallet", - call_function="test_method", - call_params={"param1": "value1"}, - ) - - @pytest.mark.asyncio - async def test_compose_call_failure(self): - """Test handling of compose_call failures.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_call = MagicMock() - mock_call.name = "test_method" - - mock_pallet = MagicMock() - mock_pallet.name = "TestPallet" - mock_pallet.calls = [mock_call] - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - # Mock compose_call to raise an error - mock_subtensor.substrate.compose_call = AsyncMock( - side_effect=Exception("Invalid parameter type") - ) - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="test_method", - args_json='{"param1": "value1"}', - ) - - assert result_call is None - assert error_msg is not None - assert "Invalid parameter" in error_msg or "Failed to compose" in error_msg From 2a2fea94de3e42efea28b559ddc53c18e60022af Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:40:32 -0800 Subject: [PATCH 17/19] useless test. First one is a dummy, second one masks errors --- tests/e2e_tests/test_crowd_contributors.py | 260 --------------------- 1 file changed, 260 deletions(-) delete mode 100644 tests/e2e_tests/test_crowd_contributors.py diff --git a/tests/e2e_tests/test_crowd_contributors.py b/tests/e2e_tests/test_crowd_contributors.py deleted file mode 100644 index ebfb842a9..000000000 --- a/tests/e2e_tests/test_crowd_contributors.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -E2E tests for crowd contributors command. - -Verify command: -* btcli crowd contributors --id -""" - -import json -import pytest - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_contributors_command(local_chain, wallet_setup): - """ - Test crowd contributors command and inspect its output. - - Steps: - 1. Create a crowdloan (if needed) or use existing one - 2. Make contributions to the crowdloan - 3. Execute contributors command and verify output - 4. Test with --verbose flag - 5. Test with --json-output flag - - Note: This test requires an existing crowdloan with contributors. - For a full e2e test, you would need to: - - Create a crowdloan - - Make contributions - - Then list contributors - """ - wallet_path_alice = "//Alice" - - # Create wallet for Alice - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - - # Test 1: List contributors for an existing crowdloan (assuming crowdloan #0 exists) - # This will work if there's a crowdloan with contributors on the test chain - result = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - # Parse JSON output - try: - result_output = json.loads(result.stdout) - # If crowdloan exists and has contributors - if result_output.get("success") is True: - assert "data" in result_output - assert "contributors" in result_output["data"] - assert "crowdloan_id" in result_output["data"] - assert result_output["data"]["crowdloan_id"] == 0 - assert isinstance(result_output["data"]["contributors"], list) - assert "total_count" in result_output["data"] - assert "total_contributed_tao" in result_output["data"] - - # If there are contributors, verify structure - if result_output["data"]["total_count"] > 0: - contributor = result_output["data"]["contributors"][0] - assert "rank" in contributor - assert "address" in contributor - assert "identity" in contributor - assert "contribution_tao" in contributor - assert "contribution_rao" in contributor - assert "percentage" in contributor - assert contributor["rank"] == 1 # First contributor should be rank 1 - assert contributor["contribution_tao"] >= 0 - assert 0 <= contributor["percentage"] <= 100 - - # If crowdloan doesn't exist or has no contributors - elif result_output.get("success") is False: - assert "error" in result_output - except json.JSONDecodeError: - # If output is not JSON (shouldn't happen with --json-output) - pytest.fail("Expected JSON output but got non-JSON response") - - # Test 2: Test with verbose flag - result_verbose = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--verbose", - ], - ) - - # Verify verbose output (should show full addresses) - assert result_verbose.exit_code == 0 or result_verbose.exit_code is None - - # Test 3: Test with non-existent crowdloan - result_not_found = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - "99999", - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - try: - result_output = json.loads(result_not_found.stdout) - # Should return error for non-existent crowdloan - assert result_output.get("success") is False - assert "error" in result_output - assert "not found" in result_output["error"].lower() - except json.JSONDecodeError: - # If output is not JSON, that's also acceptable for error cases - pass - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_contributors_with_real_crowdloan(local_chain, wallet_setup): - """ - Full e2e test: Create crowdloan, contribute, then list contributors. - - Steps: - 1. Create a crowdloan - 2. Make contributions from multiple wallets - 3. List contributors and verify all are present - 4. Verify sorting by contribution amount - """ - wallet_path_alice = "//Alice" - wallet_path_bob = "//Bob" - - # Create wallets - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( - wallet_path_bob - ) - - # Step 1: Create a crowdloan - create_result = exec_command_alice( - command="crowd", - sub_command="create", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--network", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--deposit", - "10", - "--cap", - "100", - "--duration", - "10000", - "--min-contribution", - "1", - "--no-prompt", - "--json-output", - ], - ) - - try: - create_output = json.loads(create_result.stdout) - if create_output.get("success") is True: - crowdloan_id = create_output.get("crowdloan_id") or create_output.get( - "data", {} - ).get("crowdloan_id") - - if crowdloan_id is not None: - # Step 2: Make contributions - # Alice contributes - contribute_alice = exec_command_alice( - command="crowd", - sub_command="contribute", - extra_args=[ - "--id", - str(crowdloan_id), - "--wallet-path", - wallet_path_alice, - "--network", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--amount", - "20", - "--no-prompt", - "--json-output", - ], - ) - - # Bob contributes - contribute_bob = exec_command_bob( - command="crowd", - sub_command="contribute", - extra_args=[ - "--id", - str(crowdloan_id), - "--wallet-path", - wallet_path_bob, - "--network", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_bob.name, - "--wallet-hotkey", - wallet_bob.hotkey_str, - "--amount", - "30", - "--no-prompt", - "--json-output", - ], - ) - - # Step 3: List contributors - contributors_result = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - str(crowdloan_id), - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - contributors_output = json.loads(contributors_result.stdout) - assert contributors_output.get("success") is True - assert contributors_output["data"]["crowdloan_id"] == crowdloan_id - assert contributors_output["data"]["total_count"] >= 2 - - # Verify contributors are sorted by contribution (descending) - contributors_list = contributors_output["data"]["contributors"] - if len(contributors_list) >= 2: - # Bob should be first (30 TAO > 20 TAO) - assert ( - contributors_list[0]["contribution_tao"] - >= contributors_list[1]["contribution_tao"] - ) - - # Verify percentages sum to 100% - total_percentage = sum(c["percentage"] for c in contributors_list) - assert ( - abs(total_percentage - 100.0) < 0.01 - ) # Allow small floating point errors - - except (json.JSONDecodeError, KeyError, AssertionError) as e: - # Skip test if prerequisites aren't met (e.g., insufficient balance, chain not ready) - pytest.skip(f"Test prerequisites not met: {e}") From 6d9cf47068d9f33c3e1dfdf0b6c34d89edb77b0a Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:41:02 -0800 Subject: [PATCH 18/19] outdated test. Identities are now fetched by default --- .../e2e_tests/test_crowd_identity_display.py | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 tests/e2e_tests/test_crowd_identity_display.py diff --git a/tests/e2e_tests/test_crowd_identity_display.py b/tests/e2e_tests/test_crowd_identity_display.py deleted file mode 100644 index 7953082d9..000000000 --- a/tests/e2e_tests/test_crowd_identity_display.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -E2E tests for crowd identity display functionality. - -Verify commands: -* btcli crowd list --show-identities -* btcli crowd info --id --show-identities --show-contributors -""" - -import json -import pytest - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_list_with_identities(local_chain, wallet_setup): - """ - Test crowd list command with identity display. - - Steps: - 1. Execute crowd list with --show-identities (default) - 2. Execute crowd list with --no-show-identities - 3. Verify identity information is displayed when enabled - """ - wallet_path_alice = "//Alice" - - # Create wallet for Alice - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - - # Test 1: List with identities (default) - result = exec_command_alice( - command="crowd", - sub_command="list", - extra_args=[ - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - try: - result_output = json.loads(result.stdout) - if result_output.get("success") is True: - assert "data" in result_output - assert "crowdloans" in result_output["data"] - - # Check if identity fields are present - if result_output["data"]["crowdloans"]: - crowdloan = result_output["data"]["crowdloans"][0] - # Identity fields should be present (may be None if no identity) - assert "creator_identity" in crowdloan - assert "target_identity" in crowdloan - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output") - - # Test 2: List without identities - result_no_identities = exec_command_alice( - command="crowd", - sub_command="list", - extra_args=[ - "--network", - "ws://127.0.0.1:9945", - "--show-identities", - "false", - "--json-output", - ], - ) - - try: - result_output = json.loads(result_no_identities.stdout) - if result_output.get("success") is True: - if result_output["data"]["crowdloans"]: - crowdloan = result_output["data"]["crowdloans"][0] - # Identity fields should still be present but None - assert "creator_identity" in crowdloan - assert crowdloan.get("creator_identity") is None - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output") - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_info_with_identities(local_chain, wallet_setup): - """ - Test crowd info command with identity display and contributors. - - Steps: - 1. Execute crowd info with --show-identities - 2. Execute crowd info with --show-contributors - 3. Verify identity and contributor information is displayed - """ - wallet_path_alice = "//Alice" - - # Create wallet for Alice - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - - # Test 1: Info with identities (default) - result = exec_command_alice( - command="crowd", - sub_command="info", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - try: - result_output = json.loads(result.stdout) - if result_output.get("success") is True: - assert "data" in result_output - # Identity fields should be present - assert "creator_identity" in result_output["data"] - assert "target_identity" in result_output["data"] - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output or crowdloan not found") - - # Test 2: Info with identities and contributors - result_with_contributors = exec_command_alice( - command="crowd", - sub_command="info", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--show-identities", - "true", - "--show-contributors", - "true", - "--json-output", - ], - ) - - try: - result_output = json.loads(result_with_contributors.stdout) - if result_output.get("success") is True: - assert "data" in result_output - # Contributors should be present if flag is set - assert "contributors" in result_output["data"] - if result_output["data"]["contributors"]: - contributor = result_output["data"]["contributors"][0] - assert "identity" in contributor - assert "address" in contributor - assert "contribution_tao" in contributor - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output or crowdloan not found") From b1207a4551f4b7457b7d5c84d669ad6fc1a03448 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 20:13:17 -0800 Subject: [PATCH 19/19] simplify display formatting --- bittensor_cli/src/commands/crowd/view.py | 80 +++++++++++------------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 813dd07ee..20ed82935 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -434,28 +434,26 @@ async def list_crowdloans( else: time_cell = time_label - # Format creator cell with identity if available - if loan.creator in identity_map: - creator_identity = identity_map[loan.creator] - if verbose: - creator_cell = f"{creator_identity} ({loan.creator})" - else: - creator_cell = f"{creator_identity} ({_shorten(loan.creator)})" - else: - creator_cell = loan.creator if verbose else _shorten(loan.creator) + # Format creator cell + creator_identity = identity_map.get(loan.creator) + address_display = loan.creator if verbose else _shorten(loan.creator) + creator_cell = ( + f"{creator_identity} ({address_display})" + if creator_identity + else address_display + ) - # Format target cell with identity if available + # Format target cell if loan.target_address: - if loan.target_address in identity_map: - target_identity = identity_map[loan.target_address] - if verbose: - target_cell = f"{target_identity} ({loan.target_address})" - else: - target_cell = f"{target_identity} ({_shorten(loan.target_address)})" - else: - target_cell = ( - loan.target_address if verbose else _shorten(loan.target_address) - ) + target_identity = identity_map.get(loan.target_address) + address_display = ( + loan.target_address if verbose else _shorten(loan.target_address) + ) + target_cell = ( + f"{target_identity} ({address_display})" + if target_identity + else address_display + ) else: target_cell = ( f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" @@ -734,17 +732,14 @@ async def show_crowdloan_details( table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") - # Display creator with identity if available - if crowdloan.creator in identity_map: - creator_identity = identity_map[crowdloan.creator] - if verbose: - creator_display = f"{creator_identity} ({crowdloan.creator})" - else: - creator_display = f"{creator_identity} ({_shorten(crowdloan.creator)})" - elif verbose: - creator_display = crowdloan.creator - else: - creator_display = _shorten(crowdloan.creator) + # Display creator + creator_identity = identity_map.get(crowdloan.creator) + address_display = crowdloan.creator if verbose else _shorten(crowdloan.creator) + creator_display = ( + f"{creator_identity} ({address_display})" + if creator_identity + else address_display + ) table.add_row( "Creator", f"[{COLORS.G.TEMPO}]{creator_display}[/{COLORS.G.TEMPO}]", @@ -853,20 +848,15 @@ async def show_crowdloan_details( table.add_section() if crowdloan.target_address: - if crowdloan.target_address in identity_map: - target_identity = identity_map[crowdloan.target_address] - if verbose: - target_display = f"{target_identity} ({crowdloan.target_address})" - else: - target_display = ( - f"{target_identity} ({_shorten(crowdloan.target_address)})" - ) - else: - target_display = ( - crowdloan.target_address - if verbose - else _shorten(crowdloan.target_address) - ) + target_identity = identity_map.get(crowdloan.target_address) + address_display = ( + crowdloan.target_address if verbose else _shorten(crowdloan.target_address) + ) + target_display = ( + f"{target_identity} ({address_display})" + if target_identity + else address_display + ) else: target_display = ( f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]"