From 568594c38d95d9e145027a0e347fd451d4a67422 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 19 Dec 2025 14:04:12 +0200 Subject: [PATCH] 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 ae7d04709..2f18acda9 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -85,6 +85,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, @@ -1332,6 +1333,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) @@ -2808,6 +2812,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, @@ -2815,7 +2820,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: @@ -2860,7 +2865,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) @@ -8590,6 +8595,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. @@ -8599,19 +8634,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, ) ) @@ -8631,17 +8689,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) @@ -8662,6 +8736,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), @@ -8669,6 +8753,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, ) ) @@ -8731,6 +8863,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, @@ -8743,6 +8890,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 @@ -8754,6 +8902,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) @@ -8778,6 +8929,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 ff64e41a0..1ad320805 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -24,6 +24,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, @@ -36,6 +135,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, @@ -58,9 +160,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" @@ -79,6 +207,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" @@ -217,7 +351,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: @@ -328,6 +486,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 = ( @@ -406,6 +574,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 @@ -427,6 +597,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." console.print( 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 e79d2cdd9..5e342cc7c 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