diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 302e56be..5431ca39 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,31 @@ def crowd_list( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + 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,12 +8784,22 @@ def crowd_list( or a general fundraising crowdloan. Use `--verbose` for full-precision amounts and longer addresses. + 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 --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) return self._run_command( @@ -8767,6 +8807,11 @@ def crowd_list( subtensor=self.initialize_chain(network), verbose=verbose, json_output=json_output, + status_filter=status, + type_filter=type_filter, + sort_by=sort_by, + sort_order=sort_order, + search_creator=search_creator, ) ) @@ -8786,17 +8831,25 @@ def crowd_info( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + show_contributors: bool = typer.Option( + False, + "--show-contributors", + help="Show contributor list with identities.", + ), ): """ Display detailed information about a specific crowdloan. Includes funding progress, target account, and call details among other information. + 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-contributors true """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) @@ -8824,6 +8877,53 @@ def crowd_info( wallet=wallet, verbose=verbose, json_output=json_output, + show_contributors=show_contributors, + ) + ) + + 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 +8986,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 +9013,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 +9025,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 +9052,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/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index bf2f91a2..bb249be2 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, @@ -2501,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]): """ diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py new file mode 100644 index 00000000..a46db107 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -0,0 +1,200 @@ +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 +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + console, + json_console, + print_error, + millify_tao, +) + + +def _shorten(account: Optional[str]) -> str: + """Shorten an account address for display.""" + if not account: + return "-" + return f"{account[:6]}…{account[-6:]}" + + +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 + """ + 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: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"{error_msg}") + return False + + 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: + 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 + + total_contributed = sum( + contributor_contributions.values(), start=Balance.from_tao(0) + ) + + 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": address, + "identity": identity_name, + "contribution": amount, + "percentage": percentage, + } + ) + + if json_output: + 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, + "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]" + 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( + 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 f14d4aae..2ce1dc9d 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, @@ -37,6 +39,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,17 +64,53 @@ async def create_crowdloan( print_error(f"[red]{unlock_status.message}[/red]") return False, unlock_status.message + # 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 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 = "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() @@ -80,6 +121,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 +265,30 @@ 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": + 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, + call_params=call_params, + ) + + 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 compose custom call." + + custom_call_info = { + "pallet": custom_call_pallet, + "method": custom_call_method, + "args": call_params, + } + target_address = None # Custom calls don't use target_address + elif crowdloan_type == "subnet": target_address = None if emissions_share is None: @@ -325,6 +395,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 +483,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 +506,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/utils.py b/bittensor_cli/src/commands/crowd/utils.py index 4ad7895e..22aa109c 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( diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 9a248d18..20ed8293 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -44,16 +44,48 @@ 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, + 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.""" - - current_block, loans = await asyncio.gather( + """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 + 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, all_identities = await asyncio.gather( subtensor.substrate.get_block_number(None), subtensor.get_crowdloans(), + subtensor.query_all_identities(), ) if not loans: if json_output: @@ -76,10 +108,76 @@ 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()) + # Build identity map from all identities + identity_map = {} + 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 + + # 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 +187,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 +217,45 @@ async def list_crowdloans( "time_remaining": time_remaining, "contributors_count": loan.contributors_count, "creator": loan.creator, + "creator_identity": identity_map.get(loan.creator), "target_address": loan.target_address, + "target_identity": identity_map.get(loan.target_address) + if 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 +345,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 +434,30 @@ 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}]" + # 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 ) - if not verbose and loan.target_address: - target_cell = _shorten(loan.target_address) + + # Format target cell + if 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}]" + ) funds_account_cell = ( loan.funds_account if verbose else _shorten(loan.funds_account) @@ -327,14 +510,19 @@ async def show_crowdloan_details( wallet: Optional[Wallet] = None, verbose: bool = False, json_output: bool = False, + 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: @@ -349,6 +537,19 @@ async def show_crowdloan_details( crowdloan_id, wallet.coldkeypub.ss58_address ) + # Build identity map from all identities + identity_map = {} + 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 + status = _status(crowdloan, current_block) status_color_map = { "Finalized": COLORS.G.SUCCESS, @@ -417,6 +618,7 @@ async def show_crowdloan_details( "status": status, "finalized": crowdloan.finalized, "creator": crowdloan.creator, + "creator_identity": identity_map.get(crowdloan.creator), "funds_account": crowdloan.funds_account, "raised": crowdloan.raised.tao, "cap": crowdloan.cap.tao, @@ -431,12 +633,67 @@ 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 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: + contributor_contributions = await subtensor.get_crowdloan_contributors( + crowdloan_id + ) + contributors_list = list(contributor_contributions.keys()) + if contributors_list: + 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 in contributors_list: + contribution_amount = contributor_contributions[contributor_address] + 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, + "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 +731,18 @@ 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 + 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}]{crowdloan.creator}[/{COLORS.G.TEMPO}]", + f"[{COLORS.G.TEMPO}]{creator_display}[/{COLORS.G.TEMPO}]", ) table.add_row( "Funds Account", @@ -582,7 +848,15 @@ async def show_crowdloan_details( table.add_section() if crowdloan.target_address: - target_display = 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}]" @@ -637,5 +911,81 @@ 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 + contributor_contributions = await subtensor.get_crowdloan_contributors( + crowdloan_id + ) + + if contributor_contributions: + contributors_list = list(contributor_contributions.keys()) + contributor_data = [] + total_contributed = 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.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:<70} - {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 faa1b37d..d24cd16a 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"