diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index a73aa2e8..5f94fe6d 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -67,6 +67,14 @@ from bittensor_cli.src.commands import sudo, wallets, view from bittensor_cli.src.commands import weights as weights_cmds from bittensor_cli.src.commands.liquidity import liquidity +from bittensor_cli.src.commands.crowd import ( + contribute as crowd_contribute, + create as create_crowdloan, + dissolve as crowd_dissolve, + view as view_crowdloan, + update as crowd_update, + refund as crowd_refund, +) from bittensor_cli.src.commands.liquidity.utils import ( prompt_liquidity, prompt_position_id, @@ -678,6 +686,7 @@ class CLIManager: subnets_app: typer.Typer subnet_mechanisms_app: typer.Typer weights_app: typer.Typer + crowd_app: typer.Typer utils_app: typer.Typer view_app: typer.Typer asyncio_runner = asyncio @@ -755,6 +764,7 @@ def __init__(self): self.weights_app = typer.Typer(epilog=_epilog) self.view_app = typer.Typer(epilog=_epilog) self.liquidity_app = typer.Typer(epilog=_epilog) + self.crowd_app = typer.Typer(epilog=_epilog) self.utils_app = typer.Typer(epilog=_epilog) # config alias @@ -1132,6 +1142,45 @@ def __init__(self): self.sudo_app.command("get_take", hidden=True)(self.sudo_get_take) self.sudo_app.command("set_take", hidden=True)(self.sudo_set_take) + # Crowdloan + self.app.add_typer( + self.crowd_app, + name="crowd", + short_help="Crowdloan commands, aliases: `cr`, `crowdloan`", + no_args_is_help=True, + ) + self.app.add_typer(self.crowd_app, name="cr", hidden=True, no_args_is_help=True) + self.app.add_typer( + self.crowd_app, name="crowdloan", hidden=True, no_args_is_help=True + ) + self.crowd_app.command( + "contribute", rich_help_panel=HELP_PANELS["CROWD"]["PARTICIPANT"] + )(self.crowd_contribute) + self.crowd_app.command( + "withdraw", rich_help_panel=HELP_PANELS["CROWD"]["PARTICIPANT"] + )(self.crowd_withdraw) + self.crowd_app.command( + "finalize", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_finalize) + self.crowd_app.command("list", rich_help_panel=HELP_PANELS["CROWD"]["INFO"])( + self.crowd_list + ) + self.crowd_app.command("info", rich_help_panel=HELP_PANELS["CROWD"]["INFO"])( + self.crowd_info + ) + self.crowd_app.command( + "create", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_create) + self.crowd_app.command( + "update", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_update) + self.crowd_app.command( + "refund", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_refund) + self.crowd_app.command( + "dissolve", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_dissolve) + # Liquidity self.app.add_typer( self.liquidity_app, @@ -7228,6 +7277,582 @@ def liquidity_modify( ) ) + def crowd_list( + self, + network: Optional[list[str]] = Options.network, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + List crowdloans together with their funding progress and key metadata. + + Shows every crowdloan on the selected network, including current status + (Active, Funded, Closed, Finalized), whether it is a subnet leasing crowdloan, + or a general fundraising crowdloan. + + Use `--verbose` for full-precision amounts and longer addresses. + + EXAMPLES + + [green]$[/green] btcli crowd list + + [green]$[/green] btcli crowd list --verbose + """ + self.verbosity_handler(quiet, verbose, json_output) + return self._run_command( + view_crowdloan.list_crowdloans( + subtensor=self.initialize_chain(network), + verbose=verbose, + json_output=json_output, + ) + ) + + def crowd_info( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to display", + ), + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Display detailed information about a specific crowdloan. + + Includes funding progress, target account, and call details among other information. + + EXAMPLES + + [green]$[/green] btcli crowd info --id 0 + + [green]$[/green] btcli crowd info --id 1 --verbose + """ + self.verbosity_handler(quiet, verbose, json_output) + + 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, + ) + + wallet = None + if wallet_name or wallet_path or wallet_hotkey: + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[], + validate=WV.WALLET, + ) + + return self._run_command( + view_crowdloan.show_crowdloan_details( + subtensor=self.initialize_chain(network), + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=verbose, + json_output=json_output, + ) + ) + + def crowd_create( + self, + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + deposit: Optional[float] = typer.Option( + None, + "--deposit", + help="Initial deposit in TAO to secure the crowdloan.", + min=1, + ), + min_contribution: Optional[float] = typer.Option( + None, + "--min-contribution", + "--min_contribution", + help="Minimum contribution amount in TAO.", + min=0.1, + ), + cap: Optional[int] = typer.Option( + None, + "--cap", + help="Maximum amount in TAO the crowdloan will raise.", + min=1, + ), + duration: Optional[int] = typer.Option( + None, + "--duration", + help="Crowdloan duration in blocks.", + min=1, + ), + target_address: Optional[str] = typer.Option( + None, + "--target-address", + "--target", + help="Optional target SS58 address to receive the raised funds (for fundraising type).", + ), + subnet_lease: Optional[bool] = typer.Option( + None, + "--subnet-lease/--fundraising", + help="Create a subnet leasing crowdloan (True) or general fundraising (False).", + ), + emissions_share: Optional[int] = typer.Option( + None, + "--emissions-share", + "--emissions", + help="Percentage of emissions for contributors (0-100) for subnet leasing.", + min=0, + max=100, + ), + lease_end_block: Optional[int] = typer.Option( + None, + "--lease-end-block", + "--lease-end", + help="Block number when subnet lease ends (omit for perpetual lease).", + min=1, + ), + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Start a new crowdloan campaign for fundraising or subnet leasing. + + 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 + + EXAMPLES + + General fundraising: + [green]$[/green] btcli crowd create --deposit 10 --cap 1000 --target-address 5D... + + Subnet leasing with 30% emissions for contributors: + [green]$[/green] btcli crowd create --subnet-lease --emissions-share 30 + + Subnet lease ending at block 500000: + [green]$[/green] btcli crowd create --subnet-lease --emissions-share 25 --lease-end-block 500000 + """ + self.verbosity_handler(quiet, verbose, json_output) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + create_crowdloan.create_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + deposit_tao=deposit, + min_contribution_tao=min_contribution, + cap_tao=cap, + duration_blocks=duration, + target_address=target_address, + subnet_lease=subnet_lease, + emissions_share=emissions_share, + lease_end_block=lease_end_block, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_contribute( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to display", + ), + amount: Optional[float] = typer.Option( + None, + "--amount", + "-a", + help="Amount to contribute in TAO", + min=0.001, + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Contribute TAO to an active crowdloan. + + This command allows you to contribute TAO to a crowdloan that is currently accepting contributions. + The contribution will be automatically adjusted if it would exceed the crowdloan's cap. + + EXAMPLES + + [green]$[/green] btcli crowd contribute --id 0 --amount 100 + + [green]$[/green] btcli crowd contribute --id 1 + """ + self.verbosity_handler(quiet, verbose, json_output) + + 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, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_contribute.contribute_to_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + prompt=prompt, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + json_output=json_output, + ) + ) + + def crowd_withdraw( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to withdraw from", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Withdraw contributions from a non-finalized crowdloan. + + Non-creators can withdraw their full contribution. + Creators can only withdraw amounts above their initial deposit. + """ + self.verbosity_handler(quiet, verbose, json_output) + + 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, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_contribute.withdraw_from_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_finalize( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to finalize", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Finalize a successful crowdloan that has reached its cap. + + Only the creator can finalize. This will transfer funds to the target + address (if specified) and execute any attached call (e.g., subnet creation). + """ + self.verbosity_handler(quiet, verbose, json_output) + + 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, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + create_crowdloan.finalize_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_update( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to update", + ), + min_contribution: Optional[float] = typer.Option( + None, + "--min-contribution", + "--min", + help="Update the minimum contribution amount (in TAO)", + ), + end: Optional[int] = typer.Option( + None, + "--end", + "--end-block", + help="Update the end block number", + ), + cap: Optional[float] = typer.Option( + None, + "--cap", + help="Update the cap amount (in TAO)", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Update one mutable field on a non-finalized crowdloan. + + Only the creator can invoke this. You may change the minimum contribution, + the end block, or the cap in a single call. When no flag is provided an + interactive prompt guides you through the update and validates the input + against the chain constants (absolute minimum contribution, block-duration + bounds, etc.). + """ + self.verbosity_handler(quiet, verbose, json_output) + + 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, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + min_contribution_balance = ( + Balance.from_tao(min_contribution) if min_contribution is not None else None + ) + cap_balance = Balance.from_tao(cap) if cap is not None else None + + return self._run_command( + crowd_update.update_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + min_contribution=min_contribution_balance, + end=end, + cap=cap_balance, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_refund( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to refund", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Refund contributors of a non-finalized crowdloan. + + Any account may call this once the crowdloan is no longer wanted. Each call + refunds up to the on-chain `RefundContributorsLimit` contributors (currently + 50) excluding the creator. Run it repeatedly until everyone except the creator + has been reimbursed. + """ + self.verbosity_handler(quiet, verbose, json_output) + + 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, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_refund.refund_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_dissolve( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to dissolve", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Dissolve a crowdloan after all contributors have been refunded. + + Only the creator can dissolve. The crowdloan must be non-finalized and the + raised balance must equal the creator's own contribution (i.e., all other + contributions have been withdrawn or refunded). Dissolving returns the + creator's deposit and removes the crowdloan from storage. + + If there are funds still available other than the creator's contribution, + you can run `btcli crowd refund` to refund the remaining contributors. + """ + self.verbosity_handler(quiet, verbose, json_output) + + 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, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_dissolve.dissolve_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + @staticmethod def convert( from_rao: Optional[str] = typer.Option( diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 1151a2c4..9b7d749b 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -732,6 +732,11 @@ class RootSudoOnly(Enum): "LIQUIDITY": { "LIQUIDITY_MGMT": "Liquidity Management", }, + "CROWD": { + "INITIATOR": "Crowdloan Creation & Management", + "PARTICIPANT": "Crowdloan Participation", + "INFO": "Crowdloan Information", + }, } diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py index ffd4ba3f..cfcc699f 100644 --- a/bittensor_cli/src/bittensor/chain_data.py +++ b/bittensor_cli/src/bittensor/chain_data.py @@ -1213,3 +1213,51 @@ def from_dict(cls, d: dict, netuid: int) -> "SimSwapResult": tao_fee=Balance.from_rao(d["tao_fee"]).set_unit(0), alpha_fee=Balance.from_rao(d["alpha_fee"]).set_unit(netuid), ) + + +@dataclass +class CrowdloanData(InfoBase): + creator: Optional[str] + funds_account: Optional[str] + deposit: Balance + min_contribution: Balance + cap: Balance + raised: Balance + end: int + finalized: bool + contributors_count: int + target_address: Optional[str] + has_call: bool + call_details: Optional[dict] = None + + @classmethod + def _fix_decoded(cls, decoded: dict[str, Any]) -> "CrowdloanData": + creator = ( + decode_account_id(creator_raw) + if (creator_raw := decoded.get("creator")) + else None + ) + funds_account = ( + decode_account_id(funds_raw) + if (funds_raw := decoded.get("funds_account")) + else None + ) + target_address = ( + decode_account_id(target_raw) + if (target_raw := decoded.get("target_address")) + else None + ) + return cls( + creator=creator, + funds_account=funds_account, + deposit=Balance.from_rao(int(decoded["deposit"])), + min_contribution=Balance.from_rao(int(decoded["min_contribution"])), + cap=Balance.from_rao(int(decoded["cap"])), + raised=Balance.from_rao(int(decoded["raised"])), + end=int(decoded["end"]), + finalized=bool(decoded["finalized"]), + contributors_count=int(decoded["contributors_count"]), + target_address=target_address, + has_call=bool(decoded["call"]), + call_details=decoded["call_details"], + ) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index fe5e7af2..8daf9487 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -14,7 +14,7 @@ from bittensor_wallet import Wallet from bittensor_wallet.bittensor_wallet import Keypair from bittensor_wallet.utils import SS58_FORMAT -from scalecodec import GenericCall +from scalecodec import GenericCall, ScaleBytes import typer import websockets @@ -30,6 +30,7 @@ SubnetState, MetagraphInfo, SimSwapResult, + CrowdloanData, ) from bittensor_cli.src import DelegatesDetails from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float @@ -167,6 +168,44 @@ async def query( else: return result + async def _decode_inline_call( + self, + call_option: Any, + block_hash: Optional[str] = None, + ) -> Optional[dict[str, Any]]: + """ + Decode an `Option` returned from storage into a structured dictionary. + """ + if not call_option or "Inline" not in call_option: + return None + inline_bytes = bytes(call_option["Inline"][0][0]) + call_obj = await self.substrate.create_scale_object( + "Call", + data=ScaleBytes(inline_bytes), + block_hash=block_hash, + ) + call_value = call_obj.decode() + + if not isinstance(call_value, dict): + return None + + call_args = call_value.get("call_args") or [] + args_map: dict[str, dict[str, Any]] = {} + for arg in call_args: + if isinstance(arg, dict) and arg.get("name"): + args_map[arg["name"]] = { + "type": arg.get("type"), + "value": arg.get("value"), + } + + return { + "call_index": call_value.get("call_index"), + "pallet": call_value.get("call_module"), + "method": call_value.get("call_function"), + "args": args_map, + "hash": call_value.get("call_hash"), + } + async def get_all_subnet_netuids( self, block_hash: Optional[str] = None ) -> list[int]: @@ -1693,6 +1732,101 @@ async def get_scheduled_coldkey_swap( keys_pending_swap.append(decode_account_id(ss58)) return keys_pending_swap + async def get_crowdloans( + self, block_hash: Optional[str] = None + ) -> list[CrowdloanData]: + """Retrieves all crowdloans from the network. + + Args: + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + dict[int, CrowdloanData]: A dictionary mapping crowdloan IDs to CrowdloanData objects + containing details such as creator, deposit, cap, raised amount, and finalization status. + + This function fetches information about all crowdloans + """ + crowdloans_data = await self.substrate.query_map( + module="Crowdloan", + storage_function="Crowdloans", + block_hash=block_hash, + fully_exhaust=True, + ) + crowdloans = {} + async for fund_id, fund_info in crowdloans_data: + decoded_call = await self._decode_inline_call( + fund_info["call"], + block_hash=block_hash, + ) + info_dict = dict(fund_info.value) + info_dict["call_details"] = decoded_call + crowdloans[fund_id] = CrowdloanData.from_any(info_dict) + + return crowdloans + + async def get_single_crowdloan( + self, + crowdloan_id: int, + block_hash: Optional[str] = None, + ) -> Optional[CrowdloanData]: + """Retrieves detailed information about a specific crowdloan. + + Args: + crowdloan_id (int): The unique identifier of the crowdloan to retrieve. + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + Optional[CrowdloanData]: A CrowdloanData object containing the crowdloan's details if found, + None if the crowdloan does not exist. + + The returned data includes crowdloan details such as funding targets, + contribution minimums, timeline, and current funding status + """ + crowdloan_info = await self.query( + module="Crowdloan", + storage_function="Crowdloans", + params=[crowdloan_id], + block_hash=block_hash, + ) + if crowdloan_info: + decoded_call = await self._decode_inline_call( + crowdloan_info.get("call"), + block_hash=block_hash, + ) + crowdloan_info["call_details"] = decoded_call + return CrowdloanData.from_any(crowdloan_info) + return None + + async def get_crowdloan_contribution( + self, + crowdloan_id: int, + contributor: str, + block_hash: Optional[str] = None, + ) -> Optional[Balance]: + """Retrieves a user's contribution to a specific crowdloan. + + Args: + crowdloan_id (int): The ID of the crowdloan. + contributor (str): The SS58 address of the contributor. + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + Optional[Balance]: The contribution amount as a Balance object if found, None otherwise. + + This function queries the Contributions storage to find the amount a specific address + has contributed to a given crowdloan. + """ + contribution = await self.query( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id, contributor], + block_hash=block_hash, + ) + + if contribution: + return Balance.from_rao(contribution) + return None + async def get_coldkey_swap_schedule_duration( self, block_hash: Optional[str] = None, diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index c8be3356..3846dc7b 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -1504,11 +1504,11 @@ async def print_extrinsic_id( query = await substrate.rpc_request("system_chainType", []) if query.get("result") == "Live": console.print( - f":white_heavy_check_mark:Your extrinsic has been included as {ext_id}: " + f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}: " f"[blue]https://tao.app/extrinsic/{ext_id}[/blue]" ) return console.print( - f":white_heavy_check_mark:Your extrinsic has been included as {ext_id}" + f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}" ) return diff --git a/bittensor_cli/src/commands/crowd/__init__.py b/bittensor_cli/src/commands/crowd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bittensor_cli/src/commands/crowd/contribute.py b/bittensor_cli/src/commands/crowd/contribute.py new file mode 100644 index 00000000..480f6a7f --- /dev/null +++ b/bittensor_cli/src/commands/crowd/contribute.py @@ -0,0 +1,597 @@ +import json +from typing import Optional + +from async_substrate_interface.utils.cache import asyncio +from bittensor_wallet import Wallet +from rich import box +from rich.prompt import Confirm, FloatPrompt +from rich.table import Column, Table + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + console, + json_console, + print_error, + print_extrinsic_id, + unlock_key, +) +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.bittensor.chain_data import CrowdloanData + + +def validate_for_contribution( + crowdloan: CrowdloanData, + crowdloan_id: int, + current_block: int, +) -> tuple[bool, Optional[str]]: + """Validate if a crowdloan can accept contributions. + + Args: + crowdloan: The crowdloan data object + crowdloan_id: The ID of the crowdloan + current_block: Current blockchain block number + + Returns: + tuple[bool, Optional[str]]: (is_valid, error_message) + - If valid: (True, None) + - If invalid: (False, error_message) + """ + if crowdloan.finalized: + return False, f"Crowdloan #{crowdloan_id} is already finalized." + + if current_block >= crowdloan.end: + return False, f"Crowdloan #{crowdloan_id} has ended." + + if crowdloan.raised >= crowdloan.cap: + return False, f"Crowdloan #{crowdloan_id} has reached its cap." + + return True, None + + +async def contribute_to_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + amount: Optional[float], + prompt: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + json_output: bool = False, +) -> tuple[bool, str]: + """Contribute TAO to an active crowdloan. + + Args: + subtensor: SubtensorInterface object for chain interaction + wallet: Wallet object containing coldkey for contribution + crowdloan_id: ID of the crowdloan to contribute to + amount: Amount to contribute in TAO (None to prompt) + prompt: Whether to prompt for confirmation + wait_for_inclusion: Wait for transaction inclusion + wait_for_finalization: Wait for transaction finalization + + Returns: + tuple[bool, str]: Success status and message + """ + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + is_valid, error_message = validate_for_contribution( + crowdloan, crowdloan_id, current_block + ) + if not is_valid: + if json_output: + json_console.print(json.dumps({"success": False, "error": error_message})) + else: + print_error(f"[red]{error_message}[/red]") + return False, error_message + + contributor_address = wallet.coldkeypub.ss58_address + current_contribution, user_balance, _ = await asyncio.gather( + subtensor.get_crowdloan_contribution(crowdloan_id, contributor_address), + subtensor.get_balance(contributor_address), + show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ), + ) + + if amount is None: + left_to_raise = crowdloan.cap - crowdloan.raised + max_contribution = min(user_balance, left_to_raise) + + console.print( + f"\n[bold cyan]Contribution Options:[/bold cyan]\n" + f" Your Balance: {user_balance}\n" + f" Maximum You Can Contribute: [{COLORS.S.AMOUNT}]{max_contribution}[/{COLORS.S.AMOUNT}]" + ) + amount = FloatPrompt.ask( + f"\nEnter contribution amount in {Balance.unit}", + default=float(crowdloan.min_contribution.tao), + ) + + contribution_amount = Balance.from_tao(amount) + if contribution_amount < crowdloan.min_contribution: + error_msg = f"Contribution amount ({contribution_amount}) is below minimum ({crowdloan.min_contribution})." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Contribution below minimum requirement." + + if contribution_amount > user_balance: + error_msg = f"Insufficient balance. You have {user_balance} but trying to contribute {contribution_amount}." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Insufficient balance." + + # Auto-adjustment + left_to_raise = crowdloan.cap - crowdloan.raised + actual_contribution = contribution_amount + will_be_adjusted = False + + if contribution_amount > left_to_raise: + actual_contribution = left_to_raise + will_be_adjusted = True + + # Extrinsic fee + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="contribute", + call_params={ + "crowdloan_id": crowdloan_id, + "amount": contribution_amount.rao, + }, + ) + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + updated_balance = user_balance - actual_contribution - extrinsic_fee + + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Contribution Summary[/bold cyan]", + show_footer=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + table.add_row("Crowdloan ID", str(crowdloan_id)) + table.add_row("Creator", crowdloan.creator) + table.add_row( + "Current Progress", + f"{crowdloan.raised} / {crowdloan.cap} ({(crowdloan.raised.tao / crowdloan.cap.tao * 100):.2f}%)", + ) + + if current_contribution: + table.add_row("Your Current Contribution", str(current_contribution)) + table.add_row("New Contribution", str(actual_contribution)) + table.add_row( + "Total After Contribution", + f"[{COLORS.S.AMOUNT}]{Balance.from_rao(current_contribution.rao + actual_contribution.rao)}[/{COLORS.S.AMOUNT}]", + ) + else: + table.add_row( + "Contribution Amount", + f"[{COLORS.S.AMOUNT}]{actual_contribution}[/{COLORS.S.AMOUNT}]", + ) + + if will_be_adjusted: + table.add_row( + "Note", + f"[yellow]Amount adjusted from {contribution_amount} to {actual_contribution} (cap limit)[/yellow]", + ) + + table.add_row("Transaction Fee", str(extrinsic_fee)) + table.add_row( + "Balance After", + f"[blue]{user_balance}[/blue] → [{COLORS.S.AMOUNT}]{updated_balance}[/{COLORS.S.AMOUNT}]", + ) + console.print(table) + + if will_be_adjusted: + console.print( + f"\n[yellow] Your contribution will be automatically adjusted to {actual_contribution} " + f"because the crowdloan only needs {left_to_raise} more to reach its cap.[/yellow]" + ) + + if prompt: + if not Confirm.ask("\nProceed with contribution?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Contribution cancelled by user."} + ) + ) + else: + console.print("[yellow]Contribution cancelled.[/yellow]") + return False, "Contribution cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status(f"\n:satellite: Contributing to crowdloan #{crowdloan_id}..."): + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to contribute.", + } + ) + ) + else: + print_error(f"[red]Failed to contribute: {error_message}[/red]") + return False, error_message or "Failed to contribute." + + new_balance, new_contribution, updated_crowdloan = await asyncio.gather( + subtensor.get_balance(contributor_address), + subtensor.get_crowdloan_contribution(crowdloan_id, contributor_address), + subtensor.get_single_crowdloan(crowdloan_id), + ) + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "contributor": contributor_address, + "contribution_amount": actual_contribution.tao, + "previous_contribution": current_contribution.tao + if current_contribution + else 0.0, + "total_contribution": new_contribution.tao if new_contribution else 0.0, + "balance": { + "before": user_balance.tao, + "after": new_balance.tao, + "fee": extrinsic_fee.tao, + }, + "crowdloan": { + "raised_before": crowdloan.raised.tao, + "raised_after": updated_crowdloan.raised.tao + if updated_crowdloan + else crowdloan.raised.tao, + "cap": crowdloan.cap.tao, + "percentage": ( + updated_crowdloan.raised.tao / updated_crowdloan.cap.tao * 100 + ) + if updated_crowdloan + else 0.0, + }, + "adjusted": will_be_adjusted, + "cap_reached": updated_crowdloan.raised >= updated_crowdloan.cap + if updated_crowdloan + else False, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"\n[dark_sea_green3]Successfully contributed to crowdloan #{crowdloan_id}![/dark_sea_green3]" + ) + + console.print( + f"Balance:\n [blue]{user_balance}[/blue] → " + f"[{COLORS.S.AMOUNT}]{new_balance}[/{COLORS.S.AMOUNT}]" + ) + + if new_contribution: + if current_contribution: + console.print( + f"Your Contribution:\n [blue]{current_contribution}[/blue] → " + f"[{COLORS.S.AMOUNT}]{new_contribution}[/{COLORS.S.AMOUNT}]" + ) + else: + console.print( + f"Your Contribution: [{COLORS.S.AMOUNT}]{new_contribution}[/{COLORS.S.AMOUNT}]" + ) + + if updated_crowdloan: + console.print( + f"Crowdloan Progress:\n [blue]{crowdloan.raised}[/blue] → " + f"[{COLORS.S.AMOUNT}]{updated_crowdloan.raised}[/{COLORS.S.AMOUNT}] / {updated_crowdloan.cap}" + ) + + if updated_crowdloan.raised >= updated_crowdloan.cap: + console.print( + "\n[bold green]🎉 Crowdloan has reached its funding cap![/bold green]" + ) + + await print_extrinsic_id(extrinsic_receipt) + + return True, "Successfully contributed to crowdloan." + + +async def withdraw_from_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool, + wait_for_finalization: bool, + prompt: bool, + json_output: bool = False, +) -> tuple[bool, str]: + """ + Withdraw contributions from a non-finalized crowdloan. + + Non-creators can withdraw their full contribution. + Creators can only withdraw amounts above their initial deposit. + + Args: + subtensor: SubtensorInterface instance for blockchain interaction + wallet: Wallet instance containing the user's keys + crowdloan_id: The ID of the crowdloan to withdraw from + wait_for_inclusion: Whether to wait for transaction inclusion + wait_for_finalization: Whether to wait for transaction finalization + prompt: Whether to prompt for user confirmation + + Returns: + Tuple of (success, message) indicating the result + """ + + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} does not exist." + 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 + + if crowdloan.finalized: + error_msg = f"Crowdloan #{crowdloan_id} is already finalized. Withdrawals are not allowed." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Cannot withdraw from finalized crowdloan." + + user_contribution, user_balance = await asyncio.gather( + subtensor.get_crowdloan_contribution( + crowdloan_id, wallet.coldkeypub.ss58_address + ), + subtensor.get_balance(wallet.coldkeypub.ss58_address), + ) + + if user_contribution == Balance.from_tao(0): + error_msg = ( + f"You have no contribution to withdraw from crowdloan #{crowdloan_id}." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "No contribution to withdraw." + + is_creator = wallet.coldkeypub.ss58_address == crowdloan.creator + if is_creator: + withdrawable = user_contribution - crowdloan.deposit + if withdrawable <= 0: + error_msg = f"As the creator, you cannot withdraw your deposit of {crowdloan.deposit}. Only contributions above the deposit can be withdrawn." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Creator cannot withdraw deposit amount." + remaining_contribution = crowdloan.deposit + else: + withdrawable = user_contribution + remaining_contribution = Balance.from_tao(0) + + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="withdraw", + call_params={ + "crowdloan_id": crowdloan_id, + }, + ) + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + if prompt: + new_balance = user_balance + withdrawable - extrinsic_fee + new_raised = crowdloan.raised - withdrawable + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Withdrawal Summary[/bold cyan]", + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + table.add_row("Crowdloan ID", str(crowdloan_id)) + + if is_creator: + table.add_row("Role", "[yellow]Creator[/yellow]") + table.add_row("Current Contribution", str(user_contribution)) + table.add_row("Deposit (Locked)", f"[yellow]{crowdloan.deposit}[/yellow]") + table.add_row( + "Withdrawable Amount", + f"[{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]", + ) + table.add_row( + "Remaining After Withdrawal", + f"[yellow]{remaining_contribution}[/yellow] (deposit)", + ) + else: + table.add_row("Current Contribution", str(user_contribution)) + table.add_row( + "Withdrawal Amount", + f"[{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]", + ) + + table.add_row("Transaction Fee", str(extrinsic_fee)) + table.add_row( + "Balance After", + f"[blue]{user_balance}[/blue] → [{COLORS.S.AMOUNT}]{new_balance}[/{COLORS.S.AMOUNT}]", + ) + + table.add_row( + "Crowdloan Total After", + f"[blue]{crowdloan.raised}[/blue] → [{COLORS.S.AMOUNT}]{new_raised}[/{COLORS.S.AMOUNT}]", + ) + + console.print(table) + + if not Confirm.ask("\nProceed with withdrawal?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Withdrawal cancelled by user."} + ) + ) + else: + console.print("[yellow]Withdrawal cancelled.[/yellow]") + return False, "Withdrawal cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status(f"\n:satellite: Withdrawing from crowdloan #{crowdloan_id}..."): + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to withdraw from crowdloan.", + } + ) + ) + else: + print_error( + f"[red]Failed to withdraw: {error_message or 'Unknown error'}[/red]" + ) + return False, error_message or "Failed to withdraw from crowdloan." + + new_balance, updated_contribution, updated_crowdloan = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_crowdloan_contribution( + crowdloan_id, wallet.coldkeypub.ss58_address + ), + subtensor.get_single_crowdloan(crowdloan_id), + ) + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "is_creator": is_creator, + "withdrawal_amount": withdrawable.tao, + "previous_contribution": user_contribution.tao, + "remaining_contribution": updated_contribution.tao + if updated_contribution + else 0.0, + "deposit_locked": crowdloan.deposit.tao if is_creator else None, + "balance": { + "before": user_balance.tao, + "after": new_balance.tao, + "fee": extrinsic_fee.tao, + }, + "crowdloan": { + "raised_before": crowdloan.raised.tao, + "raised_after": updated_crowdloan.raised.tao + if updated_crowdloan + else (crowdloan.raised.tao - withdrawable.tao), + }, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"\n✅ [green]Successfully withdrew from crowdloan #{crowdloan_id}![/green]\n" + ) + + console.print( + f"Amount Withdrawn: [{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]\n" + f"Balance:\n [blue]{user_balance}[/blue] → [{COLORS.S.AMOUNT}]{new_balance}[/{COLORS.S.AMOUNT}]" + f"Crowdloan raised before: [{COLORS.S.AMOUNT}]{crowdloan.raised}[/{COLORS.S.AMOUNT}]" + f"Crowdloan raised after: [{COLORS.S.AMOUNT}]{updated_crowdloan.raised}[/{COLORS.S.AMOUNT}]" + ) + + if is_creator and updated_contribution: + console.print( + f"Remaining Contribution: [{COLORS.S.AMOUNT}]{updated_contribution}[/{COLORS.S.AMOUNT}] (deposit locked)" + ) + + await print_extrinsic_id(extrinsic_receipt) + + return True, "Successfully withdrew from crowdloan." diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py new file mode 100644 index 00000000..2c2625b2 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/create.py @@ -0,0 +1,659 @@ +import asyncio +import json +from typing import Optional + +from bittensor_wallet import Wallet +from rich.prompt import Confirm, IntPrompt, Prompt, FloatPrompt +from rich.table import Table, Column, box + +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.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_error, + is_valid_ss58_address, + unlock_key, + print_extrinsic_id, +) + + +async def create_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + deposit_tao: Optional[int], + min_contribution_tao: Optional[int], + cap_tao: Optional[int], + duration_blocks: Optional[int], + target_address: Optional[str], + subnet_lease: Optional[bool], + emissions_share: Optional[int], + lease_end_block: Optional[int], + wait_for_inclusion: bool, + wait_for_finalization: bool, + prompt: bool, + json_output: bool, +) -> tuple[bool, str]: + """ + Create a new crowdloan with the given parameters. + Prompts for missing parameters if not provided. + """ + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + crowdloan_type = None + if subnet_lease is not None: + crowdloan_type = "subnet" if subnet_lease else "fundraising" + 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"], + ) + crowdloan_type = "subnet" if type_choice == 2 else "fundraising" + + if crowdloan_type == "subnet": + current_burn_cost = await subtensor.burn_cost() + console.print( + "\n[magenta]Subnet Lease Crowdloan Selected[/magenta]\n" + " • A new subnet will be created when the crowdloan is finalized\n" + " • Contributors will receive emissions as dividends\n" + " • 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" + ) + else: + console.print( + "\n[cyan]General Fundraising Crowdloan Selected[/cyan]\n" + " • Funds will be transferred to a target address when finalized\n" + " • Contributors can withdraw if the cap is not reached\n" + ) + else: + error_msg = "Crowdloan type not specified and no prompt provided." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(error_msg) + return False, error_msg + + block_hash = await subtensor.substrate.get_chain_head() + runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) + ( + minimum_deposit_raw, + min_contribution_raw, + min_duration, + max_duration, + ) = await asyncio.gather( + get_constant(subtensor, "MinimumDeposit", runtime=runtime), + get_constant(subtensor, "AbsoluteMinimumContribution", runtime=runtime), + get_constant(subtensor, "MinimumBlockDuration", runtime=runtime), + get_constant(subtensor, "MaximumBlockDuration", runtime=runtime), + ) + + minimum_deposit = Balance.from_rao(minimum_deposit_raw) + min_contribution = Balance.from_rao(min_contribution_raw) + + if not prompt: + missing_fields = [] + if deposit_tao is None: + missing_fields.append("--deposit") + if min_contribution_tao is None: + missing_fields.append("--min-contribution") + if cap_tao is None: + missing_fields.append("--cap") + if duration_blocks is None: + missing_fields.append("--duration") + if missing_fields: + error_msg = ( + "The following options must be provided when prompts are disabled: " + + ", ".join(missing_fields) + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Missing required options when prompts are disabled." + + deposit_value = deposit_tao + while True: + if deposit_value is None: + deposit_value = FloatPrompt.ask( + f"Enter the deposit amount in TAO " + f"[blue](>= {minimum_deposit.tao:,.4f})[/blue]" + ) + deposit = Balance.from_tao(deposit_value) + if deposit < minimum_deposit: + if prompt: + print_error( + f"[red]Deposit must be at least {minimum_deposit.tao:,.4f} TAO.[/red]" + ) + deposit_value = None + continue + error_msg = f"Deposit is below the minimum required deposit ({minimum_deposit.tao} TAO)." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Deposit is below the minimum required deposit." + break + + min_contribution_value = min_contribution_tao + while True: + if min_contribution_value is None: + min_contribution_value = FloatPrompt.ask( + f"Enter the minimum contribution amount in TAO " + f"[blue](>= {min_contribution.tao:,.4f})[/blue]" + ) + min_contribution = Balance.from_tao(min_contribution_value) + if min_contribution < min_contribution: + if prompt: + print_error( + f"[red]Minimum contribution must be at least " + f"{min_contribution.tao:,.4f} TAO.[/red]" + ) + min_contribution_value = None + continue + print_error( + "[red]Minimum contribution is below the chain's absolute minimum.[/red]" + ) + return False, "Minimum contribution is below the chain's absolute minimum." + break + + cap_value = cap_tao + while True: + if cap_value is None: + cap_value = FloatPrompt.ask( + f"Enter the cap amount in TAO [blue](> deposit of {deposit.tao:,.4f})[/blue]" + ) + cap = Balance.from_tao(cap_value) + if cap <= deposit: + if prompt: + print_error( + f"[red]Cap must be greater than the deposit ({deposit.tao:,.4f} TAO).[/red]" + ) + cap_value = None + continue + print_error("[red]Cap must be greater than the initial deposit.[/red]") + return False, "Cap must be greater than the initial deposit." + break + + duration_value = duration_blocks + while True: + if duration_value is None: + duration_value = IntPrompt.ask( + f"Enter the crowdloan duration in blocks " + f"[blue]({min_duration} - {max_duration})[/blue]" + ) + 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]" + ) + duration_value = None + continue + print_error("[red]Crowdloan duration is outside the allowed range.[/red]") + return False, "Crowdloan duration is outside the allowed range." + duration = duration_value + break + + current_block = await subtensor.substrate.get_block_number(None) + call_to_attach = None + + if crowdloan_type == "subnet": + target_address = None + + if emissions_share is None: + emissions_share = IntPrompt.ask( + "Enter emissions share percentage for contributors [blue](0-100)[/blue]" + ) + + if not 0 <= emissions_share <= 100: + print_error( + f"[red]Emissions share must be between 0 and 100, got {emissions_share}[/red]" + ) + return False, "Invalid emissions share percentage." + + if lease_end_block is None: + lease_perpetual = Confirm.ask( + "Should the subnet lease be perpetual?", + default=True, + ) + if not lease_perpetual: + lease_end_block = IntPrompt.ask( + f"Enter the block number when the lease should end. Current block is [bold]{current_block}[/bold]." + ) + register_lease_call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="register_leased_network", + call_params={ + "emissions_share": emissions_share, + "end_block": None if lease_perpetual else lease_end_block, + }, + ) + call_to_attach = register_lease_call + else: + 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]" + ) + return False, "Invalid target SS58 address provided." + elif prompt: + target_input = Prompt.ask( + "Enter a target SS58 address", + ) + 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]" + ) + return False, "Invalid target SS58 address provided." + + call_to_attach = None + + creator_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + if deposit > creator_balance: + print_error( + f"[red]Insufficient balance to cover the deposit. " + f"Available: {creator_balance}, required: {deposit}[/red]" + ) + return False, "Insufficient balance to cover the deposit." + + end_block = current_block + duration + + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="create", + call_params={ + "deposit": deposit.rao, + "min_contribution": min_contribution.rao, + "cap": cap.rao, + "end": end_block, + "call": call_to_attach, + "target_address": target_address, + }, + ) + + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + + if prompt: + duration_text = blocks_to_duration(duration) + + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title=f"\n[bold cyan]Crowdloan Creation Summary[/bold cyan]\n" + f"Network: [{COLORS.G.SUBHEAD_MAIN}]{subtensor.network}[/{COLORS.G.SUBHEAD_MAIN}]", + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + if crowdloan_type == "subnet": + table.add_row("Type", "[magenta]Subnet Leasing[/magenta]") + table.add_row( + "Emissions Share", f"[cyan]{emissions_share}%[/cyan] for contributors" + ) + if lease_end_block: + table.add_row("Lease Ends", f"Block {lease_end_block}") + else: + table.add_row("Lease Duration", "[green]Perpetual[/green]") + else: + table.add_row("Type", "[cyan]General Fundraising[/cyan]") + target_text = ( + target_address + if target_address + else f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) + table.add_row("Target address", target_text) + + table.add_row("Deposit", f"[{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]") + table.add_row( + "Min contribution", f"[{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]" + ) + table.add_row("Cap", f"[{COLORS.P.TAO}]{cap}[/{COLORS.P.TAO}]") + table.add_row("Duration", f"[bold]{duration}[/bold] blocks (~{duration_text})") + table.add_row("Ends at block", f"[bold]{end_block}[/bold]") + table.add_row( + "Estimated fee", f"[{COLORS.P.TAO}]{extrinsic_fee}[/{COLORS.P.TAO}]" + ) + console.print(table) + + if not Confirm.ask("Proceed with creating the crowdloan?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Cancelled crowdloan creation."} + ) + ) + else: + console.print("[yellow]Cancelled crowdloan creation.[/yellow]") + return False, "Cancelled crowdloan creation." + + success, error_message, extrinsic_receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to create crowdloan.", + } + ) + ) + else: + print_error(f"[red]{error_message or 'Failed to create crowdloan.'}[/red]") + return False, error_message or "Failed to create crowdloan." + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "data": { + "type": crowdloan_type, + "deposit": deposit.tao, + "min_contribution": min_contribution.tao, + "cap": cap.tao, + "duration": duration, + "end_block": end_block, + "extrinsic_id": extrinsic_id, + }, + } + + if crowdloan_type == "subnet": + 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 + else: + output_dict["data"]["target_address"] = target_address + + json_console.print(json.dumps(output_dict)) + message = f"{crowdloan_type.capitalize()} crowdloan created successfully." + else: + if crowdloan_type == "subnet": + message = "Subnet lease crowdloan created successfully." + 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" + 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 lease_end_block: + console.print(f" Lease ends at block: [bold]{lease_end_block}[/bold]") + else: + console.print(" Lease: [green]Perpetual[/green]") + else: + message = "Fundraising crowdloan created successfully." + 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" + f" Cap: [{COLORS.P.TAO}]{cap}[/{COLORS.P.TAO}]\n" + f" Ends at block: [bold]{end_block}[/bold]" + ) + if target_address: + console.print(f" Target address: {target_address}") + + await print_extrinsic_id(extrinsic_receipt) + + return True, message + + +async def finalize_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool, + wait_for_finalization: bool, + prompt: bool, + json_output: bool = False, +) -> tuple[bool, str]: + """ + Finalize a successful crowdloan that has reached its cap. + + Only the creator can finalize a crowdloan. Finalization will: + - Transfer funds to the target address (if specified) + - Execute the attached call (if any, e.g., subnet creation) + - Mark the crowdloan as finalized + + Args: + subtensor: SubtensorInterface instance for blockchain interaction + wallet: Wallet instance containing the user's keys + crowdloan_id: The ID of the crowdloan to finalize + wait_for_inclusion: Whether to wait for transaction inclusion + wait_for_finalization: Whether to wait for transaction finalization + prompt: Whether to prompt for user confirmation + + Returns: + Tuple of (success, message) indicating the result + """ + + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} does not exist." + 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 + + if wallet.coldkeypub.ss58_address != crowdloan.creator: + error_msg = ( + f"Only the creator can finalize a crowdloan. Creator: {crowdloan.creator}" + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Only the creator can finalize a crowdloan." + + if crowdloan.finalized: + error_msg = f"Crowdloan #{crowdloan_id} is already finalized." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Crowdloan is already finalized." + + if crowdloan.raised < crowdloan.cap: + still_needed = crowdloan.cap - crowdloan.raised + error_msg = ( + f"Crowdloan #{crowdloan_id} has not reached its cap. Raised: {crowdloan.raised.tao}, " + f"Cap: {crowdloan.cap.tao}, Still needed: {still_needed.tao}" + ) + if json_output: + 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"Raised: {crowdloan.raised}, Cap: {crowdloan.cap}\n" + f"Still needed: {still_needed.tao}[/red]" + ) + return False, "Crowdloan has not reached its cap." + + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="finalize", + call_params={ + "crowdloan_id": crowdloan_id, + }, + ) + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + if prompt: + console.print() + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Crowdloan Finalization Summary[/bold cyan]", + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + table.add_row("Crowdloan ID", str(crowdloan_id)) + table.add_row("Status", "[green]Ready to Finalize[/green]") + table.add_row( + "Total Raised", f"[{COLORS.S.AMOUNT}]{crowdloan.raised}[/{COLORS.S.AMOUNT}]" + ) + table.add_row("Contributors", str(crowdloan.contributors_count)) + + if crowdloan.target_address: + table.add_row( + "Funds Will Go To", + f"[{COLORS.G.SUBHEAD_EX_1}]{crowdloan.target_address}[/{COLORS.G.SUBHEAD_EX_1}]", + ) + + if crowdloan.has_call: + table.add_row( + "Call to Execute", "[yellow]Yes (e.g., subnet registration)[/yellow]" + ) + else: + table.add_row("Call to Execute", "[dim]None[/dim]") + + table.add_row("Transaction Fee", str(extrinsic_fee)) + + table.add_section() + table.add_row( + "[bold red]WARNING[/bold red]", + "[yellow]This action is IRREVERSIBLE![/yellow]", + ) + + console.print(table) + + console.print( + "\n[bold yellow]Important:[/bold yellow]\n" + "• Finalization will transfer all raised funds\n" + "• Any attached call will be executed immediately\n" + "• This action cannot be undone\n" + ) + + if not Confirm.ask("\nProceed with finalization?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Finalization cancelled by user."} + ) + ) + else: + console.print("[yellow]Finalization cancelled.[/yellow]") + return False, "Finalization cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + success, error_message, extrinsic_receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to finalize crowdloan.", + } + ) + ) + else: + print_error( + f"[red]Failed to finalize: {error_message or 'Unknown error'}[/red]" + ) + return False, error_message or "Failed to finalize crowdloan." + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "total_raised": crowdloan.raised.tao, + "contributors_count": crowdloan.contributors_count, + "target_address": crowdloan.target_address, + "has_call": crowdloan.has_call, + "call_executed": crowdloan.has_call, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"\n[dark_sea_green3]Successfully finalized crowdloan #{crowdloan_id}![/dark_sea_green3]\n" + ) + + console.print( + f"[bold]Finalization Complete:[/bold]\n" + f"\t• Total Raised: [{COLORS.S.AMOUNT}]{crowdloan.raised}[/{COLORS.S.AMOUNT}]\n" + f"\t• Contributors: {crowdloan.contributors_count}" + ) + + if crowdloan.target_address: + console.print( + f"\t• Funds transferred to: [{COLORS.G.SUBHEAD_EX_1}]{crowdloan.target_address}[/{COLORS.G.SUBHEAD_EX_1}]" + ) + + if crowdloan.has_call: + console.print("\t• [green]Associated call has been executed[/green]") + + await print_extrinsic_id(extrinsic_receipt) + + return True, "Successfully finalized crowdloan." diff --git a/bittensor_cli/src/commands/crowd/dissolve.py b/bittensor_cli/src/commands/crowd/dissolve.py new file mode 100644 index 00000000..b7513fb1 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/dissolve.py @@ -0,0 +1,210 @@ +import asyncio +import json + +from bittensor_wallet import Wallet +from rich.prompt import Confirm +from rich.table import Column, Table, box + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_extrinsic_id, + print_error, + unlock_key, +) + + +async def dissolve_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt: bool = True, + json_output: bool = False, +) -> tuple[bool, str]: + """Dissolve a non-finalized crowdloan after refunding contributors. + + The creator can reclaim their deposit once every other contribution has been + refunded (i.e., the raised amount equals the creator's contribution). + + Args: + subtensor: SubtensorInterface object for chain interaction. + wallet: Wallet object containing the creator's coldkey. + crowdloan_id: ID of the crowdloan to dissolve. + wait_for_inclusion: Wait for transaction inclusion. + wait_for_finalization: Wait for transaction finalization. + prompt: Whether to prompt for confirmation. + + Returns: + tuple[bool, str]: Success status and message. + """ + + creator_ss58 = wallet.coldkeypub.ss58_address + + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if crowdloan.finalized: + error_msg = ( + f"Crowdloan #{crowdloan_id} is already finalized and cannot be dissolved." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, f"Crowdloan #{crowdloan_id} is finalized." + + if creator_ss58 != crowdloan.creator: + error_msg = f"Only the creator can dissolve this crowdloan. Creator: {crowdloan.creator}, Your address: {creator_ss58}" + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Only the creator can dissolve this crowdloan.[/red]\n" + f"Creator: [blue]{crowdloan.creator}[/blue]\n" + f"Your address: [blue]{creator_ss58}[/blue]" + ) + return False, "Only the creator can dissolve this crowdloan." + + creator_contribution = await subtensor.get_crowdloan_contribution( + crowdloan_id, crowdloan.creator + ) + + if creator_contribution != crowdloan.raised: + error_msg = ( + f"Crowdloan still holds funds from other contributors. " + f"Raised: {crowdloan.raised.tao}, Creator's contribution: {creator_contribution.tao}. " + "Run 'btcli crowd refund' until only the creator's funds remain." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Crowdloan still holds funds from other contributors.[/red]\n" + f"Raised amount: [yellow]{crowdloan.raised}[/yellow]\n" + f"Creator's contribution: [yellow]{creator_contribution}[/yellow]\n" + "Run [cyan]btcli crowd refund[/cyan] until only the creator's funds remain." + ) + return False, "Crowdloan not ready to dissolve." + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + summary = Table( + Column("Field", style=COLORS.G.SUBHEAD), + Column("Value", style=COLORS.G.TEMPO), + box=box.SIMPLE, + show_header=False, + ) + summary.add_row("Crowdloan ID", f"#{crowdloan_id}") + summary.add_row("Raised", str(crowdloan.raised)) + summary.add_row("Creator Contribution", str(creator_contribution)) + summary.add_row( + "Remaining Contributors", + str(max(0, crowdloan.contributors_count - 1)), + ) + time_remaining = crowdloan.end - current_block + summary.add_row( + "Time Remaining", + blocks_to_duration(time_remaining) if time_remaining > 0 else "Ended", + ) + + console.print("\n[bold cyan]Crowdloan Dissolution Summary[/bold cyan]") + console.print(summary) + + if prompt and not Confirm.ask( + f"\n[bold]Proceed with dissolving crowdloan #{crowdloan_id}?[/bold]", + default=False, + ): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Dissolution cancelled by user."} + ) + ) + else: + console.print("[yellow]Dissolution cancelled.[/yellow]") + return False, "Dissolution cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status( + ":satellite: Submitting dissolve transaction...", spinner="aesthetic" + ): + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="dissolve", + call_params={"crowdloan_id": crowdloan_id}, + ) + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to dissolve crowdloan.", + } + ) + ) + else: + print_error(f"[red]Failed to dissolve crowdloan.[/red]\n{error_message}") + return False, error_message + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "creator": crowdloan.creator, + "total_dissolved": creator_contribution.tao, + }, + } + json_console.print(json.dumps(output_dict)) + else: + await print_extrinsic_id(extrinsic_receipt) + console.print("[green]Crowdloan dissolved successfully![/green]") + + return True, "Crowdloan dissolved successfully." diff --git a/bittensor_cli/src/commands/crowd/refund.py b/bittensor_cli/src/commands/crowd/refund.py new file mode 100644 index 00000000..d08f9129 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/refund.py @@ -0,0 +1,225 @@ +import asyncio +import json + +from bittensor_wallet import Wallet +from rich.prompt import Confirm +from rich.table import Table, Column, box + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_extrinsic_id, + print_error, + unlock_key, +) +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.commands.crowd.utils import get_constant + + +async def refund_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt: bool = True, + json_output: bool = False, +) -> tuple[bool, str]: + """Refund contributors of a non-finalized crowdloan. + + This extrinsic refunds all contributors (excluding the creator) up to the + RefundContributorsLimit. If there are more contributors than the limit, + this call may need to be executed multiple times until all contributors + are refunded. + + Anyone can call this function - it does not need to be the creator. + + Args: + subtensor: SubtensorInterface object for chain interaction + wallet: Wallet object containing coldkey (any wallet can call this) + crowdloan_id: ID of the crowdloan to refund + wait_for_inclusion: Wait for transaction inclusion + wait_for_finalization: Wait for transaction finalization + prompt: Whether to prompt for confirmation + + Returns: + tuple[bool, str]: Success status and message + """ + creator_ss58 = wallet.coldkeypub.ss58_address + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if crowdloan.finalized: + error_msg = f"Crowdloan #{crowdloan_id} is already finalized. Finalized crowdloans cannot be refunded." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, f"Crowdloan #{crowdloan_id} is already finalized." + + if creator_ss58 != crowdloan.creator: + error_msg = f"Only the creator can refund this crowdloan. Creator: {crowdloan.creator}, Your address: {creator_ss58}" + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Only the creator can refund this crowdloan.[/red]\n" + f"Creator: [blue]{crowdloan.creator}[/blue]\n" + f"Your address: [blue]{creator_ss58}[/blue]" + ) + return False, "Only the creator can refund this crowdloan." + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + refund_limit = await get_constant(subtensor, "RefundContributorsLimit") + + console.print("\n[bold cyan]Crowdloan Refund Information[/bold cyan]\n") + + info_table = Table( + Column("[bold white]Property", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + info_table.add_row("Crowdloan ID", f"#{crowdloan_id}") + info_table.add_row("Total Contributors", f"{crowdloan.contributors_count:,}") + info_table.add_row("Refund Limit (per call)", f"{refund_limit:,} contributors") + info_table.add_row("Amount to Refund", crowdloan.raised - crowdloan.deposit) + + if current_block >= crowdloan.end: + if crowdloan.raised < crowdloan.cap: + status = "[red]Failed[/red] (Cap not reached)" + else: + status = "[yellow]Ended but not finalized[/yellow]" + else: + status = "[green]Active[/green] (Still accepting contributions)" + + info_table.add_row("Status", status) + + refundable_contributors = max(0, crowdloan.contributors_count) + estimated_calls = ( + (refundable_contributors + refund_limit) // refund_limit + if refund_limit > 0 + else 0 + ) + + if estimated_calls > 1: + info_table.add_row( + "Estimated Calls Needed", + f"[yellow]~{estimated_calls}[/yellow] (due to contributor limit)", + ) + + console.print(info_table) + + if estimated_calls > 1: + console.print( + f"\n[yellow]Note:[/yellow] Due to the [cyan]Refund Contributors Limit[/cyan] of {refund_limit:,} contributors per call,\n" + f" you may need to execute this command [yellow]{estimated_calls} times[/yellow] to refund all contributors.\n" + f" Each call will refund up to {refund_limit:,} contributors until all are processed.\n" + ) + + if prompt and not Confirm.ask( + f"\n[bold]Proceed with refunding contributors of Crowdloan #{crowdloan_id}?[/bold]", + default=False, + ): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Refund cancelled by user."}) + ) + else: + console.print("[yellow]Refund cancelled.[/yellow]") + return False, "Refund cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status( + ":satellite: Submitting refund transaction...", spinner="aesthetic" + ): + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="refund", + call_params={ + "crowdloan_id": crowdloan_id, + }, + ) + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to refund contributors.", + } + ) + ) + else: + print_error(f"[red]Failed to refund contributors.[/red]\n{error_message}") + return False, error_message + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "refund_limit_per_call": refund_limit, + "total_contributors": crowdloan.contributors_count, + "estimated_calls_remaining": max(0, estimated_calls - 1), + "amount_refunded": (crowdloan.raised - crowdloan.deposit).tao, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"[green]Contributors have been refunded for Crowdloan #{crowdloan_id}.[/green]" + ) + await print_extrinsic_id(extrinsic_receipt) + + return True, f"Contributors have been refunded for Crowdloan #{crowdloan_id}." diff --git a/bittensor_cli/src/commands/crowd/update.py b/bittensor_cli/src/commands/crowd/update.py new file mode 100644 index 00000000..2b2ee04f --- /dev/null +++ b/bittensor_cli/src/commands/crowd/update.py @@ -0,0 +1,408 @@ +import asyncio +import json +from typing import Optional + +from bittensor_wallet import Wallet +from rich.prompt import Confirm, IntPrompt, FloatPrompt +from rich.table import Table, Column, box + +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 ( + blocks_to_duration, + console, + json_console, + print_error, + unlock_key, + print_extrinsic_id, +) +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.commands.crowd.utils import get_constant + + +async def update_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + min_contribution: Optional[Balance] = None, + end: Optional[int] = None, + cap: Optional[Balance] = None, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt: bool = True, + json_output: bool = False, +) -> tuple[bool, str]: + """Update parameters of a non-finalized crowdloan. + + Args: + subtensor: SubtensorInterface object for chain interaction + wallet: Wallet object containing coldkey (must be creator) + crowdloan_id: ID of the crowdloan to update + min_contribution: New minimum contribution in TAO (None to prompt) + end: New end block (None to prompt) + cap: New cap in TAO (None to prompt) + wait_for_inclusion: Wait for transaction inclusion + wait_for_finalization: Wait for transaction finalization + prompt: Whether to prompt for values + + Returns: + tuple[bool, str]: Success status and message + """ + + block_hash = await subtensor.substrate.get_chain_head() + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id, block_hash=block_hash), + subtensor.substrate.get_block_number(block_hash=block_hash), + ) + + runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) + absolute_min_rao, min_duration, max_duration = await asyncio.gather( + get_constant(subtensor, "AbsoluteMinimumContribution", runtime=runtime), + get_constant(subtensor, "MinimumBlockDuration", runtime=runtime), + get_constant(subtensor, "MaximumBlockDuration", runtime=runtime), + ) + absolute_min = Balance.from_rao(absolute_min_rao) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if crowdloan.finalized: + error_msg = ( + f"Crowdloan #{crowdloan_id} is already finalized and cannot be updated." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, f"Crowdloan #{crowdloan_id} is already finalized." + + creator_address = wallet.coldkeypub.ss58_address + if creator_address != crowdloan.creator: + error_msg = "Only the creator can update this crowdloan." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Only the creator can update this crowdloan.[/red]\n" + f"Creator: [blue]{crowdloan.creator}[/blue]\n" + f"Your address: [blue]{creator_address}[/blue]" + ) + return False, error_msg + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + if all(x is None for x in [min_contribution, end, cap]) and prompt: + console.print( + f"\n[bold cyan]What would you like to update for Crowdloan #{crowdloan_id}?[/bold cyan]\n" + ) + time_left = blocks_to_duration(crowdloan.end - current_block) + choice = IntPrompt.ask( + f"[cyan][1][/cyan] Minimum Contribution (current: [yellow]{crowdloan.min_contribution}[/yellow])\n" + f"[cyan][2][/cyan] End Block (current: [yellow]block {crowdloan.end:,}[/yellow], {time_left} remaining)\n" + f"[cyan][3][/cyan] Cap (current: [yellow]{crowdloan.cap}[/yellow])\n" + f"[cyan][4][/cyan] Cancel\n\n" + f"Enter your choice", + choices=["1", "2", "3", "4"], + default=4, + ) + + if choice == 4: + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Update cancelled by user."}) + ) + else: + console.print("[yellow]Update cancelled.[/yellow]") + return False, "Update cancelled by user." + + if choice == 1: + console.print( + f"\n[cyan]Update Minimum Contribution[/cyan]" + f"\n • Current: [yellow]{crowdloan.min_contribution}[/yellow]" + f"\n • Absolute minimum: [dim]{absolute_min}[/dim]\n" + ) + + while True: + new_value = FloatPrompt.ask( + "Enter new minimum contribution (TAO)", + default=float(crowdloan.min_contribution.tao), + ) + candidate = Balance.from_tao(new_value) + if candidate.rao < absolute_min.rao: + print_error( + f"[red]Minimum contribution must be at least {absolute_min}. Try again.[/red]" + ) + continue + min_contribution = candidate + break + + elif choice == 2: + min_end_block = current_block + min_duration + max_end_block = current_block + max_duration + duration_remaining = blocks_to_duration(crowdloan.end - current_block) + console.print( + f"\n[cyan]Update End Block[/cyan]" + f"\n • Current: [yellow]block {crowdloan.end:,}[/yellow] ({duration_remaining} remaining)" + f"\n • Current block: [dim]{current_block:,}[/dim]" + f"\n • Valid range: [dim]{min_end_block:,} - {max_end_block:,}[/dim]" + f"\n • Duration range: [dim]{blocks_to_duration(min_duration)} - {blocks_to_duration(max_duration)}[/dim]\n" + ) + + while True: + candidate_end = IntPrompt.ask( + "Enter new end block", + default=crowdloan.end, + ) + + if candidate_end <= current_block: + print_error( + f"[red]End block must be after current block ({current_block:,}). Try again.[/red]" + ) + continue + + duration = candidate_end - current_block + if duration < min_duration: + duration_range = f"[dim]{min_end_block} - {blocks_to_duration(min_duration)}[/dim]" + print_error( + f"[red]Duration is too short. Minimum: {duration_range}. Try again.[/red]" + ) + continue + if duration > max_duration: + duration_range = f"[dim]{max_end_block} - {blocks_to_duration(max_duration)}[/dim]" + print_error( + f"[red]Duration is too long. Maximum: {duration_range}. Try again.[/red]" + ) + continue + + end = candidate_end + break + + elif choice == 3: + console.print( + f"\n[cyan]Update Cap[/cyan]" + f"\n • Current cap: [yellow]{crowdloan.cap}[/yellow]" + f"\n • Already raised: [green]{crowdloan.raised}[/green]" + f"\n • Remaining to raise: [dim]{(crowdloan.cap.rao - crowdloan.raised.rao) / 1e9:.9f} TAO[/dim]" + f"\n • New cap must be >= raised amount\n" + ) + + while True: + new_value = FloatPrompt.ask( + "Enter new cap (TAO)", + default=float(crowdloan.cap.tao), + ) + candidate_cap = Balance.from_tao(new_value) + if candidate_cap.rao < crowdloan.raised.rao: + print_error( + f"[red]Cap must be >= amount already raised ({crowdloan.raised}). Try again.[/red]" + ) + continue + cap = candidate_cap + break + + value: Optional[Balance | int] = None + call_function: Optional[str] = None + param_name: Optional[str] = None + update_type: Optional[str] = None + + if min_contribution is not None: + value = min_contribution + call_function = "update_min_contribution" + param_name = "new_min_contribution" + update_type = "Minimum Contribution" + elif cap is not None: + value = cap + call_function = "update_cap" + param_name = "new_cap" + update_type = "Cap" + elif end is not None: + value = end + call_function = "update_end" + param_name = "new_end" + update_type = "End Block" + + if call_function is None or value is None or param_name is None: + error_msg = "No update parameter specified." + 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 + + # Validation + if call_function == "update_min_contribution": + if value.rao < absolute_min.rao: + error_msg = f"Minimum contribution must be at least {absolute_min}." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Minimum contribution ({value}) must be at least {absolute_min}.[/red]" + ) + return False, error_msg + + elif call_function == "update_end": + if value <= current_block: + error_msg = "End block must be in the future." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]End block ({value:,}) must be after current block ({current_block:,}).[/red]" + ) + return False, error_msg + + block_duration = value - current_block + if block_duration < min_duration: + error_msg = "Block duration too short." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Duration ({blocks_to_duration(block_duration)}) is too short. " + f"Minimum: [dim]{min_end_block} - {blocks_to_duration(min_duration)}[/dim][/red]" + ) + return False, error_msg + + if block_duration > max_duration: + error_msg = "Block duration too long." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Duration ({blocks_to_duration(block_duration)}) is too long. " + f"Maximum: [dim]{max_end_block} - {blocks_to_duration(max_duration)}[/dim][/red]" + ) + return False, error_msg + + elif call_function == "update_cap": + if value < crowdloan.raised: + error_msg = "Cap must be >= raised amount." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]New cap ({value}) must be at least the amount already raised ({crowdloan.raised}).[/red]" + ) + return False, error_msg + + # Update summary + table = Table( + Column("[bold white]Parameter", style=COLORS.G.SUBHEAD), + Column("[bold white]Current Value", style=COLORS.G.TEMPO), + Column("[bold white]New Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Update Summary[/bold cyan]", + show_footer=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + if call_function == "update_min_contribution": + table.add_row( + "Minimum Contribution", str(crowdloan.min_contribution), str(value) + ) + elif call_function == "update_end": + table.add_row( + "End Block", + f"{crowdloan.end:,} ({blocks_to_duration(crowdloan.end - current_block)} remaining)", + f"{value:,} ({blocks_to_duration(value - current_block)} remaining)", + ) + elif call_function == "update_cap": + table.add_row("Cap", str(crowdloan.cap), str(value)) + + console.print(table) + + if prompt and not Confirm.ask( + f"\n[bold]Proceed with updating {update_type}?[/bold]", default=False + ): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Update cancelled by user."}) + ) + else: + console.print("[yellow]Update cancelled.[/yellow]") + return False, "Update cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + if call_function != "update_end": + value = value.rao + + with console.status( + ":satellite: Submitting update transaction...", spinner="aesthetic" + ): + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function=call_function, + call_params={"crowdloan_id": crowdloan_id, param_name: value}, + ) + + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or f"Failed to update {update_type}.", + } + ) + ) + else: + print_error(f"[red]Failed to update {update_type}.[/red]\n{error_message}") + return False, error_message + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "update_type": update_type, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"[green]{update_type} updated successfully![/green]\n" + f"Crowdloan #{crowdloan_id} has been updated." + ) + await print_extrinsic_id(extrinsic_receipt) + + return True, f"{update_type} updated successfully." diff --git a/bittensor_cli/src/commands/crowd/utils.py b/bittensor_cli/src/commands/crowd/utils.py new file mode 100644 index 00000000..4ad7895e --- /dev/null +++ b/bittensor_cli/src/commands/crowd/utils.py @@ -0,0 +1,35 @@ +from typing import Optional + +from async_substrate_interface.types import Runtime + +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def get_constant( + subtensor: SubtensorInterface, + constant_name: str, + runtime: Optional[Runtime] = None, + block_hash: Optional[str] = None, +) -> int: + """ + Get a constant from the Crowdloan pallet. + + Args: + subtensor: SubtensorInterface object for chain interaction + constant_name: Name of the constant to get + runtime: Runtime object + block_hash: Block hash + + Returns: + The value of the constant + """ + + runtime = runtime or await subtensor.substrate.init_runtime(block_hash=block_hash) + + result = await subtensor.substrate.get_constant( + module_name="Crowdloan", + constant_name=constant_name, + block_hash=block_hash, + runtime=runtime, + ) + return getattr(result, "value", result) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py new file mode 100644 index 00000000..ba7657dd --- /dev/null +++ b/bittensor_cli/src/commands/crowd/view.py @@ -0,0 +1,641 @@ +from typing import Optional + +import asyncio +import json +from bittensor_wallet import Wallet +from rich import box +from rich.table import Column, Table + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.chain_data import CrowdloanData +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_error, + millify_tao, +) + + +def _shorten(account: str | None) -> str: + if not account: + return "-" + return f"{account[:6]}…{account[-6:]}" + + +def _status(loan: CrowdloanData, current_block: int) -> str: + if loan.finalized: + return "Finalized" + if loan.raised >= loan.cap: + return "Funded" + if current_block >= loan.end: + return "Closed" + return "Active" + + +def _time_remaining(loan: CrowdloanData, current_block: int) -> str: + diff = loan.end - current_block + if diff > 0: + return blocks_to_duration(diff) + if diff == 0: + return "due" + return f"Closed {blocks_to_duration(abs(diff))} ago" + + +async def list_crowdloans( + subtensor: SubtensorInterface, + verbose: bool = False, + json_output: bool = False, +) -> bool: + """List all crowdloans in a tabular format or JSON output.""" + + current_block, loans = await asyncio.gather( + subtensor.substrate.get_block_number(None), + subtensor.get_crowdloans(), + ) + if not 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.[/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()) + + funding_percentage = (total_raised / total_cap * 100) if total_cap > 0 else 0 + percentage_color = "dark_sea_green" if funding_percentage < 100 else "red" + formatted_percentage = ( + f"[{percentage_color}]{funding_percentage:.2f}%[/{percentage_color}]" + ) + + if json_output: + crowdloans_list = [] + for loan_id, loan in loans.items(): + status = _status(loan, current_block) + time_remaining = _time_remaining(loan, current_block) + + call_info = None + if loan.call_details: + pallet = loan.call_details.get("pallet", "") + method = loan.call_details.get("method", "") + if pallet == "SubtensorModule" and method == "register_leased_network": + call_info = "Subnet Leasing" + else: + call_info = ( + f"{pallet}.{method}" + if pallet and method + else method or pallet or "Unknown" + ) + elif loan.has_call: + call_info = "Unknown" + + crowdloan_data = { + "id": loan_id, + "status": status, + "raised": loan.raised.tao, + "cap": loan.cap.tao, + "deposit": loan.deposit.tao, + "min_contribution": loan.min_contribution.tao, + "end_block": loan.end, + "time_remaining": time_remaining, + "contributors_count": loan.contributors_count, + "creator": loan.creator, + "target_address": loan.target_address, + "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"], + ) + ) + + output_dict = { + "success": True, + "error": None, + "data": { + "crowdloans": crowdloans_list, + "total_count": total_loans, + "total_raised": total_raised, + "total_cap": total_cap, + "total_contributors": total_contributors, + "funding_percentage": funding_percentage, + "current_block": current_block, + "network": subtensor.network, + }, + } + json_console.print(json.dumps(output_dict)) + return True + + if not verbose: + funding_string = f"τ {millify_tao(total_raised)}/{millify_tao(total_cap)} ({formatted_percentage})" + else: + funding_string = ( + f"τ {total_raised:.1f}/{total_cap:.1f} ({formatted_percentage})" + ) + + table = Table( + title=f"\n[{COLORS.G.HEADER}]Crowdloans" + 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]ID", style="grey89", justify="center", footer=str(total_loans) + ) + table.add_column("[bold white]Status", style="cyan", justify="center") + table.add_column( + f"[bold white]Raised / Cap\n({Balance.get_unit(0)})", + style="dark_sea_green2", + justify="left", + footer=funding_string, + ) + table.add_column( + f"[bold white]Deposit\n({Balance.get_unit(0)})", + style="steel_blue3", + justify="left", + ) + table.add_column( + f"[bold white]Min Contribution\n({Balance.get_unit(0)})", + style=COLORS.P.EMISSION, + justify="left", + ) + table.add_column("[bold white]Ends (Block)", style=COLORS.S.TAO, justify="left") + table.add_column( + "[bold white]Time Remaining", + style=COLORS.S.ALPHA, + justify="left", + ) + table.add_column( + "[bold white]Contributors", + style=COLORS.P.ALPHA_IN, + justify="center", + footer=str(total_contributors), + ) + table.add_column( + "[bold white]Creator", + style=COLORS.G.TEMPO, + justify="left", + overflow="fold", + ) + table.add_column( + "[bold white]Target", + style=COLORS.G.SUBHEAD_EX_1, + justify="center", + ) + table.add_column( + "[bold white]Funds Account", + style=COLORS.G.SUBHEAD_EX_2, + justify="left", + overflow="fold", + ) + 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) + ), + ) + + for loan_id, loan in sorted_loans: + status = _status(loan, current_block) + time_label = _time_remaining(loan, current_block) + + raised_cell = ( + f"τ {loan.raised.tao:,.4f} / τ {loan.cap.tao:,.4f}" + if verbose + else f"τ {millify_tao(loan.raised.tao)} / τ {millify_tao(loan.cap.tao)}" + ) + + deposit_cell = ( + f"τ {loan.deposit.tao:,.4f}" + if verbose + else f"τ {millify_tao(loan.deposit.tao)}" + ) + + min_contrib_cell = ( + f"τ {loan.min_contribution.tao:,.4f}" + if verbose + else f"τ {millify_tao(loan.min_contribution.tao)}" + ) + + status_color_map = { + "Finalized": COLORS.G.SUCCESS, + "Funded": COLORS.P.EMISSION, + "Closed": COLORS.G.SYM, + "Active": COLORS.G.HINT, + } + status_color = status_color_map.get(status, "white") + status_cell = f"[{status_color}]{status}[/{status_color}]" + + if "Closed" in time_label: + time_cell = f"[{COLORS.G.SYM}]{time_label}[/{COLORS.G.SYM}]" + elif time_label == "due": + time_cell = f"[red]{time_label}[/red]" + else: + time_cell = time_label + + creator_cell = loan.creator if verbose else _shorten(loan.creator) + target_cell = ( + loan.target_address + if loan.target_address + else f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) + if not verbose and loan.target_address: + target_cell = _shorten(loan.target_address) + + funds_account_cell = ( + loan.funds_account if verbose else _shorten(loan.funds_account) + ) + + if loan.call_details: + pallet = loan.call_details.get("pallet", "") + method = loan.call_details.get("method", "") + + if pallet == "SubtensorModule" and method == "register_leased_network": + call_label = "[magenta]Subnet Leasing[/magenta]" + else: + call_label = ( + f"{pallet}.{method}" + if pallet and method + else method or pallet or "Unknown" + ) + + call_cell = call_label + elif loan.has_call: + call_cell = f"[{COLORS.G.SYM}]Unknown[/{COLORS.G.SYM}]" + else: + call_cell = "-" + + table.add_row( + str(loan_id), + status_cell, + raised_cell, + deposit_cell, + min_contrib_cell, + str(loan.end), + time_cell, + str(loan.contributors_count), + creator_cell, + target_cell, + funds_account_cell, + call_cell, + ) + + console.print(table) + + return True + + +async def show_crowdloan_details( + subtensor: SubtensorInterface, + crowdloan_id: int, + crowdloan: Optional[CrowdloanData] = None, + current_block: Optional[int] = None, + wallet: Optional[Wallet] = None, + verbose: bool = False, + json_output: 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( + subtensor.substrate.get_block_number(None), + subtensor.get_single_crowdloan(crowdloan_id), + ) + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + user_contribution = None + if wallet and wallet.coldkeypub: + user_contribution = await subtensor.get_crowdloan_contribution( + crowdloan_id, wallet.coldkeypub.ss58_address + ) + + status = _status(crowdloan, current_block) + status_color_map = { + "Finalized": COLORS.G.SUCCESS, + "Funded": COLORS.P.EMISSION, + "Closed": COLORS.G.SYM, + "Active": COLORS.G.HINT, + } + status_color = status_color_map.get(status, "white") + + if json_output: + time_remaining = _time_remaining(crowdloan, current_block) + + avg_contribution = None + if crowdloan.contributors_count > 0: + net_contributions = crowdloan.raised.tao - crowdloan.deposit.tao + avg_contribution = ( + net_contributions / (crowdloan.contributors_count - 1) + if crowdloan.contributors_count > 1 + else crowdloan.deposit.tao + ) + + call_info = None + if crowdloan.has_call and crowdloan.call_details: + pallet = crowdloan.call_details.get("pallet", "Unknown") + method = crowdloan.call_details.get("method", "Unknown") + args = crowdloan.call_details.get("args", {}) + + if pallet == "SubtensorModule" and method == "register_leased_network": + call_info = { + "type": "Subnet Leasing", + "pallet": pallet, + "method": method, + "emissions_share": args.get("emissions_share", {}).get("value"), + "end_block": args.get("end_block", {}).get("value"), + } + else: + call_info = {"pallet": pallet, "method": method, "args": args} + + user_contribution_info = None + if user_contribution: + is_creator = ( + wallet + and wallet.coldkeypub + and wallet.coldkeypub.ss58_address == crowdloan.creator + ) + withdrawable_amount = None + + if status == "Active" and not crowdloan.finalized: + if is_creator and user_contribution.tao > crowdloan.deposit.tao: + withdrawable_amount = user_contribution.tao - crowdloan.deposit.tao + elif not is_creator: + withdrawable_amount = user_contribution.tao + + user_contribution_info = { + "amount": user_contribution.tao, + "is_creator": is_creator, + "withdrawable": withdrawable_amount, + "refundable": status == "Closed", + } + + output_dict = { + "success": True, + "error": None, + "data": { + "crowdloan_id": crowdloan_id, + "status": status, + "finalized": crowdloan.finalized, + "creator": crowdloan.creator, + "funds_account": crowdloan.funds_account, + "raised": crowdloan.raised.tao, + "cap": crowdloan.cap.tao, + "raised_percentage": (crowdloan.raised.tao / crowdloan.cap.tao * 100) + if crowdloan.cap.tao > 0 + else 0, + "deposit": crowdloan.deposit.tao, + "min_contribution": crowdloan.min_contribution.tao, + "end_block": crowdloan.end, + "current_block": current_block, + "time_remaining": time_remaining, + "contributors_count": crowdloan.contributors_count, + "average_contribution": avg_contribution, + "target_address": crowdloan.target_address, + "has_call": crowdloan.has_call, + "call_details": call_info, + "user_contribution": user_contribution_info, + "network": subtensor.network, + }, + } + json_console.print(json.dumps(output_dict)) + return True, f"Displayed info for crowdloan #{crowdloan_id}" + + table = Table( + Column( + "Field", + style=COLORS.G.SUBHEAD, + min_width=20, + no_wrap=True, + ), + Column("Value", style=COLORS.G.TEMPO), + title=f"\n[underline][{COLORS.G.HEADER}]CROWDLOAN #{crowdloan_id}[/underline][/{COLORS.G.HEADER}] - [{status_color} underline]{status.upper()}[/{status_color} underline]", + show_header=False, + show_footer=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + expand=False, + ) + + # OVERVIEW Section + table.add_row("[cyan underline]OVERVIEW[/cyan underline]", "") + table.add_section() + + status_detail = "" + if status == "Active": + status_detail = " [dim](accepting contributions)[/dim]" + elif status == "Funded": + status_detail = " [yellow](awaiting finalization)[/yellow]" + elif status == "Closed": + status_detail = " [dim](failed to reach cap)[/dim]" + elif status == "Finalized": + status_detail = " [green](successfully completed)[/green]" + + table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") + table.add_row( + "Creator", + f"[{COLORS.G.TEMPO}]{crowdloan.creator}[/{COLORS.G.TEMPO}]", + ) + table.add_row( + "Funds Account", + f"[{COLORS.G.SUBHEAD_EX_2}]{crowdloan.funds_account}[/{COLORS.G.SUBHEAD_EX_2}]", + ) + + # FUNDING PROGRESS Section + table.add_section() + table.add_row("[cyan underline]FUNDING PROGRESS[/cyan underline]", "") + table.add_section() + + raised_pct = ( + (crowdloan.raised.tao / crowdloan.cap.tao * 100) if crowdloan.cap.tao > 0 else 0 + ) + progress_filled = int(raised_pct / 100 * 16) + progress_empty = 16 - progress_filled + progress_bar = f"[dark_sea_green]{'█' * progress_filled}[/dark_sea_green][grey35]{'░' * progress_empty}[/grey35]" + + if verbose: + raised_str = f"τ {crowdloan.raised.tao:,.4f} / τ {crowdloan.cap.tao:,.4f}" + deposit_str = f"τ {crowdloan.deposit.tao:,.4f}" + min_contrib_str = f"τ {crowdloan.min_contribution.tao:,.4f}" + else: + raised_str = f"τ {millify_tao(crowdloan.raised.tao)} / τ {millify_tao(crowdloan.cap.tao)}" + deposit_str = f"τ {millify_tao(crowdloan.deposit.tao)}" + min_contrib_str = f"τ {millify_tao(crowdloan.min_contribution.tao)}" + + table.add_row("Raised/Cap", raised_str) + table.add_row( + "Progress", f"{progress_bar} [dark_sea_green]{raised_pct:.2f}%[/dark_sea_green]" + ) + table.add_row("Deposit", deposit_str) + table.add_row("Min Contribution", min_contrib_str) + + # TIMELINE Section + table.add_section() + table.add_row("[cyan underline]TIMELINE[/cyan underline]", "") + table.add_section() + + time_label = _time_remaining(crowdloan, current_block) + if "Closed" in time_label: + time_display = f"[{COLORS.G.SYM}]{time_label}[/{COLORS.G.SYM}]" + elif time_label == "due": + time_display = "[red]Due now[/red]" + else: + time_display = f"[{COLORS.S.ALPHA}]{time_label}[/{COLORS.S.ALPHA}]" + + table.add_row("Ends at Block", f"{crowdloan.end}") + table.add_row("Current Block", f"{current_block}") + table.add_row("Time Remaining", time_display) + + # PARTICIPATION Section + table.add_section() + table.add_row("[cyan underline]PARTICIPATION[/cyan underline]", "") + table.add_section() + + table.add_row("Contributors", f"{crowdloan.contributors_count}") + + if crowdloan.contributors_count > 0: + net_contributions = crowdloan.raised.tao - crowdloan.deposit.tao + avg_contribution = ( + net_contributions / (crowdloan.contributors_count - 1) + if crowdloan.contributors_count > 1 + else crowdloan.deposit.tao + ) + if verbose: + avg_contrib_str = f"τ {avg_contribution:,.4f}" + else: + avg_contrib_str = f"τ {millify_tao(avg_contribution)}" + table.add_row("Avg Contribution", avg_contrib_str) + + if user_contribution: + is_creator = wallet.coldkeypub.ss58_address == crowdloan.creator + if verbose: + user_contrib_str = f"τ {user_contribution.tao:,.4f}" + else: + user_contrib_str = f"τ {millify_tao(user_contribution.tao)}" + + contrib_status = "" + if status == "Active" and not crowdloan.finalized: + if is_creator and user_contribution.tao > crowdloan.deposit.tao: + withdrawable = user_contribution.tao - crowdloan.deposit.tao + if verbose: + withdrawable_str = f"{withdrawable:,.4f}" + else: + withdrawable_str = f"{millify_tao(withdrawable)}" + contrib_status = ( + f" [yellow](τ {withdrawable_str} withdrawable)[/yellow]" + ) + elif not is_creator: + contrib_status = " [yellow](withdrawable)[/yellow]" + elif status == "Closed": + contrib_status = " [green](refundable)[/green]" + + your_contrib_value = f"{user_contrib_str}{contrib_status}" + if is_creator: + your_contrib_value += " [dim](You are the creator)[/dim]" + table.add_row("Your Contribution", your_contrib_value) + + # TARGET Section + table.add_section() + table.add_row("[cyan underline]TARGET[/cyan underline]", "") + table.add_section() + + if crowdloan.target_address: + target_display = crowdloan.target_address + else: + target_display = ( + f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) + + table.add_row("Address", target_display) + + table.add_section() + table.add_row("[cyan underline]CALL DETAILS[/cyan underline]", "") + table.add_section() + + has_call_display = ( + f"[{COLORS.G.SUCCESS}]Yes[/{COLORS.G.SUCCESS}]" + if crowdloan.has_call + else f"[{COLORS.G.SYM}]No[/{COLORS.G.SYM}]" + ) + table.add_row("Has Call", has_call_display) + + if crowdloan.has_call and crowdloan.call_details: + pallet = crowdloan.call_details.get("pallet", "Unknown") + method = crowdloan.call_details.get("method", "Unknown") + args = crowdloan.call_details.get("args", {}) + + if pallet == "SubtensorModule" and method == "register_leased_network": + table.add_row("Type", "[magenta]Subnet Leasing[/magenta]") + emissions_share = args.get("emissions_share", {}).get("value") + if emissions_share is not None: + table.add_row("Emissions Share", f"[cyan]{emissions_share}%[/cyan]") + + end_block = args.get("end_block", {}).get("value") + if end_block: + table.add_row("Lease Ends", f"Block {end_block}") + else: + table.add_row("Lease Duration", "[green]Perpetual[/green]") + else: + table.add_row("Pallet", pallet) + table.add_row("Method", method) + if args: + for arg_name, arg_data in args.items(): + if isinstance(arg_data, dict): + display_value = arg_data.get("value") + arg_type = arg_data.get("type") + else: + display_value = arg_data + arg_type = None + + if arg_type: + table.add_row( + f"{arg_name} [{arg_type}]", + str(display_value), + ) + else: + table.add_row(arg_name, str(display_value)) + + console.print(table) + return True, f"Displayed info for crowdloan #{crowdloan_id}" diff --git a/pyproject.toml b/pyproject.toml index d7e6150f..31df9fce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "pycryptodome>=3.0.0,<4.0.0", "PyYAML~=6.0.1", "rich>=13.7,<15.0", - "scalecodec==1.2.11", + "scalecodec==1.2.12", "typer>=0.16", "bittensor-wallet>=4.0.0", "packaging",