diff --git a/CHANGELOG.md b/CHANGELOG.md index 99297d0a7..120aee876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # 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 +* Tests: Adds e2e tests for crowdloan functionality by @ibraheem-abe in https://github.com/opentensor/btcli/pull/806 +* Fix: Mech count indication + missing params by @ibraheem-abe in https://github.com/opentensor/btcli/pull/807 + +## 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..72faeb5e9 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) @@ -4140,6 +4163,7 @@ def wallet_swap_coldkey( network: Optional[list[str]] = Options.network, proxy: Optional[str] = Options.proxy, announce_only: bool = Options.announce_only, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, force_swap: bool = typer.Option( @@ -4214,6 +4238,8 @@ def wallet_swap_coldkey( subtensor=self.initialize_chain(network), new_coldkey_ss58=new_wallet_coldkey_ss58, force_swap=force_swap, + decline=decline, + quiet=quiet, proxy=proxy, ) ) @@ -4682,7 +4708,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 +5030,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 +5371,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 +5597,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 +5808,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 +5840,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" @@ -5914,6 +5964,8 @@ def stake_wizard( era=period, interactive_selection=False, prompt=prompt, + decline=decline, + quiet=quiet, mev_protection=mev_protection, ) ) @@ -6001,6 +6053,7 @@ def stake_set_claim_type( proxy: Optional[str] = Options.proxy, announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -6043,6 +6096,8 @@ def stake_set_claim_type( netuids=netuids, proxy=proxy, prompt=prompt, + decline=decline, + quiet=quiet, json_output=json_output, ) ) @@ -6057,6 +6112,7 @@ def stake_process_claim( proxy: Optional[str] = Options.proxy, announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -6110,6 +6166,8 @@ def stake_process_claim( netuids=parsed_netuids, proxy=proxy, prompt=prompt, + decline=decline, + quiet=quiet, json_output=json_output, verbose=verbose, ) @@ -6470,6 +6528,7 @@ def mechanism_count_set( wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, prompt: bool = Options.prompt, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -6517,18 +6576,17 @@ def mechanism_count_set( mechanism_count = IntPrompt.ask(prompt_text) if mechanism_count == current_count: - visible_count = max(mechanism_count - 1, 0) message = ( ":white_heavy_check_mark: " - f"[dark_sea_green3]Subnet {netuid} already has {visible_count} mechanism" - f"{'s' if visible_count != 1 else ''}.[/dark_sea_green3]" + f"[dark_sea_green3]Subnet {netuid} already has {mechanism_count} mechanism" + f"{'s' if mechanism_count != 1 else ''}.[/dark_sea_green3]" ) if json_output: json_console.print( json.dumps( { "success": True, - "message": f"Subnet {netuid} already has {visible_count} mechanisms.", + "message": f"Subnet {netuid} already has {mechanism_count} mechanisms.", "extrinsic_identifier": None, } ) @@ -6563,6 +6621,8 @@ def mechanism_count_set( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, json_output=json_output, + quiet=quiet, + decline=decline, ) ) @@ -6621,6 +6681,7 @@ def mechanism_emission_set( wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, prompt: bool = Options.prompt, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -6660,6 +6721,8 @@ def mechanism_emission_set( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, prompt=prompt, + decline=decline, + quiet=quiet, json_output=json_output, ) ) @@ -7559,6 +7622,7 @@ def subnets_create( mev_protection: bool = Options.mev_protection, json_output: bool = Options.json_output, prompt: bool = Options.prompt, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): @@ -7581,6 +7645,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, @@ -7614,6 +7683,8 @@ def subnets_create( proxy=proxy, json_output=json_output, prompt=prompt, + decline=decline, + quiet=quiet, mev_protection=mev_protection, ) ) @@ -7648,6 +7719,7 @@ def subnets_start( announce_only: bool = Options.announce_only, netuid: int = Options.netuid, prompt: bool = Options.prompt, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): @@ -7684,6 +7756,8 @@ def subnets_start( netuid=netuid, proxy=proxy, prompt=prompt, + decline=decline, + quiet=quiet, ) ) @@ -7746,6 +7820,7 @@ def subnets_set_identity( ), json_output: bool = Options.json_output, prompt: bool = Options.prompt, + decline: bool = Options.decline, quiet: bool = Options.quiet, verbose: bool = Options.verbose, ): @@ -7806,6 +7881,8 @@ def subnets_set_identity( netuid=netuid, subnet_identity=identity, prompt=prompt, + decline=decline, + quiet=quiet, proxy=proxy, ) ) @@ -8698,6 +8775,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 +8809,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 +8832,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 +8856,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 +8902,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 +9011,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 +9038,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 +9050,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 +9077,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 +9670,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 +9838,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 +9968,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..b6f68c6a8 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,13 +407,14 @@ 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 user_contribution, user_balance = await asyncio.gather( subtensor.get_crowdloan_contribution( crowdloan_id, + contributor_address, ), subtensor.get_balance(contributor_address), ) @@ -424,7 +426,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 +437,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 +536,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 +563,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 +602,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_crowdloans.py b/tests/e2e_tests/test_crowdloans.py new file mode 100644 index 000000000..404ba4fe7 --- /dev/null +++ b/tests/e2e_tests/test_crowdloans.py @@ -0,0 +1,1987 @@ +""" +E2E tests for crowdloan creation, contribution, and verification. + +Verify commands: +* btcli crowd create (all 3 types: fundraising, subnet leasing, custom call) +* btcli crowd info +* btcli crowd contribute +* btcli crowd list +* btcli crowd withdraw +* btcli crowd finalize +* btcli crowd update +* btcli crowd refund +* btcli crowd dissolve +""" + +import json + + +def get_crowdloan_id_by_creator(exec_command, creator_address: str) -> int: + """Helper to retrieve crowdloan_id from crowd list by creator address.""" + result = exec_command( + command="crowd", + sub_command="list", + extra_args=[ + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + list_output = json.loads(result.stdout) + assert list_output.get("success") is True, ( + f"Failed to list crowdloans: {result.stdout}" + ) + + crowdloans = list_output.get("data", {}).get("crowdloans", []) + matching_loans = [ + loan for loan in crowdloans if loan.get("creator") == creator_address + ] + assert len(matching_loans) > 0, f"No crowdloan found for creator {creator_address}" + + return max(loan["id"] for loan in matching_loans) + + +def test_all_crowdloan_types(local_chain, wallet_setup): + """ + Test for all 3 crowdloan types: + 1. General Fundraising - with target address + 2. Subnet Leasing - with emissions share + 3. Custom Call - with pallet/method/args + + For each type: + - Create the crowdloan + - Verify creation via crowd info JSON output + - Contribute to it + - Verify contributors and amounts via crowd info --show-contributors JSON output + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + # Create wallets + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + _, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + + alice_address = wallet_alice.coldkeypub.ss58_address + bob_address = wallet_bob.coldkeypub.ss58_address + + # ======================================================================== + # Test 1: General Fundraising Crowdloan + # ======================================================================== + print("\n🧪 Testing General Fundraising Crowdloan...") + + # Create fundraising crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True, ( + f"Failed to create fundraising crowdloan: {create_result.stdout}" + ) + assert create_output["data"]["type"] == "fundraising" + assert create_output["data"]["deposit"] == 10.0 + assert create_output["data"]["cap"] == 100.0 + assert create_output["data"]["min_contribution"] == 1.0 + assert create_output["data"]["target_address"] == bob_address + assert "extrinsic_id" in create_output["data"] + + fundraising_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Verify initial state via crowd info + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(fundraising_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + info_output = json.loads(info_result.stdout) + assert info_output.get("success") is True, ( + f"Failed to get crowdloan info: {info_result.stdout}" + ) + assert info_output["data"]["crowdloan_id"] == fundraising_id + assert info_output["data"]["status"] == "Active" + assert info_output["data"]["creator"] == alice_address + assert info_output["data"]["target_address"] == bob_address + assert info_output["data"]["has_call"] is False + assert info_output["data"]["raised"] == 10.0 + assert info_output["data"]["contributors_count"] == 1 + + # Contribute to fundraising crowdloan + contribute_result = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(fundraising_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "20", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is True, ( + f"Failed to contribute: {contribute_result.stdout}" + ) + assert contribute_output["data"]["crowdloan_id"] == fundraising_id + assert contribute_output["data"]["contribution_amount"] == 20.0 + assert contribute_output["data"]["contributor"] == bob_address + assert ( + contribute_output["data"]["crowdloan"]["raised_after"] + > contribute_output["data"]["crowdloan"]["raised_before"] + ) + + # Verify contributors via crowd info --show-contributors + info_with_contributors = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(fundraising_id), + "--network", + "ws://127.0.0.1:9945", + "--show-contributors", + "--json-output", + ], + ) + + info_contrib_output = json.loads(info_with_contributors.stdout) + assert info_contrib_output.get("success") is True + assert info_contrib_output["data"]["contributors_count"] == 2 + assert ( + info_contrib_output["data"]["raised"] == 30.0 + ) # Deposit (10) + Bob's contribution (20) + assert "contributors" in info_contrib_output["data"] + assert len(info_contrib_output["data"]["contributors"]) == 2 + + bob_contributor = info_contrib_output["data"]["contributors"][0] + assert bob_contributor["address"] == bob_address + assert bob_contributor["contribution_tao"] == 20.0 + assert bob_contributor["rank"] == 1 + assert ( + abs(bob_contributor["percentage"] - (20.0 / 30.0 * 100)) < 0.01 + ) # Bob: 20/30 = 66.67% + + # Verify creator (Alice) is also in contributors list + alice_contributor = info_contrib_output["data"]["contributors"][1] + assert alice_contributor["address"] == alice_address + assert alice_contributor["contribution_tao"] == 10.0 + assert alice_contributor["rank"] == 2 + assert ( + abs(alice_contributor["percentage"] - (10.0 / 30.0 * 100)) < 0.01 + ) # Alice: 10/30 = 33.33% + + print("✅ Fundraising crowdloan test passed") + + # ======================================================================== + # Test 2: Subnet Leasing Crowdloan + # ======================================================================== + print("\n🧪 Testing Subnet Leasing Crowdloan...") + + # Create subnet leasing crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--subnet-lease", + "--emissions-share", + "25", + "--lease-end-block", + "100_000", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True, ( + f"Failed to create subnet crowdloan: {create_result.stdout}" + ) + assert create_output["data"]["type"] == "subnet" + assert create_output["data"]["deposit"] == 10.0 + assert create_output["data"]["cap"] == 100.0 + assert create_output["data"]["emissions_share"] == 25 + assert "extrinsic_id" in create_output["data"] + + subnet_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Verify initial state via crowd info + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(subnet_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + info_output = json.loads(info_result.stdout) + assert info_output.get("success") is True + assert info_output["data"]["crowdloan_id"] == subnet_id + assert info_output["data"]["status"] == "Active" + assert info_output["data"]["creator"] == alice_address + assert info_output["data"]["has_call"] is True + assert info_output["data"]["call_details"] is not None + assert info_output["data"]["call_details"]["type"] == "Subnet Leasing" + assert info_output["data"]["call_details"]["emissions_share"] == 25 + assert info_output["data"]["raised"] == 10.0 + assert info_output["data"]["contributors_count"] == 1 + + # Contribute to subnet crowdloan + contribute_result = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(subnet_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "30", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is True + assert contribute_output["data"]["crowdloan_id"] == subnet_id + assert contribute_output["data"]["contribution_amount"] == 30.0 + assert contribute_output["data"]["contributor"] == bob_address + + # Verify contributors via crowd info --show-contributors + info_with_contributors = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(subnet_id), + "--network", + "ws://127.0.0.1:9945", + "--show-contributors", + "--json-output", + ], + ) + + info_contrib_output = json.loads(info_with_contributors.stdout) + assert info_contrib_output.get("success") is True + assert info_contrib_output["data"]["contributors_count"] == 2 + assert ( + info_contrib_output["data"]["raised"] == 40.0 + ) # Deposit (10) + Bob's contribution (30) + assert "contributors" in info_contrib_output["data"] + assert len(info_contrib_output["data"]["contributors"]) == 2 + + bob_contributor = info_contrib_output["data"]["contributors"][0] + assert bob_contributor["address"] == bob_address + assert bob_contributor["contribution_tao"] == 30.0 + assert bob_contributor["rank"] == 1 + assert ( + abs(bob_contributor["percentage"] - (30.0 / 40.0 * 100)) < 0.01 + ) # Bob: 30/40 = 75% + + # Verify creator (Alice) is also in contributors list + alice_contributor = info_contrib_output["data"]["contributors"][1] + assert alice_contributor["address"] == alice_address + assert alice_contributor["contribution_tao"] == 10.0 + assert alice_contributor["rank"] == 2 + assert ( + abs(alice_contributor["percentage"] - (10.0 / 40.0 * 100)) < 0.01 + ) # Alice: 10/40 = 25% + + print("✅ Subnet leasing crowdloan test passed") + + # ======================================================================== + # Test 3: Custom Call Crowdloan + # ======================================================================== + print("\n🧪 Testing Custom Call Crowdloan...") + + # Create custom call crowdloan + custom_call_args = json.dumps({"dest": bob_address, "value": 1000000000}) + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--custom-call-pallet", + "Balances", + "--custom-call-method", + "transfer_allow_death", + "--custom-call-args", + custom_call_args, + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True, ( + f"Failed to create custom call crowdloan: {create_result.stdout}" + ) + assert create_output["data"]["type"] == "custom" + assert create_output["data"]["deposit"] == 10.0 + assert create_output["data"]["cap"] == 100.0 + assert "custom_call" in create_output["data"] + assert create_output["data"]["custom_call"]["pallet"] == "Balances" + assert create_output["data"]["custom_call"]["method"] == "transfer_allow_death" + assert "extrinsic_id" in create_output["data"] + + custom_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Verify initial state via crowd info + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(custom_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + info_output = json.loads(info_result.stdout) + assert info_output.get("success") is True + assert info_output["data"]["crowdloan_id"] == custom_id + assert info_output["data"]["status"] == "Active" + assert info_output["data"]["creator"] == alice_address + assert info_output["data"]["has_call"] is True + assert info_output["data"]["call_details"] is not None + assert info_output["data"]["call_details"]["pallet"] == "Balances" + assert info_output["data"]["call_details"]["method"] == "transfer_allow_death" + assert info_output["data"]["raised"] == 10.0 + assert info_output["data"]["contributors_count"] == 1 + + # Contribute to custom call crowdloan + contribute_result = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(custom_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "25", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is True + assert contribute_output["data"]["crowdloan_id"] == custom_id + assert contribute_output["data"]["contribution_amount"] == 25.0 + assert contribute_output["data"]["contributor"] == bob_address + + # Verify contributors via crowd info --show-contributors + info_with_contributors = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(custom_id), + "--network", + "ws://127.0.0.1:9945", + "--show-contributors", + "--json-output", + ], + ) + + info_contrib_output = json.loads(info_with_contributors.stdout) + assert info_contrib_output.get("success") is True + assert info_contrib_output["data"]["contributors_count"] == 2 + assert ( + info_contrib_output["data"]["raised"] == 35.0 + ) # Deposit (10) + Bob's contribution (25) + assert "contributors" in info_contrib_output["data"] + assert len(info_contrib_output["data"]["contributors"]) == 2 + + bob_contributor = info_contrib_output["data"]["contributors"][0] + assert bob_contributor["address"] == bob_address + assert bob_contributor["contribution_tao"] == 25.0 + assert bob_contributor["rank"] == 1 + assert ( + abs(bob_contributor["percentage"] - (25.0 / 35.0 * 100)) < 0.01 + ) # Bob: 25/35 = 71.43% + + # Verify creator (Alice) is also in contributors list + alice_contributor = info_contrib_output["data"]["contributors"][1] + assert alice_contributor["address"] == alice_address + assert alice_contributor["contribution_tao"] == 10.0 + assert alice_contributor["rank"] == 2 + assert ( + abs(alice_contributor["percentage"] - (10.0 / 35.0 * 100)) < 0.01 + ) # Alice: 10/35 = 28.57% + + print("✅ Custom call crowdloan test passed") + print("\n✅ All crowdloan type tests passed!") + + +def test_crowdloan_withdraw(local_chain, wallet_setup): + """ + Test withdrawal functionality: + - Non-creator withdrawal: Contributor withdraws full contribution + - Creator withdrawal: Creator withdraws amount above deposit (deposit must remain) + - Creator withdrawal failure: Creator cannot withdraw deposit amount + - Withdrawal from finalized crowdloan: Should fail + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + _, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + + alice_address = wallet_alice.coldkeypub.ss58_address + bob_address = wallet_bob.coldkeypub.ss58_address + + print("\n🧪 Testing Crowdloan Withdraw Functionality...") + + # Create a fundraising crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True + crowdloan_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Bob contributes + contribute_result = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "30", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is True + assert contribute_output["data"]["contribution_amount"] == 30.0 + + # Verify initial raised amount + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + initial_raised = info_output["data"]["raised"] + assert initial_raised == 40.0 # Deposit (10) + Bob's contribution (30) + + # Test 1: Non-creator (Bob) withdraws full contribution + withdraw_result = exec_command_bob( + command="crowd", + sub_command="withdraw", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + withdraw_output = json.loads(withdraw_result.stdout) + assert withdraw_output.get("success") is True + assert withdraw_output["data"]["withdrawal_amount"] == 30.0 + assert withdraw_output["data"]["is_creator"] is False + + # Verify new raised amount + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["raised"] < initial_raised + assert info_output["data"]["raised"] == 10.0 + assert info_output["data"]["contributors_count"] == 1 + + # Bob contributes again + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "20", + "--no-prompt", + "--json-output", + ], + ) + + # Alice (creator) contributes more than deposit + exec_command_alice( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--amount", + "15", + "--no-prompt", + "--json-output", + ], + ) + + # Test 2: Creator withdraws amount above deposit + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + + assert info_output["data"]["user_contribution"] is not None + creator_contribution = info_output["data"]["user_contribution"]["amount"] + assert creator_contribution == 25.0 + + # Creator withdraws amount above deposit + withdraw_result = exec_command_alice( + command="crowd", + sub_command="withdraw", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + withdraw_output = json.loads(withdraw_result.stdout) + assert withdraw_output.get("success") is True + assert withdraw_output["data"]["is_creator"] is True + assert withdraw_output["data"]["withdrawal_amount"] == creator_contribution - 10.0 + assert withdraw_output["data"]["deposit_locked"] == 10.0 + + # Test 3: Creator cannot withdraw deposit + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--wallet-path", + wallet_path_alice, + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + remaining_contribution = info_output["data"]["user_contribution"]["amount"] + assert remaining_contribution == 10.0 + + # Try to withdraw again (should fail) + withdraw_result = exec_command_alice( + command="crowd", + sub_command="withdraw", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + withdraw_output = json.loads(withdraw_result.stdout) + assert withdraw_output.get("success") is False + + print("✅ Crowdloan withdraw test passed") + + +def test_crowdloan_finalize(local_chain, wallet_setup): + """ + Test finalization functionality: + - Successful finalization: Finalize crowdloan that reached cap + - Finalization failure: Cannot finalize before cap is reached + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + _, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + + alice_address = wallet_alice.coldkeypub.ss58_address + bob_address = wallet_bob.coldkeypub.ss58_address + + print("\n🧪 Testing Crowdloan Finalize Functionality...") + + # Create a fundraising crowdloan with cap of 100 + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True + crowdloan_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Cannot finalize before cap is reached + finalize_result = exec_command_alice( + command="crowd", + sub_command="finalize", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + finalize_output = json.loads(finalize_result.stdout) + assert finalize_output.get("success") is False + assert "cap" in finalize_output.get("error", "").lower() + + # Bob contributes to reach cap + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "90", + "--no-prompt", + "--json-output", + ], + ) + + # Bob tries to finalize (should fail) + finalize_result = exec_command_bob( + command="crowd", + sub_command="finalize", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + finalize_output = json.loads(finalize_result.stdout) + assert finalize_output.get("success") is False + assert "creator" in finalize_output.get("error", "").lower() + + # Successful finalization by creator + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["raised"] >= info_output["data"]["cap"] + assert info_output["data"]["status"] == "Funded" + + finalize_result = exec_command_alice( + command="crowd", + sub_command="finalize", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + finalize_output = json.loads(finalize_result.stdout) + assert finalize_output.get("success") is True + assert finalize_output["data"]["crowdloan_id"] == crowdloan_id + + # Verify crowdloan is finalized + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["status"] == "Finalized" + assert info_output["data"]["finalized"] is True + + print("✅ Crowdloan finalize test passed") + + +def test_crowdloan_update(local_chain, wallet_setup): + """ + Test update functionality: + - Update min_contribution: Creator updates minimum contribution + - Update cap: Creator updates cap (must be >= raised amount) + - Update end_block: Creator extends crowdloan duration + - Update validation: Cannot update finalized crowdloan + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + _, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + + alice_address = wallet_alice.coldkeypub.ss58_address + bob_address = wallet_bob.coldkeypub.ss58_address + + print("\n🧪 Testing Crowdloan Update Functionality...") + + # Create a fundraising crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True + crowdloan_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Get initial values + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + initial_end_block = info_output["data"]["end_block"] + + # Test 1: Update min_contribution + update_result = exec_command_alice( + command="crowd", + sub_command="update", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--min-contribution", + "2", + "--no-prompt", + "--json-output", + ], + ) + + update_output = json.loads(update_result.stdout) + assert update_output.get("success") is True + assert update_output["data"]["update_type"] == "Minimum Contribution" + + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["min_contribution"] == 2.0 + + # Test 2: Update cap + update_result = exec_command_alice( + command="crowd", + sub_command="update", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--cap", + "150", + "--no-prompt", + "--json-output", + ], + ) + + update_output = json.loads(update_result.stdout) + assert update_output.get("success") is True + assert update_output["data"]["update_type"] == "Cap" + + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["cap"] == 150.0 + + # Test 3: Update end_block (extend duration) + new_end_block = initial_end_block + 5000 + update_result = exec_command_alice( + command="crowd", + sub_command="update", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--end-block", + str(new_end_block), + "--no-prompt", + "--json-output", + ], + ) + + update_output = json.loads(update_result.stdout) + assert update_output.get("success") is True + assert update_output["data"]["update_type"] == "End Block" + + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["end_block"] == new_end_block + print("✅ Crowdloan update test passed") + + +def test_crowdloan_refund(local_chain, wallet_setup): + """ + Test refund functionality: + - Refund contributors: Creator refunds contributors when crowdloan fails + - Refund validation: Cannot refund finalized crowdloan + - Refund verification: Verify contributors receive refunds + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + _, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + + alice_address = wallet_alice.coldkeypub.ss58_address + bob_address = wallet_bob.coldkeypub.ss58_address + + print("\n🧪 Testing Crowdloan Refund Functionality...") + + # Create a fundraising crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True + crowdloan_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Bob contributes + contribute_result = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "30", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is True + + # Test 1: Creator can refund + refund_result = exec_command_alice( + command="crowd", + sub_command="refund", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + refund_output = json.loads(refund_result.stdout) + assert refund_output.get("success") is True + assert refund_output["data"]["crowdloan_id"] == crowdloan_id + + # Verify contributors were refunded + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + # After refund, only creator's deposit should remain + assert info_output["data"]["contributors_count"] == 1 + assert info_output["data"]["raised"] == 10.0 + + # Test 2: Cannot refund finalized crowdloan + # Create new crowdloan and finalize it + exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + crowdloan_id2 = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "90", + "--no-prompt", + "--json-output", + ], + ) + + exec_command_alice( + command="crowd", + sub_command="finalize", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + refund_result = exec_command_alice( + command="crowd", + sub_command="refund", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + refund_output = json.loads(refund_result.stdout) + assert refund_output.get("success") is False + assert "finalized" in refund_output.get("error", "").lower() + + print("✅ Crowdloan refund test passed") + + +def test_crowdloan_dissolve(local_chain, wallet_setup): + """ + Test dissolve functionality: + - Successful dissolution: Dissolve after all contributors refunded + - Dissolution failure: Cannot dissolve when contributors still exist + - Dissolution failure: Cannot dissolve finalized crowdloan + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + _, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + + alice_address = wallet_alice.coldkeypub.ss58_address + bob_address = wallet_bob.coldkeypub.ss58_address + + print("\n🧪 Testing Crowdloan Dissolve Functionality...") + + # Create a fundraising crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True + crowdloan_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Test 1: Cannot dissolve when contributors still exist + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "30", + "--no-prompt", + "--json-output", + ], + ) + + dissolve_result = exec_command_alice( + command="crowd", + sub_command="dissolve", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + dissolve_output = json.loads(dissolve_result.stdout) + assert dissolve_output.get("success") is False + assert "contributors" in dissolve_output.get("error", "").lower() + + # Test 2: Successful dissolution after refunding all contributors + exec_command_alice( + command="crowd", + sub_command="refund", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + dissolve_result = exec_command_alice( + command="crowd", + sub_command="dissolve", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + dissolve_output = json.loads(dissolve_result.stdout) + assert dissolve_output.get("success") is True + assert dissolve_output["data"]["crowdloan_id"] == crowdloan_id + + # Test 3: Cannot dissolve finalized crowdloan + exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + crowdloan_id2 = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "90", + "--no-prompt", + "--json-output", + ], + ) + + exec_command_alice( + command="crowd", + sub_command="finalize", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + dissolve_result = exec_command_alice( + command="crowd", + sub_command="dissolve", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + dissolve_output = json.loads(dissolve_result.stdout) + assert dissolve_output.get("success") is False + assert "finalized" in dissolve_output.get("error", "").lower() + + print("✅ Crowdloan dissolve test passed") + + +def test_crowdloan_edge_cases(local_chain, wallet_setup): + """ + Test edge cases: + - Contribution above cap: Should auto-adjust to remaining cap + - Contribution below minimum: Should fail + - Contribution to finalized crowdloan: Should fail + - Contribution to crowdloan at cap: Should fail + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + wallet_path_charlie = "//Charlie" + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + _, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup(wallet_path_bob) + _, wallet_charlie, wallet_path_charlie, exec_command_charlie = wallet_setup( + wallet_path_charlie + ) + + alice_address = wallet_alice.coldkeypub.ss58_address + bob_address = wallet_bob.coldkeypub.ss58_address + + print("\n🧪 Testing Crowdloan Edge Cases...") + + # Create a fundraising crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "5", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + create_output = json.loads(create_result.stdout) + assert create_output.get("success") is True + crowdloan_id = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + # Test 1: Contribution below minimum should fail + contribute_result = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "2", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is False + assert "minimum" in contribute_output.get("error", "").lower() + + # Multiple contributions from same address + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "20", + "--no-prompt", + "--json-output", + ], + ) + + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "15", + "--no-prompt", + "--json-output", + ], + ) + + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--show-contributors", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + # Bob should have contributed 35 total + bob_contributions = [ + c for c in info_output["data"]["contributors"] if c["address"] == bob_address + ] + assert len(bob_contributions) == 1 + assert bob_contributions[0]["contribution_tao"] == 35.0 + + # Test 3: Multiple contributors + exec_command_charlie( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_charlie, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_charlie.name, + "--wallet-hotkey", + wallet_charlie.hotkey_str, + "--amount", + "25", + "--no-prompt", + "--json-output", + ], + ) + + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--show-contributors", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["contributors_count"] == 3 # Creator + Bob + Charlie + assert len(info_output["data"]["contributors"]) == 3 # Creator + Bob + Charlie + + # Test 4: Contribution above cap should auto-adjust + # Current raised: 10 (deposit) + 35 (Bob) + 25 (Charlie) = 70, cap = 100, remaining = 30 + contribute_result = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "50", # Would exceed cap, should adjust to 30 + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is True + assert contribute_output["data"]["adjusted"] is True + assert contribute_output["data"]["contribution_amount"] == 30.0 + + info_result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + info_output = json.loads(info_result.stdout) + assert info_output["data"]["raised"] >= info_output["data"]["cap"] + assert info_output["data"]["status"] == "Funded" + + # Test 5: Contribution to crowdloan at cap should fail + contribute_result = exec_command_charlie( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_charlie, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_charlie.name, + "--wallet-hotkey", + wallet_charlie.hotkey_str, + "--amount", + "10", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is False + assert "cap" in contribute_output.get("error", "").lower() + + # Test 6: Contribution to finalized crowdloan should fail + # Finalize the crowdloan + exec_command_alice( + command="crowd", + sub_command="finalize", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + # Create new crowdloan for finalize test + exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "5", + "--target-address", + bob_address, + "--fundraising", + "--no-prompt", + "--json-output", + ], + ) + + crowdloan_id2 = get_crowdloan_id_by_creator(exec_command_alice, alice_address) + + exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "90", + "--no-prompt", + "--json-output", + ], + ) + + exec_command_alice( + command="crowd", + sub_command="finalize", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--no-prompt", + "--json-output", + ], + ) + + contribute_result = exec_command_charlie( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id2), + "--wallet-path", + wallet_path_charlie, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_charlie.name, + "--wallet-hotkey", + wallet_charlie.hotkey_str, + "--amount", + "10", + "--no-prompt", + "--json-output", + ], + ) + + contribute_output = json.loads(contribute_result.stdout) + assert contribute_output.get("success") is False + assert "finalized" in contribute_output.get("error", "").lower() + + print("✅ Crowdloan edge cases test passed") 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