diff --git a/CHANGELOG.md b/CHANGELOG.md index 99297d0a7..75a7b67a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 9.18.0 /2026-01-15 + +## What's Changed +* Standardize Success Message Printing with print.success by @leonace924 in https://github.com/opentensor/btcli/pull/786 +* Fix live display formatting on macOS Terminal.app by @calm329 in https://github.com/opentensor/btcli/pull/789 +* Update User Liquidity E2E test by @ibraheem-abe in https://github.com/opentensor/btcli/pull/794 +* updated proxy help text by @chideraao in https://github.com/opentensor/btcli/pull/788 +* Update DurationOfStartCall -> InitialStartCallDelay by @ibraheem-abe in https://github.com/opentensor/btcli/pull/797 +* Feat: Add protection warnings by @ibraheem-abe in https://github.com/opentensor/btcli/pull/799 +* feat: Add crowdloan contributors command and enhance create/view functionality by @circlecrystalin & @ibraheem-abe in https://github.com/opentensor/btcli/pull/776 + +## New Contributors +* @circlecrystalin made their first contribution in https://github.com/opentensor/btcli/pull/776 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.17.0...v9.18.0 + ## 9.17.0 /2025-12-22 ## What's Changed diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 16e68c254..5431ca397 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -74,6 +74,7 @@ ProxyAddressBook, ProxyAnnouncements, confirm_action, + print_protection_warnings, ) from bittensor_cli.src.commands import sudo, wallets, view from bittensor_cli.src.commands import weights as weights_cmds @@ -85,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, @@ -1333,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) @@ -2007,6 +2012,9 @@ def config_add_proxy( ): """ Adds a new pure proxy to the address book. + + [bold]Example:[/bold] + [green]$[/green] btcli config add-proxy """ if self.proxies.get(name) is not None: print_error( @@ -2053,6 +2061,9 @@ def config_remove_proxy( Removes a pure proxy from the address book. Note: Does not remove the proxy on chain. Only removes it from the address book. + + [bold]Example:[/bold] + [green]$[/green] btcli config remove-proxy --name test-proxy """ if name in self.proxies: del self.proxies[name] @@ -2066,6 +2077,9 @@ def config_remove_proxy( def config_get_proxies(self): """ Displays the current proxies address book + + [bold]Example:[/bold] + [green]$[/green] btcli config proxies """ table = Table( Column("[bold white]Name", style=f"{COLORS.G.ARG}"), @@ -2114,6 +2128,14 @@ def config_update_proxy( delay: Optional[int] = typer.Option(None, help="Delay, in blocks."), note: Optional[str] = typer.Option(None, help="Any notes about this entry"), ): + """ + Updates the details of a proxy in the address book. + + Note: This command not update the proxy on chain. It only updates it on the address book. + + [bold]Example:[/bold] + [green]$[/green] btcli config update-proxy --name test-proxy + """ if name not in self.proxies: print_error( f"\n[red]Error[/red] Proxy of name '{name}' not found in address book.\n" @@ -2886,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, @@ -2893,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: @@ -2938,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) @@ -4682,7 +4705,12 @@ def stake_add( if safe_staking: rate_tolerance = self.ask_rate_tolerance(rate_tolerance) allow_partial_stake = self.ask_partial_stake(allow_partial_stake) - console.print("\n") + + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="stake add", + ) if netuids: netuids = parse_to_list( @@ -4999,8 +5027,12 @@ def stake_remove( if safe_staking: rate_tolerance = self.ask_rate_tolerance(rate_tolerance) allow_partial_stake = self.ask_partial_stake(allow_partial_stake) - console.print("\n") + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="stake remove", + ) if interactive and any( [hotkey_ss58_address, include_hotkeys, exclude_hotkeys, all_hotkeys] ): @@ -5336,6 +5368,11 @@ def stake_move( """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=None, + command_name="stake move", + ) if prompt: if not confirm_action( "This transaction will [bold]move stake[/bold] to another hotkey while keeping the same " @@ -5557,6 +5594,11 @@ def stake_transfer( """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=None, + command_name="stake transfer", + ) if prompt: if not confirm_action( "This transaction will [bold]transfer ownership[/bold] from one coldkey to another, in subnets " @@ -5763,6 +5805,15 @@ def stake_swap( "[dim]This command moves stake from one subnet to another subnet while keeping " "the same coldkey-hotkey pair.[/dim]" ) + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + rate_tolerance = self.ask_rate_tolerance(rate_tolerance) + allow_partial_stake = self.ask_partial_stake(allow_partial_stake) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="stake swap", + ) wallet = self.wallet_ask( wallet_name, @@ -5786,10 +5837,6 @@ def stake_swap( ) if not amount and not swap_all: amount = FloatPrompt.ask("Enter the [blue]amount[/blue] to swap") - safe_staking = self.ask_safe_staking(safe_staking) - if safe_staking: - rate_tolerance = self.ask_rate_tolerance(rate_tolerance) - allow_partial_stake = self.ask_partial_stake(allow_partial_stake) logger.debug( "args:\n" @@ -7581,6 +7628,11 @@ def subnets_create( """ self.verbosity_handler(quiet, verbose, json_output, prompt) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=None, + command_name="subnets create", + ) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -8698,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. @@ -8707,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( @@ -8720,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, ) ) @@ -8739,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) @@ -8777,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, ) ) @@ -8839,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, @@ -8851,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 @@ -8862,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) @@ -8886,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, @@ -9476,16 +9645,11 @@ def proxy_remove( Revokes proxy permissions previously granted to another account. This prevents the delegate account from executing any further transactions on your behalf. - [bold]Note[/bold]: You can specify a delegate to remove a single proxy or use the `--all` flag to remove all existing proxies linked to an account. - - [bold]Common Examples:[/bold] - 1. Revoke proxy permissions from a single proxy account + [bold]Example:[/bold] + Revoke proxy permissions from a single proxy account [green]$[/green] btcli proxy remove --delegate 5GDel... --proxy-type Transfer - 2. Remove all proxies linked to an account - [green]$[/green] btcli proxy remove --all - """ # TODO should add a --all flag to call Proxy.remove_proxies ? logger.debug( @@ -9649,7 +9813,25 @@ def proxy_execute_announced( verbose: bool = Options.verbose, json_output: bool = Options.json_output, ): - self.verbosity_handler(quiet, verbose, json_output, prompt, decline) + """ + Executes a previously announced proxy call. + + This command submits the inner call on-chain using the proxy relationship. The command will fail if the required delay has not passed or if the call does not match the announcement parameters. + + If you do not provide the call hash or call hex of the announced call in the command, you would be prompted to enter details of the call including the module name and call function. + + [bold]Note[/bold]: Using the `--call-hash` flag attempts to resolve the call from the proxy announcements address book. Use this flag only if the announcement was created through BTCLI. + If the announcement was created by any other method, you must provide the call hex using the `--call-hex` flag or rebuild the call explicitly via the command prompts. + + [bold]Common Examples:[/bold] + 1. Using the call hash + [green]$[/green] btcli proxy execute --call-hash caf4da69610d379c2e2e5...0cbc6b012f6cff6340c45a1 + + 2. Using the call hex + [green]$[/green] btcli proxy execute --call-hex 0x0503008f0667364ff11915b0b2a54387...27948e8f950f79a69cff9c029cdb69 + + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) outer_proxy_from_config = self.proxies.get(proxy, {}) proxy_from_config = outer_proxy_from_config.get("address") delay = 0 @@ -9761,7 +9943,7 @@ def proxy_execute_announced( else: console.print( f"The call hash you have provided matches {len(potential_call_matches)}" - f" possible entries. The results will be iterated for you to selected your intended" + f" possible entries. The results will be iterated for you to select your intended " f"call." ) for row in potential_call_matches: diff --git a/bittensor_cli/src/bittensor/extrinsics/registration.py b/bittensor_cli/src/bittensor/extrinsics/registration.py index c10929301..d082f58c6 100644 --- a/bittensor_cli/src/bittensor/extrinsics/registration.py +++ b/bittensor_cli/src/bittensor/extrinsics/registration.py @@ -33,6 +33,7 @@ confirm_action, console, print_error, + print_success, format_error_message, millify, get_human_readable, @@ -587,8 +588,8 @@ async def get_neuron_for_pubkey_and_subnet(): subtensor, netuid=netuid, hotkey_ss58=get_hotkey_pub_ss58(wallet) ) if is_registered: - print_error( - f":white_heavy_check_mark: [dark_sea_green3]Already registered on netuid:{netuid}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Already registered on netuid:{netuid}[/dark_sea_green3]" ) return True @@ -630,8 +631,8 @@ async def get_neuron_for_pubkey_and_subnet(): # https://github.com/opentensor/subtensor/blob/development/pallets/subtensor/src/errors.rs if "HotKeyAlreadyRegisteredInSubNet" in err_msg: - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Already Registered on " + print_success( + f"[dark_sea_green3]Already Registered on " f"[bold]subnet:{netuid}[/bold][/dark_sea_green3]" ) return True @@ -647,8 +648,8 @@ async def get_neuron_for_pubkey_and_subnet(): hotkey_ss58=get_hotkey_pub_ss58(wallet), ) if is_registered: - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Registered[/dark_sea_green3]" + print_success( + "[dark_sea_green3]Registered[/dark_sea_green3]" ) return True else: @@ -738,8 +739,8 @@ async def burned_register_extrinsic( era_ = {"period": era} if not neuron.is_null: + print_success("[dark_sea_green3]Already Registered[/dark_sea_green3]:") console.print( - ":white_heavy_check_mark: [dark_sea_green3]Already Registered[/dark_sea_green3]:\n" f"uid: [{COLOR_PALETTE.G.NETUID_EXTRA}]{neuron.uid}[/{COLOR_PALETTE.G.NETUID_EXTRA}]\n" f"netuid: [{COLOR_PALETTE.G.NETUID}]{neuron.netuid}[/{COLOR_PALETTE.G.NETUID}]\n" f"hotkey: [{COLOR_PALETTE.G.HK}]{neuron.hotkey}[/{COLOR_PALETTE.G.HK}]\n" @@ -798,9 +799,7 @@ async def burned_register_extrinsic( ) if len(netuids_for_hotkey) > 0: - console.print( - f":white_heavy_check_mark: [green]Registered on netuid {netuid} with UID {my_uid}[/green]" - ) + print_success(f"Registered on netuid {netuid} with UID {my_uid}") return True, f"Registered on {netuid} with UID {my_uid}", ext_id else: # neuron not found, try again diff --git a/bittensor_cli/src/bittensor/extrinsics/root.py b/bittensor_cli/src/bittensor/extrinsics/root.py index 2c346a47d..1bcdccf6b 100644 --- a/bittensor_cli/src/bittensor/extrinsics/root.py +++ b/bittensor_cli/src/bittensor/extrinsics/root.py @@ -32,6 +32,7 @@ confirm_action, console, print_error, + print_success, u16_normalized_float, print_verbose, format_error_message, @@ -343,9 +344,7 @@ async def root_register_extrinsic( subtensor, netuid=0, hotkey_ss58=get_hotkey_pub_ss58(wallet) ) if is_registered: - console.print( - ":white_heavy_check_mark: [green]Already registered on root network.[/green]" - ) + print_success("Already registered on root network.") return True, "Already registered on root network", None with console.status(":satellite: Registering to root network...", spinner="earth"): @@ -377,9 +376,7 @@ async def root_register_extrinsic( params=[0, get_hotkey_pub_ss58(wallet)], ) if uid is not None: - console.print( - f":white_heavy_check_mark: [green]Registered with UID {uid}[/green]" - ) + print_success(f"Registered with UID {uid}") return True, f"Registered with UID {uid}", ext_id else: # neuron not found, try again @@ -540,7 +537,7 @@ async def _do_set_weights(): return True if success is True: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") return True else: fmt_err = format_error_message(error_message) diff --git a/bittensor_cli/src/bittensor/extrinsics/serving.py b/bittensor_cli/src/bittensor/extrinsics/serving.py index 14945d46c..3ace41062 100644 --- a/bittensor_cli/src/bittensor/extrinsics/serving.py +++ b/bittensor_cli/src/bittensor/extrinsics/serving.py @@ -11,6 +11,7 @@ confirm_action, console, print_error, + print_success, format_error_message, unlock_key, print_extrinsic_id, @@ -112,8 +113,8 @@ async def reset_axon_extrinsic( # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Axon reset successfully[/dark_sea_green3]" + print_success( + "[dark_sea_green3]Axon reset successfully[/dark_sea_green3]" ) return True, "Not waiting for finalization or inclusion.", None @@ -125,8 +126,8 @@ async def reset_axon_extrinsic( else: ext_id = await response.get_extrinsic_identifier() await print_extrinsic_id(response) - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Axon reset successfully[/dark_sea_green3]" + print_success( + "[dark_sea_green3]Axon reset successfully[/dark_sea_green3]" ) return True, "Axon reset successfully", ext_id @@ -230,8 +231,8 @@ async def set_axon_extrinsic( # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" ) return True, "Not waiting for finalization or inclusion.", None @@ -243,8 +244,8 @@ async def set_axon_extrinsic( else: ext_id = await response.get_extrinsic_identifier() await print_extrinsic_id(response) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" ) return True, f"Axon set successfully to {ip}:{port}", ext_id diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index 5de403466..fb714bfd2 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -12,6 +12,7 @@ confirm_action, console, print_error, + print_success, print_verbose, is_valid_bittensor_address_or_public_key, print_error, @@ -219,11 +220,10 @@ async def do_transfer() -> tuple[bool, str, str, Optional[AsyncExtrinsicReceipt] success, block_hash, err_msg, ext_receipt = await do_transfer() if success: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - console.print(f"[green]Block Hash: {block_hash}[/green]") + print_success(f"Finalized. Block Hash: {block_hash}") else: - console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") + print_error(f"Failed: {err_msg}") if success: with console.status(":satellite: Checking Balance...", spinner="aesthetic"): diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index bf2f91a23..bb249be23 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/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index a5c1896d3..53a4e4191 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -156,6 +156,53 @@ def print_error(message: str, status=None): print_console(error_message, "red", err_console) +def print_success(message: str, status=None): + """Print success messages while temporarily pausing the status spinner.""" + success_message = f":white_heavy_check_mark: {message}" + if status: + status.stop() + print_console(success_message, "green", console) + status.start() + else: + print_console(success_message, "green", console) + + +def print_protection_warnings( + mev_protection: bool, + safe_staking: Optional[bool] = None, + command_name: str = "", +) -> None: + """ + Print warnings about missing MEV protection and/or limit price protection. + + Args: + mev_protection: Whether MEV protection is enabled. + safe_staking: Whether safe staking (limit price protection) is enabled. + None if limit price protection is not available for this command. + command_name: Name of the command (e.g., "stake add") for context. + """ + warnings = [] + + if not mev_protection: + warnings.append( + "⚠️ [dim][yellow]Warning:[/yellow] MEV protection is disabled. " + "This transaction may be exposed to MEV attacks.[/dim]" + ) + + if safe_staking is not None and not safe_staking: + warnings.append( + "⚠️ [dim][yellow]Warning:[/yellow] Limit price protection (safe staking) is disabled. " + "This transaction may be subject to slippage.[/dim]" + ) + + if warnings: + if command_name: + console.print(f"\n[dim]Protection status for '{command_name}':[/dim]") + for warning in warnings: + console.print(warning) + console.print() + + RAO_PER_TAO = 1e9 U16_MAX = 65535 U64_MAX = 18446744073709551615 diff --git a/bittensor_cli/src/commands/crowd/contribute.py b/bittensor_cli/src/commands/crowd/contribute.py index 933e794a6..0e0097a95 100644 --- a/bittensor_cli/src/commands/crowd/contribute.py +++ b/bittensor_cli/src/commands/crowd/contribute.py @@ -16,6 +16,7 @@ json_console, print_error, print_extrinsic_id, + print_success, unlock_key, ) from bittensor_cli.src.commands.crowd.view import show_crowdloan_details @@ -89,7 +90,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, error_msg is_valid, error_message = validate_for_contribution( @@ -99,7 +100,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_message})) else: - print_error(f"[red]{error_message}[/red]") + print_error(error_message) return False, error_message contributor_address = proxy or wallet.coldkeypub.ss58_address @@ -136,7 +137,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Contribution below minimum requirement." if contribution_amount > user_balance: @@ -144,7 +145,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Insufficient balance." # Auto-adjustment @@ -245,7 +246,7 @@ async def contribute_to_crowdloan( json.dumps({"success": False, "error": unlock_status.message}) ) else: - print_error(f"[red]{unlock_status.message}[/red]") + print_error(unlock_status.message) return False, unlock_status.message with console.status(f"\n:satellite: Contributing to crowdloan #{crowdloan_id}..."): @@ -272,7 +273,7 @@ async def contribute_to_crowdloan( ) ) else: - print_error(f"[red]Failed to contribute: {error_message}[/red]") + print_error(f"Failed to contribute: {error_message}") return False, error_message or "Failed to contribute." new_balance, new_contribution, updated_crowdloan = await asyncio.gather( @@ -398,7 +399,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, error_msg if crowdloan.finalized: @@ -406,7 +407,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Cannot withdraw from finalized crowdloan." contributor_address = proxy or wallet.coldkeypub.ss58_address @@ -424,7 +425,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "No contribution to withdraw." is_creator = wallet.coldkeypub.ss58_address == crowdloan.creator @@ -435,7 +436,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Creator cannot withdraw deposit amount." remaining_contribution = crowdloan.deposit else: @@ -534,7 +535,7 @@ async def withdraw_from_crowdloan( json.dumps({"success": False, "error": unlock_status.message}) ) else: - print_error(f"[red]{unlock_status.message}[/red]") + print_error(unlock_status.message) return False, unlock_status.message with console.status(f"\n:satellite: Withdrawing from crowdloan #{crowdloan_id}..."): @@ -561,9 +562,7 @@ async def withdraw_from_crowdloan( ) ) else: - print_error( - f"[red]Failed to withdraw: {error_message or 'Unknown error'}[/red]" - ) + print_error(f"Failed to withdraw: {error_message or 'Unknown error'}") return False, error_message or "Failed to withdraw from crowdloan." new_balance, updated_contribution, updated_crowdloan = await asyncio.gather( @@ -602,9 +601,7 @@ async def withdraw_from_crowdloan( } json_console.print(json.dumps(output_dict)) else: - console.print( - f"\n✅ [green]Successfully withdrew from crowdloan #{crowdloan_id}![/green]\n" - ) + print_success(f"Successfully withdrew from crowdloan #{crowdloan_id}!\n") console.print( f"Amount Withdrawn: [{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]\n" diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py new file mode 100644 index 000000000..a46db1073 --- /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 ff64e41a0..2ce1dc9df 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -6,18 +6,21 @@ 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, console, json_console, print_error, + print_success, is_valid_ss58_address, unlock_key, print_extrinsic_id, @@ -36,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, @@ -58,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() @@ -79,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" @@ -186,11 +234,11 @@ async def create_crowdloan( if cap <= deposit: if prompt: print_error( - f"[red]Cap must be greater than the deposit ({deposit.tao:,.4f} TAO).[/red]" + f"Cap must be greater than the deposit ({deposit.tao:,.4f} TAO)." ) cap_value = None continue - print_error("[red]Cap must be greater than the initial deposit.[/red]") + print_error("Cap must be greater than the initial deposit.") return False, "Cap must be greater than the initial deposit." break @@ -204,12 +252,12 @@ async def create_crowdloan( if duration_value < min_duration or duration_value > max_duration: if prompt: print_error( - f"[red]Duration must be between {min_duration} and " - f"{max_duration} blocks.[/red]" + f"Duration must be between {min_duration} and " + f"{max_duration} blocks." ) duration_value = None continue - print_error("[red]Crowdloan duration is outside the allowed range.[/red]") + print_error("Crowdloan duration is outside the allowed range.") return False, "Crowdloan duration is outside the allowed range." duration = duration_value break @@ -217,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: @@ -227,7 +298,7 @@ async def create_crowdloan( if not 0 <= emissions_share <= 100: print_error( - f"[red]Emissions share must be between 0 and 100, got {emissions_share}[/red]" + f"Emissions share must be between 0 and 100, got {emissions_share}" ) return False, "Invalid emissions share percentage." @@ -255,9 +326,7 @@ async def create_crowdloan( if target_address: target_address = target_address.strip() if not is_valid_ss58_address(target_address): - print_error( - f"[red]Invalid target SS58 address provided: {target_address}[/red]" - ) + print_error(f"Invalid target SS58 address provided: {target_address}") return False, "Invalid target SS58 address provided." elif prompt: target_input = Prompt.ask( @@ -266,9 +335,7 @@ async def create_crowdloan( target_address = target_input.strip() or None if not is_valid_ss58_address(target_address): - print_error( - f"[red]Invalid target SS58 address provided: {target_address}[/red]" - ) + print_error(f"Invalid target SS58 address provided: {target_address}") return False, "Invalid target SS58 address provided." call_to_attach = None @@ -278,8 +345,8 @@ async def create_crowdloan( ) if deposit > creator_balance: print_error( - f"[red]Insufficient balance to cover the deposit. " - f"Available: {creator_balance}, required: {deposit}[/red]" + f"Insufficient balance to cover the deposit. " + f"Available: {creator_balance}, required: {deposit}" ) return False, "Insufficient balance to cover the deposit." @@ -328,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 = ( @@ -383,7 +460,7 @@ async def create_crowdloan( ) ) else: - print_error(f"[red]{error_message or 'Failed to create crowdloan.'}[/red]") + print_error(f"{error_message or 'Failed to create crowdloan.'}") return False, error_message or "Failed to create crowdloan." if json_output: @@ -406,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 @@ -414,8 +493,8 @@ async def create_crowdloan( else: if crowdloan_type == "subnet": message = "Subnet lease crowdloan created successfully." + print_success(message) console.print( - f"\n:white_check_mark: [green]{message}[/green]\n" f" Type: [magenta]Subnet Leasing[/magenta]\n" f" Emissions Share: [cyan]{emissions_share}%[/cyan]\n" f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" @@ -427,10 +506,25 @@ 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) console.print( - f"\n:white_check_mark: [green]{message}[/green]\n" f" Type: [cyan]General Fundraising[/cyan]\n" f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" f" Min contribution: [{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]\n" @@ -489,7 +583,7 @@ async def finalize_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, error_msg if wallet.coldkeypub.ss58_address != crowdloan.creator: @@ -499,7 +593,7 @@ async def finalize_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Only the creator can finalize a crowdloan." if crowdloan.finalized: @@ -507,7 +601,7 @@ async def finalize_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Crowdloan is already finalized." if crowdloan.raised < crowdloan.cap: @@ -520,9 +614,9 @@ async def finalize_crowdloan( json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error( - f"[red]Crowdloan #{crowdloan_id} has not reached its cap.\n" + f"Crowdloan #{crowdloan_id} has not reached its cap.\n" f"Raised: {crowdloan.raised}, Cap: {crowdloan.cap}\n" - f"Still needed: {still_needed.tao}[/red]" + f"Still needed: {still_needed.tao}" ) return False, "Crowdloan has not reached its cap." diff --git a/bittensor_cli/src/commands/crowd/utils.py b/bittensor_cli/src/commands/crowd/utils.py index 4ad7895e5..22aa109c4 100644 --- a/bittensor_cli/src/commands/crowd/utils.py +++ b/bittensor_cli/src/commands/crowd/utils.py @@ -1,8 +1,97 @@ +import json from typing import Optional from async_substrate_interface.types import Runtime +from rich.prompt import Prompt from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import console, json_console, print_error + + +async def prompt_custom_call_params( + subtensor: SubtensorInterface, + json_output: bool = False, +) -> tuple[bool, Optional[str], Optional[str], Optional[str], Optional[str]]: + """ + Prompt user for custom call parameters (pallet, method, and JSON args) + and validate that the call can be composed. + + Args: + subtensor: SubtensorInterface instance for call validation + json_output: Whether to output errors as JSON + + Returns: + Tuple of (success, pallet_name, method_name, args_json, error_msg) + On success: (True, pallet, method, args, None) + On failure: (False, None, None, None, error_msg) + """ + if not json_output: + console.print( + "\n[bold cyan]Custom Call Parameters[/bold cyan]\n" + "[dim]You'll need to provide a pallet (module) name, method name, and optional JSON arguments.\n\n" + "[yellow]Examples:[/yellow]\n" + " • Pallet: [cyan]SubtensorModule[/cyan], [cyan]Balances[/cyan], [cyan]System[/cyan]\n" + " • Method: [cyan]transfer_allow_death[/cyan], [cyan]transfer_keep_alive[/cyan], [cyan]transfer_all[/cyan]\n" + ' • Args: [cyan]{"dest": "5D...", "value": 1000000000}[/cyan] or [cyan]{}[/cyan] for empty\n' + ) + + pallet = Prompt.ask("Enter pallet name") + if not pallet.strip(): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Pallet name cannot be empty."}) + ) + else: + print_error("[red]Pallet name cannot be empty.[/red]") + return await prompt_custom_call_params(subtensor, json_output) + + method = Prompt.ask("Enter method name") + if not method.strip(): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Method name cannot be empty."}) + ) + else: + print_error("[red]Method name cannot be empty.[/red]") + return await prompt_custom_call_params(subtensor, json_output) + + args_input = Prompt.ask( + "Enter custom call arguments as JSON [dim](or press Enter for empty: {})[/dim]", + default="{}", + ) + + try: + call_params = json.loads(args_input) + except json.JSONDecodeError as e: + error_msg = f"Invalid JSON: {e}" + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + print_error( + '[yellow]Please try again. Example: {"param1": "value", "param2": 123}[/yellow]' + ) + return await prompt_custom_call_params(subtensor, json_output) + + call, error_msg = await subtensor.compose_custom_crowdloan_call( + pallet_name=pallet, + method_name=method, + call_params=call_params, + ) + if call is None: + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]Failed to compose call: {error_msg}[/red]") + console.print( + "[yellow]Please check:\n" + " • Pallet name exists in runtime\n" + " • Method name exists in the pallet\n" + " • Arguments match the method's expected parameters[/yellow]\n" + ) + return await prompt_custom_call_params(subtensor, json_output) + + return True, pallet, method, args_input, None async def get_constant( diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 9a248d18f..20ed82935 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/bittensor_cli/src/commands/proxy.py b/bittensor_cli/src/commands/proxy.py index 9f3c403d0..8852fedf1 100644 --- a/bittensor_cli/src/commands/proxy.py +++ b/bittensor_cli/src/commands/proxy.py @@ -13,6 +13,7 @@ json_console, console, print_error, + print_success, unlock_key, ProxyAddressBook, is_valid_ss58_address_prompt, @@ -95,7 +96,7 @@ async def submit_proxy( ) else: await print_extrinsic_id(receipt) - console.print(":white_check_mark:[green]Success![/green]") + print_success("Success!") else: if json_output: json_console.print_json( @@ -627,7 +628,7 @@ async def execute_announced( ) inner_call.process() except StateDiscardedError: - err_console.print( + print_error( "The state has already been discarded for this block " "(you are likely not using an archive node endpoint)" ) @@ -645,8 +646,8 @@ async def execute_announced( ) inner_call.process() except Exception as e: - err_console.print( - f":cross_mark:[red]Failure[/red]Unable to regenerate the call data using the latest runtime: {e}\n" + print_error( + f"Failure: Unable to regenerate the call data using the latest runtime: {e}\n" "You should rerun this command on an archive node endpoint." ) if json_output: @@ -687,7 +688,7 @@ async def execute_announced( } ) else: - console.print(":white_check_mark:[green]Success![/green]") + print_success("Success!") await print_extrinsic_id(receipt) else: if json_output: diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 18c6578eb..275049be4 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -20,6 +20,7 @@ get_hotkey_wallets_for_wallet, is_valid_ss58_address, print_error, + print_success, print_verbose, unlock_key, json_console, @@ -192,9 +193,8 @@ async def safe_stake_extrinsic( block_hash=block_hash, ), ) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Finalized. " - f"Stake added to netuid: {netuid_}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Finalized. Stake added to netuid: {netuid_}[/dark_sea_green3]" ) console.print( f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " @@ -287,8 +287,7 @@ async def stake_extrinsic( block_hash=new_block_hash, ), ) - console.print( - f":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" ) console.print( diff --git a/bittensor_cli/src/commands/stake/auto_staking.py b/bittensor_cli/src/commands/stake/auto_staking.py index 9fb10847a..3d7888321 100644 --- a/bittensor_cli/src/commands/stake/auto_staking.py +++ b/bittensor_cli/src/commands/stake/auto_staking.py @@ -11,6 +11,7 @@ confirm_action, console, json_console, + print_success, get_subnet_name, is_valid_ss58_address, print_error, @@ -296,8 +297,8 @@ async def set_auto_stake_destination( if success: await print_extrinsic_id(ext_receipt) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Auto-stake destination set for netuid {netuid}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Auto-stake destination set for netuid {netuid}[/dark_sea_green3]" ) return True diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index d4fb17998..69d4083e4 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -16,6 +16,7 @@ print_error, float_to_u16, float_to_u64, + print_success, u16_to_float, u64_to_float, is_valid_ss58_address, @@ -145,7 +146,7 @@ async def set_children_extrinsic( await print_extrinsic_id(ext_receipt) modifier = "included" if wait_for_finalization: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") modifier = "finalized" return True, f"{operation} successfully {modifier}.", ext_id else: @@ -236,7 +237,7 @@ async def set_childkey_take_extrinsic( modifier = "included" if wait_for_finalization: modifier = "finalized" - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") return True, f"Successfully {modifier} childkey take", ext_id else: print_error(f"Failed: {error_message}") @@ -569,11 +570,9 @@ async def set_children( f"Your childkey request has been submitted. It will be completed around block {completion_block}. " f"The current block is {current_block}" ) - console.print( - ":white_heavy_check_mark: [green]Set children hotkeys.[/green]" - ) + print_success("Set children hotkeys.") else: - console.print(f"Unable to set children hotkeys. {message}") + print_error(f"Unable to set children hotkeys. {message}") else: # set children on all subnets that parent is registered on netuids = await subtensor.get_all_subnet_netuids() @@ -606,9 +605,7 @@ async def set_children( f"Your childkey request for netuid {netuid_} has been submitted. It will be completed around " f"block {completion_block}. The current block is {current_block}." ) - console.print( - ":white_heavy_check_mark: [green]Sent set children request for all subnets.[/green]" - ) + print_success("Sent set children request for all subnets.") if json_output: json_console.print(json.dumps(successes)) @@ -784,7 +781,7 @@ async def set_chk_take_subnet( ) # Result if success_: - console.print(":white_heavy_check_mark: [green]Set childkey take.[/green]") + print_success("Set childkey take.") console.print( f"The childkey take for {get_hotkey_pub_ss58(wallet)} is now set to {take * 100:.2f}%." ) @@ -864,7 +861,5 @@ async def set_chk_take_subnet( wait_for_finalization=False, ) output_list.append((netuid_, result, ext_id)) - console.print( - f":white_heavy_check_mark: [green]Sent childkey take of {take * 100:.2f}% to all subnets.[/green]" - ) + print_success(f"Sent childkey take of {take * 100:.2f}% to all subnets.") return output_list diff --git a/bittensor_cli/src/commands/stake/claim.py b/bittensor_cli/src/commands/stake/claim.py index d07f4715b..2f225f135 100644 --- a/bittensor_cli/src/commands/stake/claim.py +++ b/bittensor_cli/src/commands/stake/claim.py @@ -15,6 +15,7 @@ confirm_action, console, print_error, + print_success, unlock_key, print_extrinsic_id, json_console, @@ -209,7 +210,7 @@ async def set_claim_type( if success: ext_id = await ext_receipt.get_extrinsic_identifier() msg = "Successfully changed claim type" - console.print(f":white_heavy_check_mark: [green]{msg}[/green]") + print_success(msg) await print_extrinsic_id(ext_receipt) if json_output: json_console.print( diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index d4a087970..a2a554578 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -539,7 +539,7 @@ def format_cell( current_block = None previous_data = None - with Live(console=console, screen=True, auto_refresh=True) as live: + with Live(console=console, auto_refresh=True) as live: try: while True: block_hash = await subtensor.substrate.get_chain_head() diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index c106ddfbc..f28d689f2 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -19,6 +19,7 @@ print_error, group_subnets, get_subnet_name, + print_success, unlock_key, get_hotkey_pub_ss58, print_extrinsic_id, @@ -722,12 +723,10 @@ async def move_stake( return False, "" await print_extrinsic_id(response) if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") + print_success("Sent") return True, ext_id else: - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Stake moved.[/dark_sea_green3]" - ) + print_success("[dark_sea_green3]Stake moved.[/dark_sea_green3]") block_hash = await subtensor.substrate.get_chain_head() ( new_origin_stake_balance, @@ -945,7 +944,7 @@ async def transfer_stake( await print_extrinsic_id(response) ext_id = await response.get_extrinsic_identifier() if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") + print_success("Sent") return True, ext_id else: # Get and display new stake balances @@ -1184,7 +1183,7 @@ async def swap_stake( return False, "" await print_extrinsic_id(response) if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") + print_success("Sent") return True, await response.get_extrinsic_identifier() else: # Get and display new stake balances diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 1cc7116a5..eb930a60e 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -18,6 +18,7 @@ from bittensor_cli.src.bittensor.utils import ( confirm_action, console, + print_success, print_verbose, print_error, get_hotkey_wallets_for_wallet, @@ -671,7 +672,7 @@ async def _unstake_extrinsic( ), ) - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") console.print( f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_balance}" ) @@ -785,7 +786,7 @@ async def _safe_unstake_extrinsic( ), ) - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") console.print( f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_balance}" ) diff --git a/bittensor_cli/src/commands/subnets/mechanisms.py b/bittensor_cli/src/commands/subnets/mechanisms.py index 2400be707..195820c56 100644 --- a/bittensor_cli/src/commands/subnets/mechanisms.py +++ b/bittensor_cli/src/commands/subnets/mechanisms.py @@ -16,6 +16,7 @@ json_console, U16_MAX, print_extrinsic_id, + print_success, ) if TYPE_CHECKING: @@ -294,7 +295,7 @@ async def set_emission_split( return False if normalized_weights == existing_split: - message = ":white_heavy_check_mark: [dark_sea_green3]Emission split unchanged.[/dark_sea_green3]" + message = "[dark_sea_green3]Emission split unchanged.[/dark_sea_green3]" if json_output: json_console.print_json( data={ @@ -306,7 +307,7 @@ async def set_emission_split( } ) else: - console.print(message) + print_success(message) return True if not json_output: @@ -462,8 +463,7 @@ async def set_mechanism_count( if success: await print_extrinsic_id(ext_receipt) - console.print( - ":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Mechanism count set to {mechanism_count} for subnet {netuid}[/dark_sea_green3]" ) else: @@ -506,8 +506,7 @@ async def set_mechanism_emission( if success: await print_extrinsic_id(ext_receipt) - console.print( - ":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Emission split updated for subnet {netuid}[/dark_sea_green3]" ) else: diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index ad0404b4f..8130421da 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -30,6 +30,7 @@ confirm_action, console, create_and_populate_table, + print_success, print_verbose, print_error, get_metadata_table, @@ -297,8 +298,8 @@ async def _find_event_attributes_in_extrinsic_receipt( "" ) else: - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" + print_success( + f"[dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" ) return True, int(attributes[0]), ext_id @@ -933,7 +934,7 @@ def format_liquidity_cell( current_block = None previous_data = None - with Live(console=console, screen=True, auto_refresh=True) as live: + with Live(console=console, auto_refresh=True) as live: try: while True: ( @@ -2626,9 +2627,7 @@ async def set_identity( return False, None ext_id = await ext_receipt.get_extrinsic_identifier() await print_extrinsic_id(ext_receipt) - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Successfully set subnet identity\n" - ) + print_success("[dark_sea_green3]Successfully set subnet identity\n") subnet = await subtensor.subnet(netuid) identity = subnet.subnet_identity if subnet else None @@ -2725,7 +2724,7 @@ async def get_start_schedule( ), subtensor.substrate.get_constant( module_name="SubtensorModule", - constant_name="DurationOfStartCall", + constant_name="InitialStartCallDelay", block_hash=block_hash, ), subtensor.substrate.get_block_number(block_hash=block_hash), @@ -2811,9 +2810,7 @@ async def start_subnet( if success: await print_extrinsic_id(response) - console.print( - f":white_heavy_check_mark: [green]Successfully started subnet {netuid}'s emission schedule.[/green]" - ) + print_success(f"Successfully started subnet {netuid}'s emission schedule.") return True else: if "FirstEmissionBlockNumberAlreadySet" in error_msg: @@ -2895,7 +2892,7 @@ async def set_symbol( } ) else: - console.print(f":white_heavy_check_mark:[dark_sea_green3] {message}\n") + print_success(f"[dark_sea_green3] {message}\n") return True else: if json_output: diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index b039ea4f2..ec6d1461c 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -23,6 +23,7 @@ confirm_action, console, print_error, + print_success, print_verbose, normalize_hyperparameters, unlock_key, @@ -410,15 +411,13 @@ async def set_hyperparameter_extrinsic( ext_id = await ext_receipt.get_extrinsic_identifier() await print_extrinsic_id(ext_receipt) if arbitrary_extrinsic: - console.print( - f":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Hyperparameter {parameter} values changed to {call_params}[/dark_sea_green3]" ) return True, "", ext_id # Successful registration, final check for membership else: - console.print( - f":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Hyperparameter {parameter} changed to {value}[/dark_sea_green3]" ) return True, "", ext_id @@ -641,7 +640,7 @@ async def vote_senate_extrinsic( vote_data.ayes.count(hotkey_ss58) > 0 or vote_data.nays.count(hotkey_ss58) > 0 ): - console.print(":white_heavy_check_mark: [green]Vote cast.[/green]") + print_success("Vote cast.") return True else: # hotkey not found in ayes/nays @@ -729,9 +728,7 @@ async def set_take_extrinsic( print_error(err) ext_id = None else: - console.print( - ":white_heavy_check_mark: [dark_sea_green_3]Success[/dark_sea_green_3]" - ) + print_success("Success") ext_id = await ext_receipt.get_extrinsic_identifier() await print_extrinsic_id(ext_receipt) return success, ext_id @@ -1293,7 +1290,5 @@ async def trim( ) else: await print_extrinsic_id(ext_receipt) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]{msg}[/dark_sea_green3]" - ) + print_success(f"[dark_sea_green3]{msg}[/dark_sea_green3]") return True diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 96c812be5..216d7bf10 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -37,7 +37,6 @@ confirm_action, console, convert_blocks_to_time, - err_console, json_console, print_error, print_verbose, @@ -137,9 +136,7 @@ async def associate_hotkey( ) if not success: - console.print( - f"[red]:cross_mark: Failed to associate hotkey: {err_msg}[/red]" - ) + print_error(f"Failed to associate hotkey: {err_msg}") return False console.print( diff --git a/bittensor_cli/src/commands/weights.py b/bittensor_cli/src/commands/weights.py index a12f5b659..3fa0135c3 100644 --- a/bittensor_cli/src/commands/weights.py +++ b/bittensor_cli/src/commands/weights.py @@ -12,6 +12,7 @@ from bittensor_cli.src.bittensor.utils import ( confirm_action, print_error, + print_success, console, format_error_message, json_console, @@ -181,9 +182,7 @@ async def _commit_reveal( reveal_time = (current_time + timedelta(seconds=interval)).isoformat() cli_retry_cmd = f"--netuid {self.netuid} --uids {weight_uids} --weights {self.weights} --reveal-using-salt {self.salt}" # Print params to screen and notify user this is a blocking operation - console.print( - ":white_heavy_check_mark: [green]Weights hash committed to chain[/green]" - ) + print_success("Weights hash committed to chain") console.print( f":alarm_clock: [dark_orange3]Weights hash will be revealed at {reveal_time}[/dark_orange3]" ) @@ -227,9 +226,7 @@ async def reveal(self, weight_uids, weight_vals) -> tuple[bool, str, Optional[st if not self.wait_for_finalization and not self.wait_for_inclusion: return True, "Not waiting for finalization or inclusion.", ext_id - console.print( - ":white_heavy_check_mark: [green]Weights hash revealed on chain[/green]" - ) + print_success("Weights hash revealed on chain") return ( True, "Successfully revealed previously committed weights hash.", @@ -284,7 +281,7 @@ async def _do_set_weights() -> tuple[bool, str, Optional[str]]: return True, "Not waiting for finalization or inclusion.", None if success: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") # bittensor.logging.success(prefix="Set weights", suffix="Finalized: " + str(success)) return True, "Successfully set weights and finalized.", ext_id else: diff --git a/pyproject.toml b/pyproject.toml index faa1b37d8..3ec9af78e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.17.0" +version = "9.18.0" description = "Bittensor CLI" readme = "README.md" authors = [ @@ -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_liquidity.py b/tests/e2e_tests/test_liquidity.py index e97e1b6b4..a5c38ccc8 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -1,3 +1,4 @@ +import pytest import asyncio import json import time @@ -14,6 +15,7 @@ """ +@pytest.mark.skip(reason="User liquidity currently disabled on chain") def test_liquidity(local_chain, wallet_setup): wallet_path_alice = "//Alice" netuid = 2