From a90d11d12854dd1ffda1894626f67f6fd66716f5 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 14 Oct 2025 19:33:48 -0700 Subject: [PATCH 01/26] add extrinsics --- bittensor/core/extrinsics/crowdloan.py | 538 +++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 bittensor/core/extrinsics/crowdloan.py diff --git a/bittensor/core/extrinsics/crowdloan.py b/bittensor/core/extrinsics/crowdloan.py new file mode 100644 index 0000000000..1f583a2967 --- /dev/null +++ b/bittensor/core/extrinsics/crowdloan.py @@ -0,0 +1,538 @@ +from typing import TYPE_CHECKING, Optional + +from bittensor.core.extrinsics.params import CrowdloanParams +from bittensor.core.types import ExtrinsicResponse + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.subtensor import Subtensor + from bittensor.utils.balance import Balance + from scalecodec.types import GenericCall + + +def create_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + deposit: "Balance", + min_contribution: "Balance", + cap: "Balance", + end: int, + call: Optional["GenericCall"] = None, + target_address: Optional[str] = None, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = False, +) -> "ExtrinsicResponse": + """ + Creates a new crowdloan campaign on-chain. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + deposit: Initial deposit in RAO from the creator. + min_contribution: Minimum contribution amount. + cap: Maximum cap to be raised. + end: Block number when the campaign ends. + call: Runtime call data (e.g., subtensor::register_leased_network). + target_address: SS58 address to transfer funds to on success. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="create", + call_params=CrowdloanParams.create( + deposit, min_contribution, cap, end, call, target_address + ), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def contribute_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + amount: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Contributes funds to an active crowdloan campaign. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to contribute to. + amount: Amount to contribute. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="contribute", + call_params=CrowdloanParams.contribute(crowdloan_id, amount), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def withdraw_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Withdraws a contribution from an active (not yet finalized or dissolved) crowdloan. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Wallet instance used to sign the transaction (must be unlocked). + crowdloan_id: The unique identifier of the crowdloan to withdraw from. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Note: + - Regular contributors can fully withdraw their contribution before finalization. + - The creator cannot withdraw the initial deposit, but may withdraw any amount exceeding his deposit. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="withdraw", + call_params=CrowdloanParams.withdraw(crowdloan_id), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def finalize_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Finalizes a successful crowdloan campaign once the cap has been reached and the end block has passed. + + This executes the stored call or transfers the raised funds to the target address, completing the campaign. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to finalize. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="finalize", + call_params=CrowdloanParams.finalize(crowdloan_id), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def refund_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Refunds contributors from a failed or expired crowdloan campaign. + + This call attempts to refund up to the limit defined by `RefundContributorsLimit` in a single dispatch. If there are + more contributors than the limit, the call may need to be executed multiple times until all refunds are processed. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to refund. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can be called by any signed account (not only the creator). + - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. + - Each call processes a limited number of refunds (`RefundContributorsLimit`). + - If the campaign has too many contributors, multiple refund calls are required. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="refund", + call_params=CrowdloanParams.refund(crowdloan_id), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def dissolve_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Dissolves a completed or failed crowdloan campaign after all refunds are processed. + + This permanently removes the campaign from on-chain storage and refunds the creator's remaining deposit, if + applicable. Can only be called by the campaign creator. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to dissolve. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can dissolve their own crowdloan. + - All contributors (except the creator) must have been refunded first. + - The creator’s remaining contribution (deposit) is returned during dissolution. + - After this call, the crowdloan is removed from chain storage. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="dissolve", + call_params=CrowdloanParams.dissolve(crowdloan_id), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def update_min_contribution_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + new_min_contribution: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Updates the minimum contribution amount of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new value must be greater than or equal to the + absolute minimum contribution defined in the chain configuration. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_min_contribution: The new minimum contribution amount (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can only be called by the creator of the crowdloan. + - The crowdloan must not be finalized. + - The new minimum contribution must not fall below the absolute minimum defined in the runtime. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="update_min_contribution", + call_params=CrowdloanParams.update_min_contribution( + crowdloan_id, new_min_contribution + ), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def update_end_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + new_end: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Updates the end block of a non-finalized crowdloan campaign. + + Only the creator of the crowdloan can perform this action. The new end block must be valid — meaning it cannot be in + the past and must respect the minimum and maximum duration limits enforced by the chain. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_end: The new block number at which the crowdloan will end. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can call this extrinsic. + - The crowdloan must not be finalized. + - The new end block must be later than the current block and within valid duration bounds (between + `MinimumBlockDuration` and `MaximumBlockDuration`). + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="update_end", + call_params=CrowdloanParams.update_end(crowdloan_id, new_end), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def update_cap_crowdloan_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + crowdloan_id: int, + new_cap: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Updates the fundraising cap (maximum total contribution) of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new cap must be greater than or equal to the + current amount already raised. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_cap: The new fundraising cap (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can update the cap. + - The crowdloan must not be finalized. + - The new cap must be greater than or equal to the total funds already raised. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = subtensor.compose_call( + call_module="Crowdloan", + call_function="update_cap", + call_params=CrowdloanParams.update_cap(crowdloan_id, new_cap), + ) + + return subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) From 9556f93b5d5997d7ed1e467dc7eae0459c664085 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 14 Oct 2025 19:34:01 -0700 Subject: [PATCH 02/26] add params --- bittensor/core/extrinsics/params/__init__.py | 2 + bittensor/core/extrinsics/params/crowdloan.py | 108 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 bittensor/core/extrinsics/params/crowdloan.py diff --git a/bittensor/core/extrinsics/params/__init__.py b/bittensor/core/extrinsics/params/__init__.py index 516dcf1f63..285377cfa0 100644 --- a/bittensor/core/extrinsics/params/__init__.py +++ b/bittensor/core/extrinsics/params/__init__.py @@ -1,4 +1,5 @@ from .children import ChildrenParams +from .crowdloan import CrowdloanParams from .liquidity import LiquidityParams from .move_stake import MoveStakeParams from .registration import RegistrationParams @@ -15,6 +16,7 @@ __all__ = [ "get_transfer_fn_params", "ChildrenParams", + "CrowdloanParams", "LiquidityParams", "MoveStakeParams", "RegistrationParams", diff --git a/bittensor/core/extrinsics/params/crowdloan.py b/bittensor/core/extrinsics/params/crowdloan.py new file mode 100644 index 0000000000..182e534b43 --- /dev/null +++ b/bittensor/core/extrinsics/params/crowdloan.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from bittensor.utils.balance import Balance + + +@dataclass +class CrowdloanParams: + @classmethod + def create( + cls, + deposit: "Balance", + min_contribution: "Balance", + cap: "Balance", + end: int, + call: Optional[str] = None, + target_address: Optional[str] = None, + ) -> dict: + """Returns the parameters for the `create`.""" + return { + "deposit": deposit.rao, + "min_contribution": min_contribution.rao, + "cap": cap.rao, + "end": end, + "call": call, + "target_address": target_address, + } + + @classmethod + def contribute( + cls, + crowdloan_id: int, + amount: "Balance", + ) -> dict: + """Returns the parameters for the `contribute`.""" + return { + "crowdloan_id": crowdloan_id, + "amount": amount.rao, + } + + @classmethod + def withdraw( + cls, + crowdloan_id: int, + ) -> dict: + """Returns the parameters for the `withdraw`.""" + return {"crowdloan_id": crowdloan_id} + + @classmethod + def finalize( + cls, + crowdloan_id: int, + ) -> dict: + """Returns the parameters for the `finalize`.""" + return {"crowdloan_id": crowdloan_id} + + @classmethod + def refund( + cls, + crowdloan_id: int, + ) -> dict: + """Returns the parameters for the `refund`.""" + return {"crowdloan_id": crowdloan_id} + + @classmethod + def dissolve( + cls, + crowdloan_id: int, + ) -> dict: + """Returns the parameters for the `dissolve`.""" + return {"crowdloan_id": crowdloan_id} + + @classmethod + def update_min_contribution( + cls, + crowdloan_id: int, + new_min_contribution: "Balance", + ) -> dict: + """Returns the parameters for the `update_min_contribution`.""" + return { + "crowdloan_id": crowdloan_id, + "new_min_contribution": new_min_contribution.rao, + } + + @classmethod + def update_end( + cls, + crowdloan_id: int, + new_end: int, + ) -> dict: + """Returns the parameters for the `update_end`.""" + return { + "crowdloan_id": crowdloan_id, + "new_end": new_end, + } + + @classmethod + def update_cap( + cls, + crowdloan_id: int, + new_cap: "Balance", + ) -> dict: + """Returns the parameters for the `update_cap`.""" + return { + "crowdloan_id": crowdloan_id, + "new_cap": new_cap.rao, + } From 41598c4fa8780e83cf971c95803024a69a7e3ea1 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 14 Oct 2025 19:34:24 -0700 Subject: [PATCH 03/26] add `bittensor.core.chain_data.crowdloan_info.CrowdloanInfo` --- bittensor/core/chain_data/__init__.py | 3 + bittensor/core/chain_data/crowdloan_info.py | 114 ++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 bittensor/core/chain_data/crowdloan_info.py diff --git a/bittensor/core/chain_data/__init__.py b/bittensor/core/chain_data/__init__.py index 6a423501f7..a232d8b651 100644 --- a/bittensor/core/chain_data/__init__.py +++ b/bittensor/core/chain_data/__init__.py @@ -7,6 +7,7 @@ from .axon_info import AxonInfo from .chain_identity import ChainIdentity +from .crowdloan_info import CrowdloanInfo, CrowdloanConstants from .delegate_info import DelegateInfo, DelegatedInfo from .delegate_info_lite import DelegateInfoLite from .dynamic_info import DynamicInfo @@ -37,6 +38,8 @@ __all__ = [ "AxonInfo", "ChainIdentity", + "CrowdloanInfo", + "CrowdloanConstants", "DelegateInfo", "DelegatedInfo", "DelegateInfoLite", diff --git a/bittensor/core/chain_data/crowdloan_info.py b/bittensor/core/chain_data/crowdloan_info.py new file mode 100644 index 0000000000..da5b368e28 --- /dev/null +++ b/bittensor/core/chain_data/crowdloan_info.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass +from typing import Optional +from bittensor.core.chain_data.utils import decode_account_id +from scalecodec import ScaleBytes +from bittensor.utils.balance import Balance + + +@dataclass +class CrowdloanInfo: + """ + Represents a single on-chain crowdloan campaign from the `pallet-crowdloan`. + + Each instance reflects the current state of a specific crowdloan as stored in chain storage. It includes funding + details, creator information, contribution totals, and optional call/target data that define what happens upon + successful finalization. + + Attributes: + id: The unique identifier (index) of the crowdloan. + creator: The SS58 address of the creator (campaign initiator). + deposit: The creator's initial deposit locked to open the crowdloan. + min_contribution: The minimum contribution amount allowed per participant. + end: The block number when the campaign ends. + cap: The maximum amount to be raised (funding cap). + funds_account: The account ID holding the crowdloan’s funds. + raised: The total amount raised so far. + target_address: Optional SS58 address to which funds are transferred upon success. + call: Optional encoded runtime call (e.g., a `register_leased_network` extrinsic) to execute on finalize. + finalized: Whether the crowdloan has been finalized on-chain. + contributors_count: Number of unique contributors currently participating. + """ + + id: int + creator: str + deposit: Balance + min_contribution: Balance + end: int + cap: Balance + funds_account: str + raised: Balance + target_address: Optional[str] + call: Optional[str] + finalized: bool + contributors_count: int + + @classmethod + def from_dict(cls, idx: int, data: dict) -> "CrowdloanInfo": + """Returns a CrowdloanInfo object from decoded chain data.""" + return cls( + id=idx, + creator=decode_account_id(data["creator"]), + deposit=Balance.from_rao(data["deposit"]), + min_contribution=Balance.from_rao(data["min_contribution"]), + end=data["end"], + cap=Balance.from_rao(data["cap"]), + funds_account=decode_account_id(data["funds_account"]) + if data.get("funds_account") + else None, + raised=Balance.from_rao(data["raised"]), + target_address=decode_account_id(data.get("target_address")) + if data.get("target_address") + else None, + call=data.get("call") if data.get("call") else None, + finalized=data["finalized"], + contributors_count=data["contributors_count"], + ) + + +@dataclass +class CrowdloanConstants: + """ + Represents all runtime constants defined in the `pallet-crowdloan`. + + These attributes correspond directly to on-chain configuration constants exposed by the Crowdloan pallet. They + define contribution limits, duration bounds, pallet identifiers, and refund behavior that govern how crowdloan + campaigns operate within the Subtensor network. + + Each attribute is fetched directly from the runtime via `Subtensor.substrate.get_constant("Crowdloan", )` and + reflects the current chain configuration at the time of retrieval. + + Attributes: + AbsoluteMinimumContribution: The absolute minimum amount required to contribute to any crowdloan. + MaxContributors: The maximum number of unique contributors allowed per crowdloan. + MaximumBlockDuration: The maximum allowed duration (in blocks) for a crowdloan campaign. + MinimumDeposit: The minimum deposit required from the creator to open a new crowdloan. + MinimumBlockDuration: The minimum allowed duration (in blocks) for a crowdloan campaign. + RefundContributorsLimit: The maximum number of contributors that can be refunded in single on-chain refund call. + """ + + AbsoluteMinimumContribution: Optional["Balance"] + MaxContributors: Optional[int] + MaximumBlockDuration: Optional[int] + MinimumDeposit: Optional["Balance"] + MinimumBlockDuration: Optional[int] + RefundContributorsLimit: Optional[int] + + @classmethod + def constants_names(cls) -> list[str]: + """Returns the list of all constant field names defined in this dataclass.""" + from dataclasses import fields + + return [f.name for f in fields(cls)] + + @classmethod + def from_dict(cls, data: dict) -> "CrowdloanConstants": + """ + Creates a `CrowdloanConstants` instance from a dictionary of decoded chain constants. + + Parameters: + data: Dictionary mapping constant names to their decoded values (returned by `Subtensor.query_constant()`). + + Returns: + CrowdloanConstants: The structured dataclass with constants filled in. + """ + return cls(**{name: data.get(name) for name in cls.constants_names()}) From c6df44ced7b17d835477792466ab588f5fd263db Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 14 Oct 2025 19:34:38 -0700 Subject: [PATCH 04/26] add subtensor methods --- bittensor/core/subtensor.py | 190 ++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index f5a96a91a5..807ae8637a 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -15,6 +15,8 @@ from bittensor.core.async_subtensor import ProposalVoteData from bittensor.core.axon import Axon from bittensor.core.chain_data import ( + CrowdloanInfo, + CrowdloanConstants, DelegatedInfo, DelegateInfo, DynamicInfo, @@ -196,6 +198,32 @@ def __exit__(self, exc_type, exc_val, exc_tb): # Helpers ========================================================================================================== + def _decode_crowdloan_entry( + self, + crowdloan_id: int, + data: dict, + block_hash: Optional[str] = None, + ) -> "CrowdloanInfo": + """ + Internal helper to parse and decode a single Crowdloan record. + + Automatically decodes the embedded `call` field if present (Inline SCALE format). + """ + call_data = data.get("call") + if call_data and "Inline" in call_data: + try: + inline_bytes = bytes(call_data["Inline"][0][0]) + decoded_call = self.substrate.create_scale_object( + type_string="Call", + data=scalecodec.ScaleBytes(inline_bytes), + block_hash=block_hash, + ).decode() + data["call"] = decoded_call + except Exception as e: + data["call"] = {"decode_error": str(e), "raw": call_data} + + return CrowdloanInfo.from_dict(crowdloan_id, data) + @lru_cache(maxsize=128) def _get_block_hash(self, block_id: int): return self.substrate.get_block_hash(block_id) @@ -1233,6 +1261,168 @@ def get_commitment_metadata( ) return commit_data + def get_crowdloan_constants( + self, + constants: Optional[list[str]] = None, + block: Optional[int] = None, + ) -> "CrowdloanConstants": + """ + Fetches runtime configuration constants from the `Crowdloan` pallet. + + If a list of constant names is provided, only those constants will be queried. + Otherwise, all known constants defined in `CrowdloanConstants.field_names()` are fetched. + + Parameters: + constants: A list of specific constant names to fetch from the pallet. If omitted, all constants from + `CrowdloanConstants` are queried. + block: The blockchain block number for the query. + + Returns: + CrowdloanConstants: + A structured dataclass containing the retrieved values. Missing constants are returned as `None`. + + Example: + print(subtensor.get_crowdloan_constants()) + CrowdloanConstants( + AbsoluteMinimumContribution=τ1.000000000, + MaxContributors=1000, + MaximumBlockDuration=86400, + MinimumDeposit=τ10.000000000, + MinimumBlockDuration=600, + RefundContributorsLimit=50 + ) + + crowdloan_consts = subtensor.get_crowdloan_constants( + constants=["MaxContributors", "RefundContributorsLimit"] + ) + print(crowdloan_consts) + CrowdloanConstants(MaxContributors=1000, RefundContributorsLimit=50) + + print(crowdloan_consts.MaxContributors) + 1000 + """ + result = {} + const_names = constants or CrowdloanConstants.constants_names() + + for const_name in const_names: + value = self.query_constant( + module_name="Crowdloan", + constant_name=const_name, + block=block, + ) + result[const_name] = value.value if hasattr(value, "value") else value + + return CrowdloanConstants.from_dict(result) + + def get_crowdloan_contributions( + self, + crowdloan_id: int, + block: Optional[int] = None, + ) -> dict[str, "Balance"]: + """ + Returns a mapping of contributor SS58 addresses to their contribution amounts for a specific crowdloan. + + Parameters: + crowdloan_id: The unique identifier of the crowdloan. + block: The blockchain block number for the query. + + Returns: + Dict[address -> Balance]. + """ + block_hash = self.determine_block_hash(block) + query = self.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + block_hash=block_hash, + ) + result = {} + for record in query.records: + if record[1].value: + result[decode_account_id(record[0])] = Balance.from_rao(record[1].value) + return result + + def get_crowdloan_by_id( + self, crowdloan_id: int, block: Optional[int] = None + ) -> Optional["CrowdloanInfo"]: + """ + Returns detailed information about a specific crowdloan by ID. + + Parameters: + crowdloan_id: Unique identifier of the crowdloan. + block: The blockchain block number for the query. + + Returns: + CrowdloanInfo if found, else None. + """ + block_hash = self.determine_block_hash(block) + query = self.substrate.query( + module="Crowdloan", + storage_function="Crowdloans", + params=[crowdloan_id], + block_hash=block_hash, + ) + if not query: + return None + return self._decode_crowdloan_entry( + crowdloan_id=crowdloan_id, data=query.value, block_hash=block_hash + ) + + def get_crowdloan_next_id( + self, + block: Optional[int] = None, + ) -> int: + """ + Returns the next available crowdloan ID (auto-increment value). + + Parameters: + block: The blockchain block number for the query. + + Returns: + The next crowdloan ID to be used when creating a new campaign. + """ + block_hash = self.determine_block_hash(block) + result = self.substrate.query( + module="Crowdloan", + storage_function="NextCrowdloanId", + block_hash=block_hash, + ) + return int(result.value or 0) + + def get_crowdloans( + self, + block: Optional[int] = None, + ) -> list["CrowdloanInfo"]: + """ + Returns a list of all existing crowdloans with their metadata. + + Parameters: + block: The blockchain block number for the query. + + Returns: + List of CrowdloanInfo which contains (id, creator, cap, raised, end, finalized, etc.) + """ + block_hash = self.determine_block_hash(block) + query = self.substrate.query_map( + module="Crowdloan", + storage_function="Crowdloans", + block_hash=block_hash, + ) + + crowdloans = [] + + for c_id, value_obj in getattr(query, "records", []): + data = value_obj.value + if not data: + continue + crowdloans.append( + self._decode_crowdloan_entry( + crowdloan_id=c_id, data=data, block_hash=block_hash + ) + ) + + return crowdloans + def get_delegate_by_hotkey( self, hotkey_ss58: str, block: Optional[int] = None ) -> Optional["DelegateInfo"]: From e972c27bb2a83271b06a33260b8fefa88539381a Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 14 Oct 2025 19:38:22 -0700 Subject: [PATCH 05/26] unused import --- bittensor/core/chain_data/crowdloan_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor/core/chain_data/crowdloan_info.py b/bittensor/core/chain_data/crowdloan_info.py index da5b368e28..ba6be90798 100644 --- a/bittensor/core/chain_data/crowdloan_info.py +++ b/bittensor/core/chain_data/crowdloan_info.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional + from bittensor.core.chain_data.utils import decode_account_id -from scalecodec import ScaleBytes from bittensor.utils.balance import Balance From 172248cdf3b0964c2e2d1f9893fce97fc064a7aa Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 14 Oct 2025 19:53:08 -0700 Subject: [PATCH 06/26] update SubtensorApi and related test --- bittensor/core/subtensor.py | 6 ++++-- bittensor/extras/subtensor_api/__init__.py | 6 ++++++ bittensor/extras/subtensor_api/crowdloans.py | 14 ++++++++++++++ bittensor/extras/subtensor_api/utils.py | 9 +++++++++ tests/unit_tests/test_subtensor_api.py | 4 ++++ 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 bittensor/extras/subtensor_api/crowdloans.py diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 807ae8637a..24e341ce59 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -1305,12 +1305,14 @@ def get_crowdloan_constants( const_names = constants or CrowdloanConstants.constants_names() for const_name in const_names: - value = self.query_constant( + query = self.query_constant( module_name="Crowdloan", constant_name=const_name, block=block, ) - result[const_name] = value.value if hasattr(value, "value") else value + + if query is not None: + result[const_name] = query.value return CrowdloanConstants.from_dict(result) diff --git a/bittensor/extras/subtensor_api/__init__.py b/bittensor/extras/subtensor_api/__init__.py index 15316ae9c7..68dc1890ae 100644 --- a/bittensor/extras/subtensor_api/__init__.py +++ b/bittensor/extras/subtensor_api/__init__.py @@ -4,6 +4,7 @@ from bittensor.core.subtensor import Subtensor as _Subtensor from .chain import Chain as _Chain from .commitments import Commitments as _Commitments +from .crowdloans import Crowdloans as _Crowdloans from .delegates import Delegates as _Delegates from .extrinsics import Extrinsics as _Extrinsics from .metagraphs import Metagraphs as _Metagraphs @@ -209,6 +210,11 @@ def commitments(self): """Property to access commitments methods.""" return _Commitments(self.inner_subtensor) + @property + def crowdloans(self): + """Property to access crowdloans methods.""" + return _Crowdloans(self.inner_subtensor) + @property def delegates(self): """Property to access delegates methods.""" diff --git a/bittensor/extras/subtensor_api/crowdloans.py b/bittensor/extras/subtensor_api/crowdloans.py new file mode 100644 index 0000000000..0aa3d820e5 --- /dev/null +++ b/bittensor/extras/subtensor_api/crowdloans.py @@ -0,0 +1,14 @@ +from typing import Union +from bittensor.core.subtensor import Subtensor as _Subtensor +from bittensor.core.async_subtensor import AsyncSubtensor as _AsyncSubtensor + + +class Crowdloans: + """Class for managing any Crowdloans operations.""" + + def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): + self.get_crowdloan_constants = subtensor.get_crowdloan_constants + self.get_crowdloan_contributions = subtensor.get_crowdloan_contributions + self.get_crowdloan_by_id = subtensor.get_crowdloan_by_id + self.get_crowdloan_next_id = subtensor.get_crowdloan_next_id + self.get_crowdloans = subtensor.get_crowdloans diff --git a/bittensor/extras/subtensor_api/utils.py b/bittensor/extras/subtensor_api/utils.py index 762957145c..4e31fdc7bf 100644 --- a/bittensor/extras/subtensor_api/utils.py +++ b/bittensor/extras/subtensor_api/utils.py @@ -19,6 +19,15 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.chain_endpoint = subtensor.inner_subtensor.chain_endpoint subtensor.commit_reveal_enabled = subtensor.inner_subtensor.commit_reveal_enabled subtensor.commit_weights = subtensor.inner_subtensor.commit_weights + subtensor.get_crowdloan_constants = ( + subtensor.inner_subtensor.get_crowdloan_constants + ) + subtensor.get_crowdloan_contributions = ( + subtensor.inner_subtensor.get_crowdloan_contributions + ) + subtensor.get_crowdloan_by_id = subtensor.inner_subtensor.get_crowdloan_by_id + subtensor.get_crowdloan_next_id = subtensor.inner_subtensor.get_crowdloan_next_id + subtensor.get_crowdloans = subtensor.inner_subtensor.get_crowdloans subtensor.determine_block_hash = subtensor.inner_subtensor.determine_block_hash subtensor.difficulty = subtensor.inner_subtensor.difficulty subtensor.does_hotkey_exist = subtensor.inner_subtensor.does_hotkey_exist diff --git a/tests/unit_tests/test_subtensor_api.py b/tests/unit_tests/test_subtensor_api.py index 7eaac5fc3a..c16f8c59c2 100644 --- a/tests/unit_tests/test_subtensor_api.py +++ b/tests/unit_tests/test_subtensor_api.py @@ -20,6 +20,9 @@ def test_properties_methods_comparable(other_class: "Subtensor" = None): subtensor_api_methods = [m for m in dir(subtensor_api) if not m.startswith("_")] chain_methods = [m for m in dir(subtensor_api.chain) if not m.startswith("_")] + crowdloans_methods = [ + m for m in dir(subtensor_api.crowdloans) if not m.startswith("_") + ] commitments_methods = [ m for m in dir(subtensor_api.commitments) if not m.startswith("_") ] @@ -42,6 +45,7 @@ def test_properties_methods_comparable(other_class: "Subtensor" = None): subtensor_api_methods + chain_methods + commitments_methods + + crowdloans_methods + delegates_methods + extrinsics_methods + metagraphs_methods From b76660e4e1ec6e987ee1b702c314b550c740952b Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 00:02:41 -0700 Subject: [PATCH 07/26] update SubtensorApi --- bittensor/extras/subtensor_api/crowdloans.py | 11 +++++++++++ bittensor/extras/subtensor_api/extrinsics.py | 11 +++++++++++ bittensor/extras/subtensor_api/utils.py | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/bittensor/extras/subtensor_api/crowdloans.py b/bittensor/extras/subtensor_api/crowdloans.py index 0aa3d820e5..f07ba78f3b 100644 --- a/bittensor/extras/subtensor_api/crowdloans.py +++ b/bittensor/extras/subtensor_api/crowdloans.py @@ -7,8 +7,19 @@ class Crowdloans: """Class for managing any Crowdloans operations.""" def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): + self.contribute_crowdloan = subtensor.contribute_crowdloan + self.create_crowdloan = subtensor.create_crowdloan + self.dissolve_crowdloan = subtensor.dissolve_crowdloan + self.finalize_crowdloan = subtensor.finalize_crowdloan self.get_crowdloan_constants = subtensor.get_crowdloan_constants self.get_crowdloan_contributions = subtensor.get_crowdloan_contributions self.get_crowdloan_by_id = subtensor.get_crowdloan_by_id self.get_crowdloan_next_id = subtensor.get_crowdloan_next_id self.get_crowdloans = subtensor.get_crowdloans + self.refund_crowdloan = subtensor.refund_crowdloan + self.update_cap_crowdloan = subtensor.update_cap_crowdloan + self.update_end_crowdloan = subtensor.update_end_crowdloan + self.update_min_contribution_crowdloan = ( + subtensor.update_min_contribution_crowdloan + ) + self.withdraw_crowdloan = subtensor.withdraw_crowdloan diff --git a/bittensor/extras/subtensor_api/extrinsics.py b/bittensor/extras/subtensor_api/extrinsics.py index b428f5e247..7ecf8aa1d5 100644 --- a/bittensor/extras/subtensor_api/extrinsics.py +++ b/bittensor/extras/subtensor_api/extrinsics.py @@ -12,9 +12,14 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.add_stake_multiple = subtensor.add_stake_multiple self.burned_register = subtensor.burned_register self.commit_weights = subtensor.commit_weights + self.contribute_crowdloan = subtensor.contribute_crowdloan + self.create_crowdloan = subtensor.create_crowdloan + self.dissolve_crowdloan = subtensor.dissolve_crowdloan + self.finalize_crowdloan = subtensor.finalize_crowdloan self.get_extrinsic_fee = subtensor.get_extrinsic_fee self.modify_liquidity = subtensor.modify_liquidity self.move_stake = subtensor.move_stake + self.refund_crowdloan = subtensor.refund_crowdloan self.register = subtensor.register self.register_subnet = subtensor.register_subnet self.remove_liquidity = subtensor.remove_liquidity @@ -36,4 +41,10 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.unstake = subtensor.unstake self.unstake_all = subtensor.unstake_all self.unstake_multiple = subtensor.unstake_multiple + self.update_cap_crowdloan = subtensor.update_cap_crowdloan + self.update_end_crowdloan = subtensor.update_end_crowdloan + self.update_min_contribution_crowdloan = ( + subtensor.update_min_contribution_crowdloan + ) self.validate_extrinsic_params = subtensor.validate_extrinsic_params + self.withdraw_crowdloan = subtensor.withdraw_crowdloan diff --git a/bittensor/extras/subtensor_api/utils.py b/bittensor/extras/subtensor_api/utils.py index 4e31fdc7bf..ecc0d470f3 100644 --- a/bittensor/extras/subtensor_api/utils.py +++ b/bittensor/extras/subtensor_api/utils.py @@ -19,6 +19,10 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.chain_endpoint = subtensor.inner_subtensor.chain_endpoint subtensor.commit_reveal_enabled = subtensor.inner_subtensor.commit_reveal_enabled subtensor.commit_weights = subtensor.inner_subtensor.commit_weights + subtensor.contribute_crowdloan = subtensor.inner_subtensor.contribute_crowdloan + subtensor.create_crowdloan = subtensor.inner_subtensor.create_crowdloan + subtensor.dissolve_crowdloan = subtensor.inner_subtensor.dissolve_crowdloan + subtensor.finalize_crowdloan = subtensor.inner_subtensor.finalize_crowdloan subtensor.get_crowdloan_constants = ( subtensor.inner_subtensor.get_crowdloan_constants ) @@ -176,6 +180,7 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.query_runtime_api = subtensor.inner_subtensor.query_runtime_api subtensor.query_subtensor = subtensor.inner_subtensor.query_subtensor subtensor.recycle = subtensor.inner_subtensor.recycle + subtensor.refund_crowdloan = subtensor.inner_subtensor.refund_crowdloan subtensor.register = subtensor.inner_subtensor.register subtensor.register_subnet = subtensor.inner_subtensor.register_subnet subtensor.remove_liquidity = subtensor.inner_subtensor.remove_liquidity @@ -212,9 +217,15 @@ def add_legacy_methods(subtensor: "SubtensorApi"): subtensor.unstake = subtensor.inner_subtensor.unstake subtensor.unstake_all = subtensor.inner_subtensor.unstake_all subtensor.unstake_multiple = subtensor.inner_subtensor.unstake_multiple + subtensor.update_cap_crowdloan = subtensor.inner_subtensor.update_cap_crowdloan + subtensor.update_end_crowdloan = subtensor.inner_subtensor.update_end_crowdloan + subtensor.update_min_contribution_crowdloan = ( + subtensor.inner_subtensor.update_min_contribution_crowdloan + ) subtensor.validate_extrinsic_params = ( subtensor.inner_subtensor.validate_extrinsic_params ) subtensor.wait_for_block = subtensor.inner_subtensor.wait_for_block subtensor.weights = subtensor.inner_subtensor.weights subtensor.weights_rate_limit = subtensor.inner_subtensor.weights_rate_limit + subtensor.withdraw_crowdloan = subtensor.inner_subtensor.withdraw_crowdloan From 037d4d9c2a676945983e21980445727cc77d7a0e Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 00:41:48 -0700 Subject: [PATCH 08/26] add sync extrinsic's subtensor methods --- bittensor/core/subtensor.py | 400 ++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 24e341ce59..247753a729 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -45,6 +45,17 @@ set_children_extrinsic, root_set_pending_childkey_cooldown_extrinsic, ) +from bittensor.core.extrinsics.crowdloan import ( + contribute_crowdloan_extrinsic, + create_crowdloan_extrinsic, + dissolve_crowdloan_extrinsic, + finalize_crowdloan_extrinsic, + refund_crowdloan_extrinsic, + update_cap_crowdloan_extrinsic, + update_end_crowdloan_extrinsic, + update_min_contribution_crowdloan_extrinsic, + withdraw_crowdloan_extrinsic, +) from bittensor.core.extrinsics.liquidity import ( add_liquidity_extrinsic, modify_liquidity_extrinsic, @@ -3941,6 +3952,173 @@ def commit_weights( ) return response + def contribute_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + amount: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Contributes funds to an active crowdloan campaign. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to contribute to. + amount: Amount to contribute. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return contribute_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + def create_crowdloan( + self, + wallet: "Wallet", + deposit: "Balance", + min_contribution: "Balance", + cap: "Balance", + end: int, + call: Optional["GenericCall"] = None, + target_address: Optional[str] = None, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = False, + ) -> ExtrinsicResponse: + """ + Creates a new crowdloan campaign on-chain. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + deposit: Initial deposit in RAO from the creator. + min_contribution: Minimum contribution amount. + cap: Maximum cap to be raised. + end: Block number when the campaign ends. + call: Runtime call data (e.g., subtensor::register_leased_network). + target_address: SS58 address to transfer funds to on success. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return create_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + deposit=deposit, + min_contribution=min_contribution, + cap=cap, + end=end, + call=call, + target_address=target_address, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + def dissolve_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Dissolves a completed or failed crowdloan campaign after all refunds are processed. + + This permanently removes the campaign from on-chain storage and refunds the creator's remaining deposit, if + applicable. Can only be called by the campaign creator. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to dissolve. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can dissolve their own crowdloan. + - All contributors (except the creator) must have been refunded first. + - The creator’s remaining contribution (deposit) is returned during dissolution. + - After this call, the crowdloan is removed from chain storage. + """ + return dissolve_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + def finalize_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Finalizes a successful crowdloan campaign once the cap has been reached and the end block has passed. + + This executes the stored call or transfers the raised funds to the target address, completing the campaign. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to finalize. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return finalize_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + def modify_liquidity( self, wallet: "Wallet", @@ -4064,6 +4242,50 @@ def move_stake( wait_for_finalization=wait_for_finalization, ) + def refund_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Refunds contributors from a failed or expired crowdloan campaign. + + This call attempts to refund up to the limit defined by `RefundContributorsLimit` in a single dispatch. If there are + more contributors than the limit, the call may need to be executed multiple times until all refunds are processed. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to refund. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can be called by any signed account (not only the creator). + - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. + - Each call processes a limited number of refunds (`RefundContributorsLimit`). + - If the campaign has too many contributors, multiple refund calls are required. + """ + return refund_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + def register( self, wallet: "Wallet", @@ -5256,3 +5478,181 @@ def unstake_multiple( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) + + def update_cap_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + new_cap: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Updates the fundraising cap (maximum total contribution) of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new cap must be greater than or equal to the + current amount already raised. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_cap: The new fundraising cap (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can update the cap. + - The crowdloan must not be finalized. + - The new cap must be greater than or equal to the total funds already raised. + """ + return update_cap_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_cap=new_cap, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + def update_end_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + new_end: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Updates the end block of a non-finalized crowdloan campaign. + + Only the creator of the crowdloan can perform this action. The new end block must be valid — meaning it cannot be in + the past and must respect the minimum and maximum duration limits enforced by the chain. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_end: The new block number at which the crowdloan will end. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can call this extrinsic. + - The crowdloan must not be finalized. + - The new end block must be later than the current block and within valid duration bounds (between + `MinimumBlockDuration` and `MaximumBlockDuration`). + """ + return update_end_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_end=new_end, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + def update_min_contribution_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + new_min_contribution: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Updates the minimum contribution amount of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new value must be greater than or equal to the + absolute minimum contribution defined in the chain configuration. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_min_contribution: The new minimum contribution amount (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can only be called by the creator of the crowdloan. + - The crowdloan must not be finalized. + - The new minimum contribution must not fall below the absolute minimum defined in the runtime. + """ + return update_min_contribution_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_min_contribution=new_min_contribution, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + def withdraw_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Withdraws a contribution from an active (not yet finalized or dissolved) crowdloan. + + Parameters: + wallet: Wallet instance used to sign the transaction (must be unlocked). + crowdloan_id: The unique identifier of the crowdloan to withdraw from. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Note: + - Regular contributors can fully withdraw their contribution before finalization. + - The creator cannot withdraw the initial deposit, but may withdraw any amount exceeding his deposit. + """ + return withdraw_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) From 0bb23c5145e6751d158e44f4b3c1905b2268f473 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 00:42:01 -0700 Subject: [PATCH 09/26] add async extrinsics --- .../core/extrinsics/asyncex/crowdloan.py | 549 ++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 bittensor/core/extrinsics/asyncex/crowdloan.py diff --git a/bittensor/core/extrinsics/asyncex/crowdloan.py b/bittensor/core/extrinsics/asyncex/crowdloan.py new file mode 100644 index 0000000000..63a68cfd34 --- /dev/null +++ b/bittensor/core/extrinsics/asyncex/crowdloan.py @@ -0,0 +1,549 @@ +from typing import TYPE_CHECKING, Optional + +from bittensor.core.extrinsics.params import CrowdloanParams +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import check_balance_amount + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.async_subtensor import AsyncSubtensor + from bittensor.utils.balance import Balance + from scalecodec.types import GenericCall + + +async def contribute_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + amount: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Contributes funds to an active crowdloan campaign. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to contribute to. + amount: Amount to contribute. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + check_balance_amount(amount) + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="contribute", + call_params=CrowdloanParams.contribute(crowdloan_id, amount), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def create_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + deposit: "Balance", + min_contribution: "Balance", + cap: "Balance", + end: int, + call: Optional["GenericCall"] = None, + target_address: Optional[str] = None, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = False, +) -> "ExtrinsicResponse": + """ + Creates a new crowdloan campaign on-chain. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + deposit: Initial deposit in RAO from the creator. + min_contribution: Minimum contribution amount. + cap: Maximum cap to be raised. + end: Block number when the campaign ends. + call: Runtime call data (e.g., subtensor::register_leased_network). + target_address: SS58 address to transfer funds to on success. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + check_balance_amount(deposit) + check_balance_amount(min_contribution) + check_balance_amount(cap) + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="create", + call_params=CrowdloanParams.create( + deposit, min_contribution, cap, end, call, target_address + ), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def dissolve_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Dissolves a completed or failed crowdloan campaign after all refunds are processed. + + This permanently removes the campaign from on-chain storage and refunds the creator's remaining deposit, if + applicable. Can only be called by the campaign creator. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to dissolve. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can dissolve their own crowdloan. + - All contributors (except the creator) must have been refunded first. + - The creator’s remaining contribution (deposit) is returned during dissolution. + - After this call, the crowdloan is removed from chain storage. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="dissolve", + call_params=CrowdloanParams.dissolve(crowdloan_id), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def finalize_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Finalizes a successful crowdloan campaign once the cap has been reached and the end block has passed. + + This executes the stored call or transfers the raised funds to the target address, completing the campaign. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to finalize. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="finalize", + call_params=CrowdloanParams.finalize(crowdloan_id), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def refund_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Refunds contributors from a failed or expired crowdloan campaign. + + This call attempts to refund up to the limit defined by `RefundContributorsLimit` in a single dispatch. If there are + more contributors than the limit, the call may need to be executed multiple times until all refunds are processed. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to refund. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can be called by any signed account (not only the creator). + - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. + - Each call processes a limited number of refunds (`RefundContributorsLimit`). + - If the campaign has too many contributors, multiple refund calls are required. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="refund", + call_params=CrowdloanParams.refund(crowdloan_id), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def update_cap_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + new_cap: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Updates the fundraising cap (maximum total contribution) of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new cap must be greater than or equal to the + current amount already raised. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_cap: The new fundraising cap (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can update the cap. + - The crowdloan must not be finalized. + - The new cap must be greater than or equal to the total funds already raised. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + check_balance_amount(new_cap) + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="update_cap", + call_params=CrowdloanParams.update_cap(crowdloan_id, new_cap), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def update_end_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + new_end: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Updates the end block of a non-finalized crowdloan campaign. + + Only the creator of the crowdloan can perform this action. The new end block must be valid — meaning it cannot be in + the past and must respect the minimum and maximum duration limits enforced by the chain. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_end: The new block number at which the crowdloan will end. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can call this extrinsic. + - The crowdloan must not be finalized. + - The new end block must be later than the current block and within valid duration bounds (between + `MinimumBlockDuration` and `MaximumBlockDuration`). + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="update_end", + call_params=CrowdloanParams.update_end(crowdloan_id, new_end), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def update_min_contribution_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + new_min_contribution: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Updates the minimum contribution amount of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new value must be greater than or equal to the + absolute minimum contribution defined in the chain configuration. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_min_contribution: The new minimum contribution amount (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can only be called by the creator of the crowdloan. + - The crowdloan must not be finalized. + - The new minimum contribution must not fall below the absolute minimum defined in the runtime. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + check_balance_amount(new_min_contribution) + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="update_min_contribution", + call_params=CrowdloanParams.update_min_contribution( + crowdloan_id, new_min_contribution + ), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def withdraw_crowdloan_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Withdraws a contribution from an active (not yet finalized or dissolved) crowdloan. + + Parameters: + subtensor: Active Subtensor connection. + wallet: Wallet instance used to sign the transaction (must be unlocked). + crowdloan_id: The unique identifier of the crowdloan to withdraw from. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Note: + - Regular contributors can fully withdraw their contribution before finalization. + - The creator cannot withdraw the initial deposit, but may withdraw any amount exceeding his deposit. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + extrinsic_call = await subtensor.compose_call( + call_module="Crowdloan", + call_function="withdraw", + call_params=CrowdloanParams.withdraw(crowdloan_id), + ) + + return await subtensor.sign_and_send_extrinsic( + call=extrinsic_call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) From 6f17cef763070b883b6113ae10548be4958c0045 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 00:43:21 -0700 Subject: [PATCH 10/26] update async subtensor --- bittensor/core/async_subtensor.py | 625 ++++++++++++++++++++++++++++++ 1 file changed, 625 insertions(+) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 3e029e5b0b..262fb673b5 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -14,6 +14,8 @@ from scalecodec import GenericCall from bittensor.core.chain_data import ( + CrowdloanInfo, + CrowdloanConstants, DelegateInfo, DynamicInfo, MetagraphInfo, @@ -43,6 +45,17 @@ root_set_pending_childkey_cooldown_extrinsic, set_children_extrinsic, ) +from bittensor.core.extrinsics.asyncex.crowdloan import ( + contribute_crowdloan_extrinsic, + create_crowdloan_extrinsic, + dissolve_crowdloan_extrinsic, + finalize_crowdloan_extrinsic, + refund_crowdloan_extrinsic, + update_cap_crowdloan_extrinsic, + update_end_crowdloan_extrinsic, + update_min_contribution_crowdloan_extrinsic, + withdraw_crowdloan_extrinsic, +) from bittensor.core.extrinsics.asyncex.liquidity import ( add_liquidity_extrinsic, modify_liquidity_extrinsic, @@ -286,6 +299,33 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): # Helpers ========================================================================================================== + async def _decode_crowdloan_entry( + self, + crowdloan_id: int, + data: dict, + block_hash: Optional[str] = None, + ) -> "CrowdloanInfo": + """ + Internal helper to parse and decode a single Crowdloan record. + + Automatically decodes the embedded `call` field if present (Inline SCALE format). + """ + call_data = data.get("call") + if call_data and "Inline" in call_data: + try: + inline_bytes = bytes(call_data["Inline"][0][0]) + scale_object = await self.substrate.create_scale_object( + type_string="Call", + data=scalecodec.ScaleBytes(inline_bytes), + block_hash=block_hash, + ) + decoded_call = scale_object.decode() + data["call"] = decoded_call + except Exception as e: + data["call"] = {"decode_error": str(e), "raw": call_data} + + return CrowdloanInfo.from_dict(crowdloan_id, data) + @a.lru_cache(maxsize=128) async def _get_block_hash(self, block_id: int): return await self.substrate.get_block_hash(block_id) @@ -1840,6 +1880,202 @@ async def get_commitment_metadata( ) return commit_data + async def get_crowdloan_constants( + self, + constants: Optional[list[str]] = None, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> "CrowdloanConstants": + """ + Fetches runtime configuration constants from the `Crowdloan` pallet. + + If a list of constant names is provided, only those constants will be queried. + Otherwise, all known constants defined in `CrowdloanConstants.field_names()` are fetched. + + Parameters: + constants: A list of specific constant names to fetch from the pallet. If omitted, all constants from + `CrowdloanConstants` are queried. + block: The blockchain block number for the query. + block_hash: The hash of the block at which to check the hotkey ownership. + reuse_block: Whether to reuse the last-used blockchain hash. + + Returns: + CrowdloanConstants: + A structured dataclass containing the retrieved values. Missing constants are returned as `None`. + + Example: + print(subtensor.get_crowdloan_constants()) + CrowdloanConstants( + AbsoluteMinimumContribution=τ1.000000000, + MaxContributors=1000, + MaximumBlockDuration=86400, + MinimumDeposit=τ10.000000000, + MinimumBlockDuration=600, + RefundContributorsLimit=50 + ) + + crowdloan_consts = subtensor.get_crowdloan_constants( + constants=["MaxContributors", "RefundContributorsLimit"] + ) + print(crowdloan_consts) + CrowdloanConstants(MaxContributors=1000, RefundContributorsLimit=50) + + print(crowdloan_consts.MaxContributors) + 1000 + """ + result = {} + const_names = constants or CrowdloanConstants.constants_names() + + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + for const_name in const_names: + query = await self.query_constant( + module_name="Crowdloan", + constant_name=const_name, + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + + if query is not None: + result[const_name] = query.value + + return CrowdloanConstants.from_dict(result) + + async def get_crowdloan_contributions( + self, + crowdloan_id: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[str, "Balance"]: + """ + Returns a mapping of contributor SS58 addresses to their contribution amounts for a specific crowdloan. + + Parameters: + crowdloan_id: The unique identifier of the crowdloan. + block: The blockchain block number for the query. + block_hash: The hash of the block at which to check the hotkey ownership. + reuse_block: Whether to reuse the last-used blockchain hash. + + Returns: + Dict[address -> Balance]. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + block_hash=block_hash, + ) + + result = {} + + if query.records: + async for record in query: + if record[1].value: + result[decode_account_id(record[0])] = Balance.from_rao( + record[1].value + ) + + return result + + async def get_crowdloan_by_id( + self, + crowdloan_id: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional["CrowdloanInfo"]: + """ + Returns detailed information about a specific crowdloan by ID. + + Parameters: + crowdloan_id: Unique identifier of the crowdloan. + block: The blockchain block number for the query. + block_hash: The hash of the block at which to check the hotkey ownership. + reuse_block: Whether to reuse the last-used blockchain hash. + + Returns: + CrowdloanInfo if found, else None. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="Crowdloan", + storage_function="Crowdloans", + params=[crowdloan_id], + block_hash=block_hash, + ) + if not query: + return None + return await self._decode_crowdloan_entry( + crowdloan_id=crowdloan_id, data=query.value, block_hash=block_hash + ) + + async def get_crowdloan_next_id( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """ + Returns the next available crowdloan ID (auto-increment value). + + Parameters: + block: The blockchain block number for the query. + block_hash: The hash of the block at which to check the hotkey ownership. + reuse_block: Whether to reuse the last-used blockchain hash. + + Returns: + The next crowdloan ID to be used when creating a new campaign. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.query( + module="Crowdloan", + storage_function="NextCrowdloanId", + block_hash=block_hash, + ) + return int(result.value or 0) + + async def get_crowdloans( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list["CrowdloanInfo"]: + """ + Returns a list of all existing crowdloans with their metadata. + + Parameters: + block: The blockchain block number for the query. + block_hash: The hash of the block at which to check the hotkey ownership. + reuse_block: Whether to reuse the last-used blockchain hash. + + Returns: + List of CrowdloanInfo which contains (id, creator, cap, raised, end, finalized, etc.) + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query_map( + module="Crowdloan", + storage_function="Crowdloans", + block_hash=block_hash, + ) + + crowdloans = [] + + if query.records: + async for c_id, value_obj in query: + data = value_obj.value + if not data: + continue + crowdloans.append( + await self._decode_crowdloan_entry( + crowdloan_id=c_id, data=data, block_hash=block_hash + ) + ) + + return crowdloans + async def get_delegate_by_hotkey( self, hotkey_ss58: str, @@ -4934,6 +5170,173 @@ async def commit_weights( ) return response + async def contribute_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + amount: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Contributes funds to an active crowdloan campaign. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to contribute to. + amount: Amount to contribute. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await contribute_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + async def create_crowdloan( + self, + wallet: "Wallet", + deposit: "Balance", + min_contribution: "Balance", + cap: "Balance", + end: int, + call: Optional["GenericCall"] = None, + target_address: Optional[str] = None, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = False, + ) -> ExtrinsicResponse: + """ + Creates a new crowdloan campaign on-chain. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + deposit: Initial deposit in RAO from the creator. + min_contribution: Minimum contribution amount. + cap: Maximum cap to be raised. + end: Block number when the campaign ends. + call: Runtime call data (e.g., subtensor::register_leased_network). + target_address: SS58 address to transfer funds to on success. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await create_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + deposit=deposit, + min_contribution=min_contribution, + cap=cap, + end=end, + call=call, + target_address=target_address, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + async def dissolve_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Dissolves a completed or failed crowdloan campaign after all refunds are processed. + + This permanently removes the campaign from on-chain storage and refunds the creator's remaining deposit, if + applicable. Can only be called by the campaign creator. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to dissolve. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can dissolve their own crowdloan. + - All contributors (except the creator) must have been refunded first. + - The creator’s remaining contribution (deposit) is returned during dissolution. + - After this call, the crowdloan is removed from chain storage. + """ + return await dissolve_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + async def finalize_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Finalizes a successful crowdloan campaign once the cap has been reached and the end block has passed. + + This executes the stored call or transfers the raised funds to the target address, completing the campaign. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to finalize. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await finalize_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + async def modify_liquidity( self, wallet: "Wallet", @@ -5057,6 +5460,50 @@ async def move_stake( wait_for_finalization=wait_for_finalization, ) + async def refund_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Refunds contributors from a failed or expired crowdloan campaign. + + This call attempts to refund up to the limit defined by `RefundContributorsLimit` in a single dispatch. If there are + more contributors than the limit, the call may need to be executed multiple times until all refunds are processed. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to refund. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can be called by any signed account (not only the creator). + - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. + - Each call processes a limited number of refunds (`RefundContributorsLimit`). + - If the campaign has too many contributors, multiple refund calls are required. + """ + return await refund_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + async def register( self: "AsyncSubtensor", wallet: "Wallet", @@ -6269,6 +6716,184 @@ async def unstake_multiple( wait_for_finalization=wait_for_finalization, ) + async def update_cap_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + new_cap: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Updates the fundraising cap (maximum total contribution) of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new cap must be greater than or equal to the + current amount already raised. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_cap: The new fundraising cap (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can update the cap. + - The crowdloan must not be finalized. + - The new cap must be greater than or equal to the total funds already raised. + """ + return await update_cap_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_cap=new_cap, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + async def update_end_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + new_end: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Updates the end block of a non-finalized crowdloan campaign. + + Only the creator of the crowdloan can perform this action. The new end block must be valid — meaning it cannot be in + the past and must respect the minimum and maximum duration limits enforced by the chain. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_end: The new block number at which the crowdloan will end. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Only the creator can call this extrinsic. + - The crowdloan must not be finalized. + - The new end block must be later than the current block and within valid duration bounds (between + `MinimumBlockDuration` and `MaximumBlockDuration`). + """ + return await update_end_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_end=new_end, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + async def update_min_contribution_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + new_min_contribution: "Balance", + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Updates the minimum contribution amount of a non-finalized crowdloan. + + Only the creator of the crowdloan can perform this action, and the new value must be greater than or equal to the + absolute minimum contribution defined in the chain configuration. + + Parameters: + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to update. + new_min_contribution: The new minimum contribution amount (in TAO or Balance). + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - Can only be called by the creator of the crowdloan. + - The crowdloan must not be finalized. + - The new minimum contribution must not fall below the absolute minimum defined in the runtime. + """ + return await update_min_contribution_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_min_contribution=new_min_contribution, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + async def withdraw_crowdloan( + self, + wallet: "Wallet", + crowdloan_id: int, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Withdraws a contribution from an active (not yet finalized or dissolved) crowdloan. + + Parameters: + wallet: Wallet instance used to sign the transaction (must be unlocked). + crowdloan_id: The unique identifier of the crowdloan to withdraw from. + period: The number of blocks during which the transaction will remain valid after it's submitted. If + the transaction is not included in a block within that number of blocks, it will expire and be rejected. + You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. + wait_for_finalization: Whether to wait for finalization of the extrinsic. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Note: + - Regular contributors can fully withdraw their contribution before finalization. + - The creator cannot withdraw the initial deposit, but may withdraw any amount exceeding his deposit. + """ + return await withdraw_crowdloan_extrinsic( + subtensor=self, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + async def get_async_subtensor( network: Optional[str] = None, From 769afa451bc1c64a1ccd728ef300b80015384e37 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 00:43:29 -0700 Subject: [PATCH 11/26] update sync extrinsics --- bittensor/core/extrinsics/crowdloan.py | 187 +++++++++++++------------ 1 file changed, 99 insertions(+), 88 deletions(-) diff --git a/bittensor/core/extrinsics/crowdloan.py b/bittensor/core/extrinsics/crowdloan.py index 1f583a2967..6863a1c5f7 100644 --- a/bittensor/core/extrinsics/crowdloan.py +++ b/bittensor/core/extrinsics/crowdloan.py @@ -2,6 +2,7 @@ from bittensor.core.extrinsics.params import CrowdloanParams from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import check_balance_amount if TYPE_CHECKING: from bittensor_wallet import Wallet @@ -10,32 +11,24 @@ from scalecodec.types import GenericCall -def create_crowdloan_extrinsic( +def contribute_crowdloan_extrinsic( subtensor: "Subtensor", wallet: "Wallet", - deposit: "Balance", - min_contribution: "Balance", - cap: "Balance", - end: int, - call: Optional["GenericCall"] = None, - target_address: Optional[str] = None, + crowdloan_id: int, + amount: "Balance", period: Optional[int] = None, raise_error: bool = False, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ - Creates a new crowdloan campaign on-chain. + Contributes funds to an active crowdloan campaign. Parameters: subtensor: Active Subtensor connection. wallet: Bittensor Wallet instance used to sign the transaction. - deposit: Initial deposit in RAO from the creator. - min_contribution: Minimum contribution amount. - cap: Maximum cap to be raised. - end: Block number when the campaign ends. - call: Runtime call data (e.g., subtensor::register_leased_network). - target_address: SS58 address to transfer funds to on success. + crowdloan_id: The unique identifier of the crowdloan to contribute to. + amount: Amount to contribute. period: The number of blocks during which the transaction will remain valid after it's submitted. If the transaction is not included in a block within that number of blocks, it will expire and be rejected. You can think of it as an expiration date for the transaction. @@ -52,12 +45,12 @@ def create_crowdloan_extrinsic( ).success: return unlocked + check_balance_amount(amount) + extrinsic_call = subtensor.compose_call( call_module="Crowdloan", - call_function="create", - call_params=CrowdloanParams.create( - deposit, min_contribution, cap, end, call, target_address - ), + call_function="contribute", + call_params=CrowdloanParams.contribute(crowdloan_id, amount), ) return subtensor.sign_and_send_extrinsic( @@ -73,24 +66,32 @@ def create_crowdloan_extrinsic( return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) -def contribute_crowdloan_extrinsic( +def create_crowdloan_extrinsic( subtensor: "Subtensor", wallet: "Wallet", - crowdloan_id: int, - amount: "Balance", + deposit: "Balance", + min_contribution: "Balance", + cap: "Balance", + end: int, + call: Optional["GenericCall"] = None, + target_address: Optional[str] = None, period: Optional[int] = None, raise_error: bool = False, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = True, + wait_for_inclusion: bool = False, + wait_for_finalization: bool = False, ) -> "ExtrinsicResponse": """ - Contributes funds to an active crowdloan campaign. + Creates a new crowdloan campaign on-chain. Parameters: subtensor: Active Subtensor connection. wallet: Bittensor Wallet instance used to sign the transaction. - crowdloan_id: The unique identifier of the crowdloan to contribute to. - amount: Amount to contribute. + deposit: Initial deposit in RAO from the creator. + min_contribution: Minimum contribution amount. + cap: Maximum cap to be raised. + end: Block number when the campaign ends. + call: Runtime call data (e.g., subtensor::register_leased_network). + target_address: SS58 address to transfer funds to on success. period: The number of blocks during which the transaction will remain valid after it's submitted. If the transaction is not included in a block within that number of blocks, it will expire and be rejected. You can think of it as an expiration date for the transaction. @@ -107,10 +108,16 @@ def contribute_crowdloan_extrinsic( ).success: return unlocked + check_balance_amount(deposit) + check_balance_amount(min_contribution) + check_balance_amount(cap) + extrinsic_call = subtensor.compose_call( call_module="Crowdloan", - call_function="contribute", - call_params=CrowdloanParams.contribute(crowdloan_id, amount), + call_function="create", + call_params=CrowdloanParams.create( + deposit, min_contribution, cap, end, call, target_address + ), ) return subtensor.sign_and_send_extrinsic( @@ -126,7 +133,7 @@ def contribute_crowdloan_extrinsic( return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) -def withdraw_crowdloan_extrinsic( +def dissolve_crowdloan_extrinsic( subtensor: "Subtensor", wallet: "Wallet", crowdloan_id: int, @@ -136,12 +143,15 @@ def withdraw_crowdloan_extrinsic( wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ - Withdraws a contribution from an active (not yet finalized or dissolved) crowdloan. + Dissolves a completed or failed crowdloan campaign after all refunds are processed. + + This permanently removes the campaign from on-chain storage and refunds the creator's remaining deposit, if + applicable. Can only be called by the campaign creator. Parameters: subtensor: Active Subtensor connection. - wallet: Wallet instance used to sign the transaction (must be unlocked). - crowdloan_id: The unique identifier of the crowdloan to withdraw from. + wallet: Bittensor Wallet instance used to sign the transaction. + crowdloan_id: The unique identifier of the crowdloan to dissolve. period: The number of blocks during which the transaction will remain valid after it's submitted. If the transaction is not included in a block within that number of blocks, it will expire and be rejected. You can think of it as an expiration date for the transaction. @@ -152,9 +162,11 @@ def withdraw_crowdloan_extrinsic( Returns: ExtrinsicResponse: The result object of the extrinsic execution. - Note: - - Regular contributors can fully withdraw their contribution before finalization. - - The creator cannot withdraw the initial deposit, but may withdraw any amount exceeding his deposit. + Notes: + - Only the creator can dissolve their own crowdloan. + - All contributors (except the creator) must have been refunded first. + - The creator’s remaining contribution (deposit) is returned during dissolution. + - After this call, the crowdloan is removed from chain storage. """ try: if not ( @@ -164,8 +176,8 @@ def withdraw_crowdloan_extrinsic( extrinsic_call = subtensor.compose_call( call_module="Crowdloan", - call_function="withdraw", - call_params=CrowdloanParams.withdraw(crowdloan_id), + call_function="dissolve", + call_params=CrowdloanParams.dissolve(crowdloan_id), ) return subtensor.sign_and_send_extrinsic( @@ -292,25 +304,27 @@ def refund_crowdloan_extrinsic( return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) -def dissolve_crowdloan_extrinsic( +def update_cap_crowdloan_extrinsic( subtensor: "Subtensor", wallet: "Wallet", crowdloan_id: int, + new_cap: "Balance", period: Optional[int] = None, raise_error: bool = False, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ - Dissolves a completed or failed crowdloan campaign after all refunds are processed. + Updates the fundraising cap (maximum total contribution) of a non-finalized crowdloan. - This permanently removes the campaign from on-chain storage and refunds the creator's remaining deposit, if - applicable. Can only be called by the campaign creator. + Only the creator of the crowdloan can perform this action, and the new cap must be greater than or equal to the + current amount already raised. Parameters: subtensor: Active Subtensor connection. wallet: Bittensor Wallet instance used to sign the transaction. - crowdloan_id: The unique identifier of the crowdloan to dissolve. + crowdloan_id: The unique identifier of the crowdloan to update. + new_cap: The new fundraising cap (in TAO or Balance). period: The number of blocks during which the transaction will remain valid after it's submitted. If the transaction is not included in a block within that number of blocks, it will expire and be rejected. You can think of it as an expiration date for the transaction. @@ -322,10 +336,9 @@ def dissolve_crowdloan_extrinsic( ExtrinsicResponse: The result object of the extrinsic execution. Notes: - - Only the creator can dissolve their own crowdloan. - - All contributors (except the creator) must have been refunded first. - - The creator’s remaining contribution (deposit) is returned during dissolution. - - After this call, the crowdloan is removed from chain storage. + - Only the creator can update the cap. + - The crowdloan must not be finalized. + - The new cap must be greater than or equal to the total funds already raised. """ try: if not ( @@ -333,10 +346,12 @@ def dissolve_crowdloan_extrinsic( ).success: return unlocked + check_balance_amount(new_cap) + extrinsic_call = subtensor.compose_call( call_module="Crowdloan", - call_function="dissolve", - call_params=CrowdloanParams.dissolve(crowdloan_id), + call_function="update_cap", + call_params=CrowdloanParams.update_cap(crowdloan_id, new_cap), ) return subtensor.sign_and_send_extrinsic( @@ -352,27 +367,27 @@ def dissolve_crowdloan_extrinsic( return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) -def update_min_contribution_crowdloan_extrinsic( +def update_end_crowdloan_extrinsic( subtensor: "Subtensor", wallet: "Wallet", crowdloan_id: int, - new_min_contribution: "Balance", + new_end: int, period: Optional[int] = None, raise_error: bool = False, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ - Updates the minimum contribution amount of a non-finalized crowdloan. + Updates the end block of a non-finalized crowdloan campaign. - Only the creator of the crowdloan can perform this action, and the new value must be greater than or equal to the - absolute minimum contribution defined in the chain configuration. + Only the creator of the crowdloan can perform this action. The new end block must be valid — meaning it cannot be in + the past and must respect the minimum and maximum duration limits enforced by the chain. Parameters: subtensor: Active Subtensor connection. wallet: Bittensor Wallet instance used to sign the transaction. crowdloan_id: The unique identifier of the crowdloan to update. - new_min_contribution: The new minimum contribution amount (in TAO or Balance). + new_end: The new block number at which the crowdloan will end. period: The number of blocks during which the transaction will remain valid after it's submitted. If the transaction is not included in a block within that number of blocks, it will expire and be rejected. You can think of it as an expiration date for the transaction. @@ -384,9 +399,10 @@ def update_min_contribution_crowdloan_extrinsic( ExtrinsicResponse: The result object of the extrinsic execution. Notes: - - Can only be called by the creator of the crowdloan. + - Only the creator can call this extrinsic. - The crowdloan must not be finalized. - - The new minimum contribution must not fall below the absolute minimum defined in the runtime. + - The new end block must be later than the current block and within valid duration bounds (between + `MinimumBlockDuration` and `MaximumBlockDuration`). """ try: if not ( @@ -396,10 +412,8 @@ def update_min_contribution_crowdloan_extrinsic( extrinsic_call = subtensor.compose_call( call_module="Crowdloan", - call_function="update_min_contribution", - call_params=CrowdloanParams.update_min_contribution( - crowdloan_id, new_min_contribution - ), + call_function="update_end", + call_params=CrowdloanParams.update_end(crowdloan_id, new_end), ) return subtensor.sign_and_send_extrinsic( @@ -415,27 +429,27 @@ def update_min_contribution_crowdloan_extrinsic( return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) -def update_end_crowdloan_extrinsic( +def update_min_contribution_crowdloan_extrinsic( subtensor: "Subtensor", wallet: "Wallet", crowdloan_id: int, - new_end: int, + new_min_contribution: "Balance", period: Optional[int] = None, raise_error: bool = False, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ - Updates the end block of a non-finalized crowdloan campaign. + Updates the minimum contribution amount of a non-finalized crowdloan. - Only the creator of the crowdloan can perform this action. The new end block must be valid — meaning it cannot be in - the past and must respect the minimum and maximum duration limits enforced by the chain. + Only the creator of the crowdloan can perform this action, and the new value must be greater than or equal to the + absolute minimum contribution defined in the chain configuration. Parameters: subtensor: Active Subtensor connection. wallet: Bittensor Wallet instance used to sign the transaction. crowdloan_id: The unique identifier of the crowdloan to update. - new_end: The new block number at which the crowdloan will end. + new_min_contribution: The new minimum contribution amount (in TAO or Balance). period: The number of blocks during which the transaction will remain valid after it's submitted. If the transaction is not included in a block within that number of blocks, it will expire and be rejected. You can think of it as an expiration date for the transaction. @@ -447,10 +461,9 @@ def update_end_crowdloan_extrinsic( ExtrinsicResponse: The result object of the extrinsic execution. Notes: - - Only the creator can call this extrinsic. + - Can only be called by the creator of the crowdloan. - The crowdloan must not be finalized. - - The new end block must be later than the current block and within valid duration bounds (between - `MinimumBlockDuration` and `MaximumBlockDuration`). + - The new minimum contribution must not fall below the absolute minimum defined in the runtime. """ try: if not ( @@ -458,10 +471,14 @@ def update_end_crowdloan_extrinsic( ).success: return unlocked + check_balance_amount(new_min_contribution) + extrinsic_call = subtensor.compose_call( call_module="Crowdloan", - call_function="update_end", - call_params=CrowdloanParams.update_end(crowdloan_id, new_end), + call_function="update_min_contribution", + call_params=CrowdloanParams.update_min_contribution( + crowdloan_id, new_min_contribution + ), ) return subtensor.sign_and_send_extrinsic( @@ -477,27 +494,22 @@ def update_end_crowdloan_extrinsic( return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) -def update_cap_crowdloan_extrinsic( +def withdraw_crowdloan_extrinsic( subtensor: "Subtensor", wallet: "Wallet", crowdloan_id: int, - new_cap: "Balance", period: Optional[int] = None, raise_error: bool = False, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ - Updates the fundraising cap (maximum total contribution) of a non-finalized crowdloan. - - Only the creator of the crowdloan can perform this action, and the new cap must be greater than or equal to the - current amount already raised. + Withdraws a contribution from an active (not yet finalized or dissolved) crowdloan. Parameters: subtensor: Active Subtensor connection. - wallet: Bittensor Wallet instance used to sign the transaction. - crowdloan_id: The unique identifier of the crowdloan to update. - new_cap: The new fundraising cap (in TAO or Balance). + wallet: Wallet instance used to sign the transaction (must be unlocked). + crowdloan_id: The unique identifier of the crowdloan to withdraw from. period: The number of blocks during which the transaction will remain valid after it's submitted. If the transaction is not included in a block within that number of blocks, it will expire and be rejected. You can think of it as an expiration date for the transaction. @@ -508,10 +520,9 @@ def update_cap_crowdloan_extrinsic( Returns: ExtrinsicResponse: The result object of the extrinsic execution. - Notes: - - Only the creator can update the cap. - - The crowdloan must not be finalized. - - The new cap must be greater than or equal to the total funds already raised. + Note: + - Regular contributors can fully withdraw their contribution before finalization. + - The creator cannot withdraw the initial deposit, but may withdraw any amount exceeding his deposit. """ try: if not ( @@ -521,8 +532,8 @@ def update_cap_crowdloan_extrinsic( extrinsic_call = subtensor.compose_call( call_module="Crowdloan", - call_function="update_cap", - call_params=CrowdloanParams.update_cap(crowdloan_id, new_cap), + call_function="withdraw", + call_params=CrowdloanParams.withdraw(crowdloan_id), ) return subtensor.sign_and_send_extrinsic( From 1d4445cd2c05b9511ff24b1b5128145c27bcd4e1 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 10:52:54 -0700 Subject: [PATCH 12/26] add wallet without balance --- tests/e2e_tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py index 5256de5aef..f6483c0463 100644 --- a/tests/e2e_tests/conftest.py +++ b/tests/e2e_tests/conftest.py @@ -322,6 +322,12 @@ def eve_wallet(): return wallet +@pytest.fixture +def fred_wallet(): + keypair, wallet = setup_wallet("//Fred") + return wallet + + @pytest.fixture(autouse=True) def log_test_start_and_end(request): test_name = request.node.nodeid From 5d7d53fa4a702cb1cb837f301359cd748fc7f43a Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 10:53:08 -0700 Subject: [PATCH 13/26] update CrowdloanConstants --- bittensor/core/chain_data/crowdloan_info.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bittensor/core/chain_data/crowdloan_info.py b/bittensor/core/chain_data/crowdloan_info.py index ba6be90798..83e119ffbd 100644 --- a/bittensor/core/chain_data/crowdloan_info.py +++ b/bittensor/core/chain_data/crowdloan_info.py @@ -84,6 +84,9 @@ class CrowdloanConstants: MinimumDeposit: The minimum deposit required from the creator to open a new crowdloan. MinimumBlockDuration: The minimum allowed duration (in blocks) for a crowdloan campaign. RefundContributorsLimit: The maximum number of contributors that can be refunded in single on-chain refund call. + + Note: + All Balance amounts are in RAO. """ AbsoluteMinimumContribution: Optional["Balance"] From fe9565c07d8baf54e99000b1b5a61784cac1ccd5 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 10:53:18 -0700 Subject: [PATCH 14/26] add e2e test --- tests/e2e_tests/test_crowdloan.py | 269 ++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/e2e_tests/test_crowdloan.py diff --git a/tests/e2e_tests/test_crowdloan.py b/tests/e2e_tests/test_crowdloan.py new file mode 100644 index 0000000000..6c2ffd7bcf --- /dev/null +++ b/tests/e2e_tests/test_crowdloan.py @@ -0,0 +1,269 @@ +from bittensor import Balance +from bittensor.core.extrinsics.registration import RegistrationParams +from bittensor_wallet import Wallet + + +def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet): + + # no one crowdloan has been created yet + next_crowdloan = subtensor.crowdloans.get_crowdloan_next_id() + assert next_crowdloan == 0 + + # no crowdloans before creation + assert subtensor.crowdloans.get_crowdloans() == [] + # no contributions before creation + assert subtensor.crowdloans.get_crowdloan_contributions(next_crowdloan) == {} + # no crowdloan with next ID before creation + assert subtensor.crowdloans.get_crowdloan_by_id(next_crowdloan) is None + + # fetch crowdloan constants + crowdloan_constants = subtensor.crowdloans.get_crowdloan_constants(next_crowdloan) + assert crowdloan_constants.AbsoluteMinimumContribution == Balance.from_rao(100000000) + assert crowdloan_constants.MaxContributors == 500 + assert crowdloan_constants.MinimumBlockDuration == 50 + assert crowdloan_constants.MaximumBlockDuration == 20000 + assert crowdloan_constants.MinimumDeposit == Balance.from_rao(10000000000) + assert crowdloan_constants.RefundContributorsLimit == 50 + + # All extrinsics expected to fail with InvalidCrowdloanId error + invalid_calls = [ + lambda: subtensor.crowdloans.contribute_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(10) + ), + lambda: subtensor.crowdloans.withdraw_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ), + lambda: subtensor.crowdloans.update_min_contribution_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_min_contribution=Balance.from_tao(10) + ), + lambda: subtensor.crowdloans.update_cap_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_cap=Balance.from_tao(10) + ), + lambda: subtensor.crowdloans.update_end_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_end=10000 + ), + lambda: subtensor.crowdloans.dissolve_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ), + lambda: subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ), + ] + + for call in invalid_calls: + response = call() + assert response.success is False + assert "InvalidCrowdloanId" in response.message + assert response.error["name"] == "InvalidCrowdloanId" + + # create crowdloan to raise funds to send to wallet + current_block = subtensor.block + crowdloan_cap = Balance.from_tao(20) + + # check DepositTooLow error + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(5), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=current_block + 240, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "DepositTooLow" in response.message + assert response.error["name"] == "DepositTooLow" + + # check CapTooLow error + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=Balance.from_tao(10), + end=current_block + 240, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "CapTooLow" in response.message + assert response.error["name"] == "CapTooLow" + + # check CannotEndInPast error + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=current_block, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "CannotEndInPast" in response.message + assert response.error["name"] == "CannotEndInPast" + + # check BlockDurationTooShort error + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=subtensor.block + 10, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "BlockDurationTooShort" in response.message + assert response.error["name"] == "BlockDurationTooShort" + + # check BlockDurationTooLong error + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=subtensor.block + crowdloan_constants.MaximumBlockDuration + 100, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "BlockDurationTooLong" in response.message + assert response.error["name"] == "BlockDurationTooLong" + + # successful creation + dave_balance_before = subtensor.wallets.get_balance(dave_wallet.hotkey.ss58_address) + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=subtensor.block + 240, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response.success, response.message + + crowdloans = subtensor.crowdloans.get_crowdloans() + assert len(crowdloans) == 1 + assert crowdloans[0].id == next_crowdloan + + # check contribute crowdloan + # from alice + response = subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, + crowdloan_id=next_crowdloan, + amount=Balance.from_tao(5) + ) + assert response.success, response.message + + # from charlie + response = subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan, + amount=Balance.from_tao(5) + ) + assert response.success, response.message + + # check charlie_wallet withdraw amount back + charlie_balance_before = subtensor.wallets.get_balance(charlie_wallet.hotkey.ss58_address) + response = subtensor.crowdloans.withdraw_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan + ) + assert response.success, response.message + charlie_balance_after = subtensor.wallets.get_balance(charlie_wallet.hotkey.ss58_address) + assert charlie_balance_after == charlie_balance_before + Balance.from_tao(5) - response.extrinsic_fee + + # from charlie again + response = subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan, + amount=Balance.from_tao(5) + ) + assert response.success, response.message + + # check over contribution with CapRaised error + response = subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, + crowdloan_id=next_crowdloan, + amount=Balance.from_tao(1) + ) + assert "CapRaised" in response.message + assert response.error["name"] == "CapRaised" + + crowdloan_contributions = subtensor.crowdloans.get_crowdloan_contributions(next_crowdloan) + assert len(crowdloan_contributions) == 3 + assert crowdloan_contributions[bob_wallet.hotkey.ss58_address] == Balance.from_tao(10) + assert crowdloan_contributions[alice_wallet.hotkey.ss58_address] == Balance.from_tao(5) + assert crowdloan_contributions[charlie_wallet.hotkey.ss58_address] == Balance.from_tao(5) + + # check finalization + response = subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert response.success, response.message + + # check AlreadyFinalized error after finalization + response = subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert "AlreadyFinalized" in response.message + assert response.error["name"] == "AlreadyFinalized" + + dave_balance_after = subtensor.wallets.get_balance(dave_wallet.hotkey.ss58_address) + assert dave_balance_after == dave_balance_before + crowdloan_cap + + # check error after finalization + response = subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan, + amount=Balance.from_tao(5) + ) + assert "CapRaised" in response.message + assert response.error["name"] == "CapRaised" + + # need add cases + # 1. creation using call + # 2. update_min_contribution_crowdloan_extrinsic + # 3. update_end_crowdloan_extrinsic + # 4. update_cap_crowdloan_extrinsic + # 5. refund_crowdloan_extrinsic + # 6. dissolve_crowdloan_extrinsic + # 7. add docstring with steps + # 8. duplicate test for async impl + + +# def test_crowdloan_with_call(subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet, fred_wallet): +# +# assert subtensor.wallets.get_balance(fred_wallet.hotkey.ss58_address) == Balance.from_tao(0) +# +# crowdloan_call = subtensor.compose_call( +# call_module="SubtensorModule", +# call_function="register_network", +# call_params=RegistrationParams.register_network( +# hotkey_ss58=fred_wallet.hotkey.ss58_address +# ), +# ) +# +# next_crowdloan = subtensor.crowdloans.get_crowdloan_next_id() +# +# response = subtensor.crowdloans.create_crowdloan( +# wallet=bob_wallet, +# deposit=Balance.from_tao(10), +# min_contribution=Balance.from_tao(5), +# cap=Balance.from_tao(30), +# end=subtensor.block + 2400, +# call=crowdloan_call, +# raise_error=True, +# wait_for_inclusion=False, +# wait_for_finalization=False, +# ) +# +# assert response.success, response.message +# +# crowdloans = subtensor.crowdloans.get_crowdloans() +# assert len(crowdloans) == 1 +# assert crowdloans[0].id == next_crowdloan +# + From 220b0cdecbb39b57f0127fa45082a0dc94b0ca2a Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 12:38:36 -0700 Subject: [PATCH 15/26] update make --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6d4d4db485..bbe425653e 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ reinstall: clean clean-venv install reinstall-dev: clean clean-venv install-dev ruff: - @python -m ruff format bittensor + @python -m ruff format . check: ruff @mypy --ignore-missing-imports bittensor/ --python-version=3.10 From bc087a524d010d257367c451089a4e7a1f6a7a4c Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 12:38:47 -0700 Subject: [PATCH 16/26] add 2 sync e2e tests --- tests/e2e_tests/test_crowdloan.py | 445 ++++++++++++++++++++++++------ 1 file changed, 357 insertions(+), 88 deletions(-) diff --git a/tests/e2e_tests/test_crowdloan.py b/tests/e2e_tests/test_crowdloan.py index 6c2ffd7bcf..5426806be9 100644 --- a/tests/e2e_tests/test_crowdloan.py +++ b/tests/e2e_tests/test_crowdloan.py @@ -3,8 +3,33 @@ from bittensor_wallet import Wallet -def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet): +def test_crowdloan_with_target( + subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet, fred_wallet +): + """Tests crowdloan creation with target. + Steps: + - Verify initial empty state + - Validate crowdloan constants + - Check InvalidCrowdloanId errors + - Test creation validation errors + - Create valid crowdloan with target + - Verify creation and parameters + - Update end block, cap, and min contribution + - Test low contribution rejection + - Add contributions from Alice and Charlie + - Test withdrawal and re-contribution + - Validate CapRaised behavior + - Finalize crowdloan successfully + - Confirm target (Fred) received funds + - Validate post-finalization errors + - Create second crowdloan for refund test + - Contribute from Alice and Dave + - Refund all contributors + - Verify balances after refund + - Dissolve refunded crowdloan + - Confirm only finalized crowdloan remains + """ # no one crowdloan has been created yet next_crowdloan = subtensor.crowdloans.get_crowdloan_next_id() assert next_crowdloan == 0 @@ -18,7 +43,9 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall # fetch crowdloan constants crowdloan_constants = subtensor.crowdloans.get_crowdloan_constants(next_crowdloan) - assert crowdloan_constants.AbsoluteMinimumContribution == Balance.from_rao(100000000) + assert crowdloan_constants.AbsoluteMinimumContribution == Balance.from_rao( + 100000000 + ) assert crowdloan_constants.MaxContributors == 500 assert crowdloan_constants.MinimumBlockDuration == 50 assert crowdloan_constants.MaximumBlockDuration == 20000 @@ -34,7 +61,9 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall wallet=bob_wallet, crowdloan_id=next_crowdloan ), lambda: subtensor.crowdloans.update_min_contribution_crowdloan( - wallet=bob_wallet, crowdloan_id=next_crowdloan, new_min_contribution=Balance.from_tao(10) + wallet=bob_wallet, + crowdloan_id=next_crowdloan, + new_min_contribution=Balance.from_tao(10), ), lambda: subtensor.crowdloans.update_cap_crowdloan( wallet=bob_wallet, crowdloan_id=next_crowdloan, new_cap=Balance.from_tao(10) @@ -58,7 +87,7 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall # create crowdloan to raise funds to send to wallet current_block = subtensor.block - crowdloan_cap = Balance.from_tao(20) + crowdloan_cap = Balance.from_tao(15) # check DepositTooLow error response = subtensor.crowdloans.create_crowdloan( @@ -67,7 +96,7 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall min_contribution=Balance.from_tao(1), cap=crowdloan_cap, end=current_block + 240, - target_address=dave_wallet.hotkey.ss58_address, + target_address=fred_wallet.hotkey.ss58_address, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -81,7 +110,7 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall min_contribution=Balance.from_tao(1), cap=Balance.from_tao(10), end=current_block + 240, - target_address=dave_wallet.hotkey.ss58_address, + target_address=fred_wallet.hotkey.ss58_address, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -95,7 +124,7 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall min_contribution=Balance.from_tao(1), cap=crowdloan_cap, end=current_block, - target_address=dave_wallet.hotkey.ss58_address, + target_address=fred_wallet.hotkey.ss58_address, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -109,7 +138,7 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall min_contribution=Balance.from_tao(1), cap=crowdloan_cap, end=subtensor.block + 10, - target_address=dave_wallet.hotkey.ss58_address, + target_address=fred_wallet.hotkey.ss58_address, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -123,80 +152,135 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall min_contribution=Balance.from_tao(1), cap=crowdloan_cap, end=subtensor.block + crowdloan_constants.MaximumBlockDuration + 100, - target_address=dave_wallet.hotkey.ss58_address, + target_address=fred_wallet.hotkey.ss58_address, wait_for_inclusion=True, wait_for_finalization=True, ) assert "BlockDurationTooLong" in response.message assert response.error["name"] == "BlockDurationTooLong" - # successful creation - dave_balance_before = subtensor.wallets.get_balance(dave_wallet.hotkey.ss58_address) + # === SUCCESSFUL creation === + fred_balance = subtensor.wallets.get_balance(fred_wallet.hotkey.ss58_address) + assert fred_balance == Balance.from_tao(0) + + end_block = subtensor.block + 240 response = subtensor.crowdloans.create_crowdloan( wallet=bob_wallet, deposit=Balance.from_tao(10), min_contribution=Balance.from_tao(1), cap=crowdloan_cap, - end=subtensor.block + 240, - target_address=dave_wallet.hotkey.ss58_address, + end=end_block, + target_address=fred_wallet.hotkey.ss58_address, wait_for_inclusion=True, wait_for_finalization=True, ) assert response.success, response.message + # check crowdloan created successfully crowdloans = subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] assert len(crowdloans) == 1 - assert crowdloans[0].id == next_crowdloan + assert crowdloan.id == next_crowdloan + assert crowdloan.contributors_count == 1 + assert crowdloan.min_contribution == Balance.from_tao(1) + assert crowdloan.end == end_block - # check contribute crowdloan - # from alice - response = subtensor.crowdloans.contribute_crowdloan( - wallet=alice_wallet, + # check update end block + new_end_block = end_block + 100 + response = subtensor.crowdloans.update_end_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_end=new_end_block + ) + assert response.success, response.message + + crowdloans = subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert len(crowdloans) == 1 + assert crowdloan.id == next_crowdloan + assert crowdloan.end == new_end_block + + # check update crowdloan cap + updated_crowdloan_cap = Balance.from_tao(20) + response = subtensor.crowdloans.update_cap_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_cap=updated_crowdloan_cap + ) + assert response.success, response.message + + crowdloans = subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert len(crowdloans) == 1 + assert crowdloan.id == next_crowdloan + assert crowdloan.cap == updated_crowdloan_cap + + # check min contribution update + response = subtensor.crowdloans.update_min_contribution_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, - amount=Balance.from_tao(5) + new_min_contribution=Balance.from_tao(5), ) assert response.success, response.message - # from charlie + # check contribution not enough response = subtensor.crowdloans.contribute_crowdloan( - wallet=charlie_wallet, - crowdloan_id=next_crowdloan, - amount=Balance.from_tao(5) + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(1) + ) + assert "ContributionTooLow" in response.message + assert response.error["name"] == "ContributionTooLow" + + # check successful contribution crowdloan + # contribution from alice + response = subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) + ) + assert response.success, response.message + + # contribution from charlie + response = subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) ) assert response.success, response.message # check charlie_wallet withdraw amount back - charlie_balance_before = subtensor.wallets.get_balance(charlie_wallet.hotkey.ss58_address) + charlie_balance_before = subtensor.wallets.get_balance( + charlie_wallet.hotkey.ss58_address + ) response = subtensor.crowdloans.withdraw_crowdloan( - wallet=charlie_wallet, - crowdloan_id=next_crowdloan + wallet=charlie_wallet, crowdloan_id=next_crowdloan ) assert response.success, response.message - charlie_balance_after = subtensor.wallets.get_balance(charlie_wallet.hotkey.ss58_address) - assert charlie_balance_after == charlie_balance_before + Balance.from_tao(5) - response.extrinsic_fee + charlie_balance_after = subtensor.wallets.get_balance( + charlie_wallet.hotkey.ss58_address + ) + assert ( + charlie_balance_after + == charlie_balance_before + Balance.from_tao(5) - response.extrinsic_fee + ) - # from charlie again + # contribution from charlie again response = subtensor.crowdloans.contribute_crowdloan( - wallet=charlie_wallet, - crowdloan_id=next_crowdloan, - amount=Balance.from_tao(5) + wallet=charlie_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) ) assert response.success, response.message # check over contribution with CapRaised error response = subtensor.crowdloans.contribute_crowdloan( - wallet=alice_wallet, - crowdloan_id=next_crowdloan, - amount=Balance.from_tao(1) + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(1) ) assert "CapRaised" in response.message assert response.error["name"] == "CapRaised" - crowdloan_contributions = subtensor.crowdloans.get_crowdloan_contributions(next_crowdloan) + crowdloan_contributions = subtensor.crowdloans.get_crowdloan_contributions( + next_crowdloan + ) assert len(crowdloan_contributions) == 3 - assert crowdloan_contributions[bob_wallet.hotkey.ss58_address] == Balance.from_tao(10) - assert crowdloan_contributions[alice_wallet.hotkey.ss58_address] == Balance.from_tao(5) - assert crowdloan_contributions[charlie_wallet.hotkey.ss58_address] == Balance.from_tao(5) + assert crowdloan_contributions[bob_wallet.hotkey.ss58_address] == Balance.from_tao( + 10 + ) + assert crowdloan_contributions[ + alice_wallet.hotkey.ss58_address + ] == Balance.from_tao(5) + assert crowdloan_contributions[ + charlie_wallet.hotkey.ss58_address + ] == Balance.from_tao(5) # check finalization response = subtensor.crowdloans.finalize_crowdloan( @@ -204,6 +288,12 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall ) assert response.success, response.message + # make sure fred received raised amount + fred_balance_after_finalize = subtensor.wallets.get_balance( + fred_wallet.hotkey.ss58_address + ) + assert fred_balance_after_finalize == updated_crowdloan_cap + # check AlreadyFinalized error after finalization response = subtensor.crowdloans.finalize_crowdloan( wallet=bob_wallet, crowdloan_id=next_crowdloan @@ -211,59 +301,238 @@ def test_crowdloan_with_target(subtensor, alice_wallet, bob_wallet, charlie_wall assert "AlreadyFinalized" in response.message assert response.error["name"] == "AlreadyFinalized" - dave_balance_after = subtensor.wallets.get_balance(dave_wallet.hotkey.ss58_address) - assert dave_balance_after == dave_balance_before + crowdloan_cap - # check error after finalization response = subtensor.crowdloans.contribute_crowdloan( - wallet=charlie_wallet, - crowdloan_id=next_crowdloan, - amount=Balance.from_tao(5) + wallet=charlie_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) ) assert "CapRaised" in response.message assert response.error["name"] == "CapRaised" - # need add cases - # 1. creation using call - # 2. update_min_contribution_crowdloan_extrinsic - # 3. update_end_crowdloan_extrinsic - # 4. update_cap_crowdloan_extrinsic - # 5. refund_crowdloan_extrinsic - # 6. dissolve_crowdloan_extrinsic - # 7. add docstring with steps - # 8. duplicate test for async impl - - -# def test_crowdloan_with_call(subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet, fred_wallet): -# -# assert subtensor.wallets.get_balance(fred_wallet.hotkey.ss58_address) == Balance.from_tao(0) -# -# crowdloan_call = subtensor.compose_call( -# call_module="SubtensorModule", -# call_function="register_network", -# call_params=RegistrationParams.register_network( -# hotkey_ss58=fred_wallet.hotkey.ss58_address -# ), -# ) -# -# next_crowdloan = subtensor.crowdloans.get_crowdloan_next_id() -# -# response = subtensor.crowdloans.create_crowdloan( -# wallet=bob_wallet, -# deposit=Balance.from_tao(10), -# min_contribution=Balance.from_tao(5), -# cap=Balance.from_tao(30), -# end=subtensor.block + 2400, -# call=crowdloan_call, -# raise_error=True, -# wait_for_inclusion=False, -# wait_for_finalization=False, -# ) -# -# assert response.success, response.message -# -# crowdloans = subtensor.crowdloans.get_crowdloans() -# assert len(crowdloans) == 1 -# assert crowdloans[0].id == next_crowdloan -# + # check dissolve crowdloan error after finalization + response = subtensor.crowdloans.dissolve_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert "AlreadyFinalized" in response.message + assert response.error["name"] == "AlreadyFinalized" + + crowdloans = subtensor.crowdloans.get_crowdloans() + assert len(crowdloans) == 1 + + # === check refund crowdloan (create + contribute + refund + dissolve) === + next_crowdloan = subtensor.crowdloans.get_crowdloan_next_id() + assert next_crowdloan == 1 + + bob_deposit = Balance.from_tao(10) + crowdloan_cap = Balance.from_tao(20) + + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=bob_deposit, + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=subtensor.block + 240, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response.success, response.message + + crowdloans = subtensor.crowdloans.get_crowdloans() + assert len(crowdloans) == 2 + + # check crowdloan's raised amount decreased after refund + crowdloans = subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert crowdloan.raised == bob_deposit + + alice_balance_before = subtensor.wallets.get_balance( + alice_wallet.hotkey.ss58_address + ) + alice_contribute_amount = Balance.from_tao(5) + dave_balance_before = subtensor.wallets.get_balance(dave_wallet.hotkey.ss58_address) + dave_contribution_amount = Balance.from_tao(5) + + # contribution from alice + response_alice_contrib = subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=alice_contribute_amount + ) + assert response_alice_contrib.success, response_alice_contrib.message + + # check alice balance decreased + alice_balance_after_contrib = subtensor.wallets.get_balance( + alice_wallet.hotkey.ss58_address + ) + assert ( + alice_balance_after_contrib + == alice_balance_before + - alice_contribute_amount + - response_alice_contrib.extrinsic_fee + ) + + # contribution from dave + response_dave_contrib = subtensor.crowdloans.contribute_crowdloan( + wallet=dave_wallet, crowdloan_id=next_crowdloan, amount=dave_contribution_amount + ) + assert response_dave_contrib.success, response_dave_contrib.message + + # check dave balance decreased + dave_balance_after_contrib = subtensor.wallets.get_balance( + dave_wallet.hotkey.ss58_address + ) + assert ( + dave_balance_after_contrib + == dave_balance_before + - dave_contribution_amount + - response_dave_contrib.extrinsic_fee + ) + + # check crowdloan's raised amount + crowdloans = subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert ( + crowdloan.raised + == bob_deposit + alice_contribute_amount + dave_contribution_amount + ) + + # refund crowdloan + response = subtensor.crowdloans.refund_crowdloan( + wallet=bob_wallet, + crowdloan_id=next_crowdloan, + ) + assert response.success, response.message + + # check crowdloan's raised amount decreased after refund + crowdloans = subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert crowdloan.raised == bob_deposit + + # check alice balance increased after refund + alice_balance_after_refund = subtensor.wallets.get_balance( + alice_wallet.hotkey.ss58_address + ) + assert ( + alice_balance_after_refund + == alice_balance_after_contrib + alice_contribute_amount + ) + + # check dave balance increased after refund + dave_balance_after_refund = subtensor.wallets.get_balance( + dave_wallet.hotkey.ss58_address + ) + assert ( + dave_balance_after_refund + == dave_balance_after_contrib + dave_contribution_amount + ) + + # dissolve crowdloan + response = subtensor.crowdloans.dissolve_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert response.success, response.message + + # check that chain has just one finalized crowdloan + crowdloans = subtensor.crowdloans.get_crowdloans() + assert len(crowdloans) == 1 + + +def test_crowdloan_with_call( + subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet, fred_wallet +): + """Tests crowdloan creation with call. + + Steps: + - Compose subnet registration call + - Create new crowdloan + - Verify creation and balance change + - Alice contributes to crowdloan + - Charlie contributes to crowdloan + - Verify total raised and contributors + - Finalize crowdloan campaign + - Verify new subnet created (composed crowdloan call executed) + - Confirm subnet owner is Fred + """ + # create crowdloan's call + crowdloan_call = subtensor.compose_call( + call_module="SubtensorModule", + call_function="register_network", + call_params=RegistrationParams.register_network( + hotkey_ss58=fred_wallet.hotkey.ss58_address + ), + ) + + next_crowdloan = subtensor.crowdloans.get_crowdloan_next_id() + subnets_before = subtensor.subnets.get_all_subnets_netuid() + crowdloan_cap = Balance.from_tao(30) + crowdloan_deposit = Balance.from_tao(10) + + bob_balance_before = subtensor.wallets.get_balance(bob_wallet.hotkey.ss58_address) + + response = subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=crowdloan_deposit, + min_contribution=Balance.from_tao(5), + cap=crowdloan_cap, + end=subtensor.block + 2400, + call=crowdloan_call, + raise_error=True, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + + # keep it until ASI has a fix for `wait_for_inclusion=True` and `wait_for_finalization=True` + subtensor.wait_for_block(subtensor.block + 10) + + # check creation was successful + assert response.success, response.message + + # check bob balance decreased + bob_balance_after = subtensor.wallets.get_balance(bob_wallet.hotkey.ss58_address) + assert ( + bob_balance_after + == bob_balance_before - crowdloan_deposit - response.extrinsic_fee + ) + + # contribution from alice + alice_contribute_amount = Balance.from_tao(10) + response = subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=alice_contribute_amount + ) + assert response.success, response.message + + # contribution from charlie + charlie_contribute_amount = Balance.from_tao(10) + response = subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan, + amount=charlie_contribute_amount, + ) + assert response.success, response.message + + # make sure the crowdloan company is ready to finalize + crowdloans = subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert len(crowdloans) == 1 + assert crowdloan.id == next_crowdloan + assert crowdloan.contributors_count == 3 + assert ( + crowdloan.raised + == crowdloan_deposit + alice_contribute_amount + charlie_contribute_amount + ) + assert crowdloan.cap == crowdloan_cap + + # finalize crowdloan + response = subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert response.success, response.message + + # check new subnet exist + subnets_after = subtensor.subnets.get_all_subnets_netuid() + assert len(subnets_after) == len(subnets_before) + 1 + + # get new subnet id and owner + new_subnet_id = subnets_after[-1] + new_subnet_owner_hk = subtensor.subnets.get_subnet_owner_hotkey(new_subnet_id) + # make sure subnet owner is fred + assert new_subnet_owner_hk == fred_wallet.hotkey.ss58_address From e5655177dee48d2b036120eefae8d3f8d0c56a61 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 13:15:11 -0700 Subject: [PATCH 17/26] ass async e2e tests --- tests/e2e_tests/test_crowdloan.py | 575 +++++++++++++++++++++++++++++- 1 file changed, 574 insertions(+), 1 deletion(-) diff --git a/tests/e2e_tests/test_crowdloan.py b/tests/e2e_tests/test_crowdloan.py index 5426806be9..ec7cf5d418 100644 --- a/tests/e2e_tests/test_crowdloan.py +++ b/tests/e2e_tests/test_crowdloan.py @@ -1,6 +1,8 @@ from bittensor import Balance from bittensor.core.extrinsics.registration import RegistrationParams from bittensor_wallet import Wallet +import pytest +import asyncio def test_crowdloan_with_target( @@ -435,6 +437,457 @@ def test_crowdloan_with_target( assert len(crowdloans) == 1 +@pytest.mark.asyncio +async def test_crowdloan_with_target_async( + async_subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet, fred_wallet +): + """Async tests crowdloan creation with target. + + Steps: + - Verify initial empty state + - Validate crowdloan constants + - Check InvalidCrowdloanId errors + - Test creation validation errors + - Create valid crowdloan with target + - Verify creation and parameters + - Update end block, cap, and min contribution + - Test low contribution rejection + - Add contributions from Alice and Charlie + - Test withdrawal and re-contribution + - Validate CapRaised behavior + - Finalize crowdloan successfully + - Confirm target (Fred) received funds + - Validate post-finalization errors + - Create second crowdloan for refund test + - Contribute from Alice and Dave + - Refund all contributors + - Verify balances after refund + - Dissolve refunded crowdloan + - Confirm only finalized crowdloan remains + """ + # no one crowdloan has been created yet + ( + next_crowdloan, + crowdloans, + crowdloan_contributions, + crowdloan_by_id, + ) = await asyncio.gather( + async_subtensor.crowdloans.get_crowdloan_next_id(), + async_subtensor.crowdloans.get_crowdloans(), + async_subtensor.crowdloans.get_crowdloan_contributions(0), + async_subtensor.crowdloans.get_crowdloan_by_id(0), + ) + # no created crowdloans yet + assert next_crowdloan == 0 + # no crowdloans before creation + assert len(crowdloans) == 0 + # no contributions before creation + assert crowdloan_contributions == {} + # no crowdloan with next ID before creation + assert crowdloan_by_id is None + + # fetch crowdloan constants + crowdloan_constants = await async_subtensor.crowdloans.get_crowdloan_constants( + next_crowdloan + ) + assert crowdloan_constants.AbsoluteMinimumContribution == Balance.from_rao( + 100000000 + ) + assert crowdloan_constants.MaxContributors == 500 + assert crowdloan_constants.MinimumBlockDuration == 50 + assert crowdloan_constants.MaximumBlockDuration == 20000 + assert crowdloan_constants.MinimumDeposit == Balance.from_rao(10000000000) + assert crowdloan_constants.RefundContributorsLimit == 50 + + # All extrinsics expected to fail with InvalidCrowdloanId error + invalid_calls = [ + lambda: async_subtensor.crowdloans.contribute_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(10) + ), + lambda: async_subtensor.crowdloans.withdraw_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ), + lambda: async_subtensor.crowdloans.update_min_contribution_crowdloan( + wallet=bob_wallet, + crowdloan_id=next_crowdloan, + new_min_contribution=Balance.from_tao(10), + ), + lambda: async_subtensor.crowdloans.update_cap_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_cap=Balance.from_tao(10) + ), + lambda: async_subtensor.crowdloans.update_end_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_end=10000 + ), + lambda: async_subtensor.crowdloans.dissolve_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ), + lambda: async_subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ), + ] + + for call in invalid_calls: + response = await call() + assert response.success is False + assert "InvalidCrowdloanId" in response.message + assert response.error["name"] == "InvalidCrowdloanId" + + # create crowdloan to raise funds to send to wallet + current_block = await async_subtensor.block + crowdloan_cap = Balance.from_tao(15) + + # check DepositTooLow error + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(5), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=current_block + 240, + target_address=fred_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "DepositTooLow" in response.message + assert response.error["name"] == "DepositTooLow" + + # check CapTooLow error + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=Balance.from_tao(10), + end=current_block + 240, + target_address=fred_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "CapTooLow" in response.message + assert response.error["name"] == "CapTooLow" + + # check CannotEndInPast error + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=current_block, + target_address=fred_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "CannotEndInPast" in response.message + assert response.error["name"] == "CannotEndInPast" + + # check BlockDurationTooShort error + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=await async_subtensor.block + 49, + target_address=fred_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "BlockDurationTooShort" in response.message + assert response.error["name"] == "BlockDurationTooShort" + + # check BlockDurationTooLong error + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=await async_subtensor.block + + crowdloan_constants.MaximumBlockDuration + + 100, + target_address=fred_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert "BlockDurationTooLong" in response.message + assert response.error["name"] == "BlockDurationTooLong" + + # === SUCCESSFUL creation === + fred_balance = await async_subtensor.wallets.get_balance( + fred_wallet.hotkey.ss58_address + ) + assert fred_balance == Balance.from_tao(0) + + end_block = await async_subtensor.block + 240 + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=Balance.from_tao(10), + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=end_block, + target_address=fred_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response.success, response.message + + # check crowdloan created successfully + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert len(crowdloans) == 1 + assert crowdloan.id == next_crowdloan + assert crowdloan.contributors_count == 1 + assert crowdloan.min_contribution == Balance.from_tao(1) + assert crowdloan.end == end_block + + # check update end block + new_end_block = end_block + 100 + response = await async_subtensor.crowdloans.update_end_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_end=new_end_block + ) + assert response.success, response.message + + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert len(crowdloans) == 1 + assert crowdloan.id == next_crowdloan + assert crowdloan.end == new_end_block + + # check update crowdloan cap + updated_crowdloan_cap = Balance.from_tao(20) + response = await async_subtensor.crowdloans.update_cap_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan, new_cap=updated_crowdloan_cap + ) + assert response.success, response.message + + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert len(crowdloans) == 1 + assert crowdloan.id == next_crowdloan + assert crowdloan.cap == updated_crowdloan_cap + + # check min contribution update + response = await async_subtensor.crowdloans.update_min_contribution_crowdloan( + wallet=bob_wallet, + crowdloan_id=next_crowdloan, + new_min_contribution=Balance.from_tao(5), + ) + assert response.success, response.message + + # check contribution not enough + response = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(1) + ) + assert "ContributionTooLow" in response.message + assert response.error["name"] == "ContributionTooLow" + + # check successful contribution crowdloan + # contribution from alice + response = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) + ) + assert response.success, response.message + + # contribution from charlie + response = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) + ) + assert response.success, response.message + + # check charlie_wallet withdraw amount back + charlie_balance_before = await async_subtensor.wallets.get_balance( + charlie_wallet.hotkey.ss58_address + ) + response = await async_subtensor.crowdloans.withdraw_crowdloan( + wallet=charlie_wallet, crowdloan_id=next_crowdloan + ) + assert response.success, response.message + charlie_balance_after = await async_subtensor.wallets.get_balance( + charlie_wallet.hotkey.ss58_address + ) + assert ( + charlie_balance_after + == charlie_balance_before + Balance.from_tao(5) - response.extrinsic_fee + ) + + # contribution from charlie again + response = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) + ) + assert response.success, response.message + + # check over contribution with CapRaised error + response = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(1) + ) + assert "CapRaised" in response.message + assert response.error["name"] == "CapRaised" + + crowdloan_contributions = ( + await async_subtensor.crowdloans.get_crowdloan_contributions(next_crowdloan) + ) + assert len(crowdloan_contributions) == 3 + assert crowdloan_contributions[bob_wallet.hotkey.ss58_address] == Balance.from_tao( + 10 + ) + assert crowdloan_contributions[ + alice_wallet.hotkey.ss58_address + ] == Balance.from_tao(5) + assert crowdloan_contributions[ + charlie_wallet.hotkey.ss58_address + ] == Balance.from_tao(5) + + # check finalization + response = await async_subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert response.success, response.message + + # make sure fred received raised amount + fred_balance_after_finalize = await async_subtensor.wallets.get_balance( + fred_wallet.hotkey.ss58_address + ) + assert fred_balance_after_finalize == updated_crowdloan_cap + + # check AlreadyFinalized error after finalization + response = await async_subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert "AlreadyFinalized" in response.message + assert response.error["name"] == "AlreadyFinalized" + + # check error after finalization + response = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, crowdloan_id=next_crowdloan, amount=Balance.from_tao(5) + ) + assert "CapRaised" in response.message + assert response.error["name"] == "CapRaised" + + # check dissolve crowdloan error after finalization + response = await async_subtensor.crowdloans.dissolve_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert "AlreadyFinalized" in response.message + assert response.error["name"] == "AlreadyFinalized" + + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + assert len(crowdloans) == 1 + + # === check refund crowdloan (create + contribute + refund + dissolve) === + next_crowdloan = await async_subtensor.crowdloans.get_crowdloan_next_id() + assert next_crowdloan == 1 + + bob_deposit = Balance.from_tao(10) + crowdloan_cap = Balance.from_tao(20) + + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=bob_deposit, + min_contribution=Balance.from_tao(1), + cap=crowdloan_cap, + end=await async_subtensor.block + 240, + target_address=dave_wallet.hotkey.ss58_address, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response.success, response.message + + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + assert len(crowdloans) == 2 + + # check crowdloan's raised amount decreased after refund + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert crowdloan.raised == bob_deposit + + alice_balance_before = await async_subtensor.wallets.get_balance( + alice_wallet.hotkey.ss58_address + ) + alice_contribute_amount = Balance.from_tao(5) + dave_balance_before = await async_subtensor.wallets.get_balance( + dave_wallet.hotkey.ss58_address + ) + dave_contribution_amount = Balance.from_tao(5) + + # contribution from alice + response_alice_contrib = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, crowdloan_id=next_crowdloan, amount=alice_contribute_amount + ) + assert response_alice_contrib.success, response_alice_contrib.message + + # check alice balance decreased + alice_balance_after_contrib = await async_subtensor.wallets.get_balance( + alice_wallet.hotkey.ss58_address + ) + assert ( + alice_balance_after_contrib + == alice_balance_before + - alice_contribute_amount + - response_alice_contrib.extrinsic_fee + ) + + # contribution from dave + response_dave_contrib = await async_subtensor.crowdloans.contribute_crowdloan( + wallet=dave_wallet, crowdloan_id=next_crowdloan, amount=dave_contribution_amount + ) + assert response_dave_contrib.success, response_dave_contrib.message + + # check dave balance decreased + dave_balance_after_contrib = await async_subtensor.wallets.get_balance( + dave_wallet.hotkey.ss58_address + ) + assert ( + dave_balance_after_contrib + == dave_balance_before + - dave_contribution_amount + - response_dave_contrib.extrinsic_fee + ) + + # check crowdloan's raised amount + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert ( + crowdloan.raised + == bob_deposit + alice_contribute_amount + dave_contribution_amount + ) + + # refund crowdloan + response = await async_subtensor.crowdloans.refund_crowdloan( + wallet=bob_wallet, + crowdloan_id=next_crowdloan, + ) + assert response.success, response.message + + # check crowdloan's raised amount decreased after refund + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert crowdloan.raised == bob_deposit + + # check alice balance increased after refund + alice_balance_after_refund = await async_subtensor.wallets.get_balance( + alice_wallet.hotkey.ss58_address + ) + assert ( + alice_balance_after_refund + == alice_balance_after_contrib + alice_contribute_amount + ) + + # check dave balance increased after refund + dave_balance_after_refund = await async_subtensor.wallets.get_balance( + dave_wallet.hotkey.ss58_address + ) + assert ( + dave_balance_after_refund + == dave_balance_after_contrib + dave_contribution_amount + ) + + # dissolve crowdloan + response = await async_subtensor.crowdloans.dissolve_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert response.success, response.message + + # check that chain has just one finalized crowdloan + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + assert len(crowdloans) == 1 + + def test_crowdloan_with_call( subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet, fred_wallet ): @@ -479,7 +932,7 @@ def test_crowdloan_with_call( wait_for_finalization=False, ) - # keep it until ASI has a fix for `wait_for_inclusion=True` and `wait_for_finalization=True` + # keep it until `scalecodec` has a fix for `wait_for_inclusion=True` and `wait_for_finalization=True` subtensor.wait_for_block(subtensor.block + 10) # check creation was successful @@ -536,3 +989,123 @@ def test_crowdloan_with_call( # make sure subnet owner is fred assert new_subnet_owner_hk == fred_wallet.hotkey.ss58_address + + +@pytest.mark.asyncio +async def test_crowdloan_with_call_async( + async_subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet, fred_wallet +): + """Async tests crowdloan creation with call. + + Steps: + - Compose subnet registration call + - Create new crowdloan + - Verify creation and balance change + - Alice contributes to crowdloan + - Charlie contributes to crowdloan + - Verify total raised and contributors + - Finalize crowdloan campaign + - Verify new subnet created (composed crowdloan call executed) + - Confirm subnet owner is Fred + """ + # create crowdloan's call + crowdloan_call = await async_subtensor.compose_call( + call_module="SubtensorModule", + call_function="register_network", + call_params=RegistrationParams.register_network( + hotkey_ss58=fred_wallet.hotkey.ss58_address + ), + ) + + crowdloan_cap = Balance.from_tao(30) + crowdloan_deposit = Balance.from_tao(10) + + ( + next_crowdloan, + subnets_before, + bob_balance_before, + current_block, + ) = await asyncio.gather( + async_subtensor.crowdloans.get_crowdloan_next_id(), + async_subtensor.subnets.get_all_subnets_netuid(), + async_subtensor.wallets.get_balance(bob_wallet.hotkey.ss58_address), + async_subtensor.block, + ) + end_block = current_block + 2400 + + response = await async_subtensor.crowdloans.create_crowdloan( + wallet=bob_wallet, + deposit=crowdloan_deposit, + min_contribution=Balance.from_tao(5), + cap=crowdloan_cap, + end=end_block, + call=crowdloan_call, + raise_error=True, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + + # keep it until `scalecodec` has a fix for `wait_for_inclusion=True` and `wait_for_finalization=True` + await async_subtensor.wait_for_block(current_block + 20) + + # check creation was successful + assert response.success, response.message + + # check bob balance decreased + bob_balance_after = await async_subtensor.wallets.get_balance( + bob_wallet.hotkey.ss58_address + ) + assert ( + bob_balance_after + == bob_balance_before - crowdloan_deposit - response.extrinsic_fee + ) + + # contribution from alice and charlie + alice_contribute_amount = Balance.from_tao(10) + charlie_contribute_amount = Balance.from_tao(10) + + a_response, c_response = await asyncio.gather( + async_subtensor.crowdloans.contribute_crowdloan( + wallet=alice_wallet, + crowdloan_id=next_crowdloan, + amount=alice_contribute_amount, + ), + async_subtensor.crowdloans.contribute_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan, + amount=charlie_contribute_amount, + ), + ) + assert a_response.success, a_response.message + assert c_response.success, c_response.message + + # make sure the crowdloan company is ready to finalize + crowdloans = await async_subtensor.crowdloans.get_crowdloans() + crowdloan = [c for c in crowdloans if c.id == next_crowdloan][0] + assert len(crowdloans) == 1 + assert crowdloan.id == next_crowdloan + assert crowdloan.contributors_count == 3 + assert ( + crowdloan.raised + == crowdloan_deposit + alice_contribute_amount + charlie_contribute_amount + ) + assert crowdloan.cap == crowdloan_cap + + # finalize crowdloan + response = await async_subtensor.crowdloans.finalize_crowdloan( + wallet=bob_wallet, crowdloan_id=next_crowdloan + ) + assert response.success, response.message + + # check new subnet exist + subnets_after = await async_subtensor.subnets.get_all_subnets_netuid() + assert len(subnets_after) == len(subnets_before) + 1 + + # get new subnet id and owner + new_subnet_id = subnets_after[-1] + new_subnet_owner_hk = await async_subtensor.subnets.get_subnet_owner_hotkey( + new_subnet_id + ) + + # make sure subnet owner is fred + assert new_subnet_owner_hk == fred_wallet.hotkey.ss58_address From c06aa32da4a371d756a6b268407315c40ae36736 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Wed, 15 Oct 2025 22:58:29 +0200 Subject: [PATCH 18/26] Adds temporary patch for scalecodec --- bittensor/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bittensor/__init__.py b/bittensor/__init__.py index 57b009ece9..561d6afdee 100644 --- a/bittensor/__init__.py +++ b/bittensor/__init__.py @@ -3,6 +3,7 @@ from .core.settings import __version__, version_split, DEFAULTS, DEFAULT_NETWORK from .utils.btlogging import logging from .utils.easy_imports import * +import scalecodec.types def __getattr__(name): @@ -13,3 +14,20 @@ def __getattr__(name): ) return version_split raise AttributeError(f"module {__name__} has no attribute {name}") + + +# the following patches the `scalecodec.types.Option.process` that allows for decoding certain extrinsics (specifically +# the ones used by crowdloan using Option. There is a PR up for this: https://github.com/JAMdotTech/py-scale-codec/pull/134 +# and this patch will be removed when this is applied/released. + +def patched_process(self): + + option_byte = self.get_next_bytes(1) + + if self.sub_type and option_byte != b'\x00': + self.value_object = self.process_type(self.sub_type, metadata=self.metadata) + return self.value_object.value + + return None + +scalecodec.types.Option.process = patched_process From d8fc624fbf3cde823b9ab28d7be691dc6c672d07 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Wed, 15 Oct 2025 23:00:21 +0200 Subject: [PATCH 19/26] Ruff --- bittensor/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bittensor/__init__.py b/bittensor/__init__.py index 561d6afdee..117aad5c4c 100644 --- a/bittensor/__init__.py +++ b/bittensor/__init__.py @@ -20,14 +20,15 @@ def __getattr__(name): # the ones used by crowdloan using Option. There is a PR up for this: https://github.com/JAMdotTech/py-scale-codec/pull/134 # and this patch will be removed when this is applied/released. -def patched_process(self): +def patched_process(self): option_byte = self.get_next_bytes(1) - if self.sub_type and option_byte != b'\x00': + if self.sub_type and option_byte != b"\x00": self.value_object = self.process_type(self.sub_type, metadata=self.metadata) return self.value_object.value return None + scalecodec.types.Option.process = patched_process From 1eb5e455afaa9694fef624c89c15499c8793ce1e Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 14:49:16 -0700 Subject: [PATCH 20/26] apply scalecodec patch, move to settings, update extrinsics and related subtensor calls --- bittensor/__init__.py | 19 ----------- bittensor/core/async_subtensor.py | 4 +-- .../core/extrinsics/asyncex/crowdloan.py | 4 +-- bittensor/core/extrinsics/crowdloan.py | 4 +-- bittensor/core/settings.py | 24 ++++++++++++- bittensor/core/subtensor.py | 4 +-- tests/e2e_tests/test_crowdloan.py | 34 ------------------- 7 files changed, 31 insertions(+), 62 deletions(-) diff --git a/bittensor/__init__.py b/bittensor/__init__.py index 117aad5c4c..57b009ece9 100644 --- a/bittensor/__init__.py +++ b/bittensor/__init__.py @@ -3,7 +3,6 @@ from .core.settings import __version__, version_split, DEFAULTS, DEFAULT_NETWORK from .utils.btlogging import logging from .utils.easy_imports import * -import scalecodec.types def __getattr__(name): @@ -14,21 +13,3 @@ def __getattr__(name): ) return version_split raise AttributeError(f"module {__name__} has no attribute {name}") - - -# the following patches the `scalecodec.types.Option.process` that allows for decoding certain extrinsics (specifically -# the ones used by crowdloan using Option. There is a PR up for this: https://github.com/JAMdotTech/py-scale-codec/pull/134 -# and this patch will be removed when this is applied/released. - - -def patched_process(self): - option_byte = self.get_next_bytes(1) - - if self.sub_type and option_byte != b"\x00": - self.value_object = self.process_type(self.sub_type, metadata=self.metadata) - return self.value_object.value - - return None - - -scalecodec.types.Option.process = patched_process diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 262fb673b5..2bb79b7fb5 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -5219,8 +5219,8 @@ async def create_crowdloan( target_address: Optional[str] = None, period: Optional[int] = None, raise_error: bool = False, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, ) -> ExtrinsicResponse: """ Creates a new crowdloan campaign on-chain. diff --git a/bittensor/core/extrinsics/asyncex/crowdloan.py b/bittensor/core/extrinsics/asyncex/crowdloan.py index 63a68cfd34..89c7022053 100644 --- a/bittensor/core/extrinsics/asyncex/crowdloan.py +++ b/bittensor/core/extrinsics/asyncex/crowdloan.py @@ -77,8 +77,8 @@ async def create_crowdloan_extrinsic( target_address: Optional[str] = None, period: Optional[int] = None, raise_error: bool = False, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ Creates a new crowdloan campaign on-chain. diff --git a/bittensor/core/extrinsics/crowdloan.py b/bittensor/core/extrinsics/crowdloan.py index 6863a1c5f7..9ba1ae557e 100644 --- a/bittensor/core/extrinsics/crowdloan.py +++ b/bittensor/core/extrinsics/crowdloan.py @@ -77,8 +77,8 @@ def create_crowdloan_extrinsic( target_address: Optional[str] = None, period: Optional[int] = None, raise_error: bool = False, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, ) -> "ExtrinsicResponse": """ Creates a new crowdloan campaign on-chain. diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index 6ff48febbd..6df783be03 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -1,8 +1,9 @@ -import os import importlib.metadata +import os import re from pathlib import Path +import scalecodec.types from munch import munchify ROOT_TAO_STAKE_WEIGHT = 0.18 @@ -165,3 +166,24 @@ def __apply_nest_asyncio(): __apply_nest_asyncio() + + +def __apply_scalecodec_patch(): + # the following patches the `scalecodec.types.Option.process` that allows for decoding certain extrinsics (specifically + # the ones used by crowdloan using Option. + # There is a PR up for this: https://github.com/JAMdotTech/py-scale-codec/pull/134 + # and this patch will be removed when this is applied/released. + + def patched_process(self): + option_byte = self.get_next_bytes(1) + + if self.sub_type and option_byte != b"\x00": + self.value_object = self.process_type(self.sub_type, metadata=self.metadata) + return self.value_object.value + + return None + + scalecodec.types.Option.process = patched_process + + +__apply_scalecodec_patch() diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 247753a729..a4dcdc3d48 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -4001,8 +4001,8 @@ def create_crowdloan( target_address: Optional[str] = None, period: Optional[int] = None, raise_error: bool = False, - wait_for_inclusion: bool = False, - wait_for_finalization: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, ) -> ExtrinsicResponse: """ Creates a new crowdloan campaign on-chain. diff --git a/tests/e2e_tests/test_crowdloan.py b/tests/e2e_tests/test_crowdloan.py index ec7cf5d418..8741fae301 100644 --- a/tests/e2e_tests/test_crowdloan.py +++ b/tests/e2e_tests/test_crowdloan.py @@ -99,8 +99,6 @@ def test_crowdloan_with_target( cap=crowdloan_cap, end=current_block + 240, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "DepositTooLow" in response.message assert response.error["name"] == "DepositTooLow" @@ -113,8 +111,6 @@ def test_crowdloan_with_target( cap=Balance.from_tao(10), end=current_block + 240, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "CapTooLow" in response.message assert response.error["name"] == "CapTooLow" @@ -127,8 +123,6 @@ def test_crowdloan_with_target( cap=crowdloan_cap, end=current_block, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "CannotEndInPast" in response.message assert response.error["name"] == "CannotEndInPast" @@ -141,8 +135,6 @@ def test_crowdloan_with_target( cap=crowdloan_cap, end=subtensor.block + 10, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "BlockDurationTooShort" in response.message assert response.error["name"] == "BlockDurationTooShort" @@ -155,8 +147,6 @@ def test_crowdloan_with_target( cap=crowdloan_cap, end=subtensor.block + crowdloan_constants.MaximumBlockDuration + 100, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "BlockDurationTooLong" in response.message assert response.error["name"] == "BlockDurationTooLong" @@ -173,8 +163,6 @@ def test_crowdloan_with_target( cap=crowdloan_cap, end=end_block, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert response.success, response.message @@ -334,8 +322,6 @@ def test_crowdloan_with_target( cap=crowdloan_cap, end=subtensor.block + 240, target_address=dave_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert response.success, response.message @@ -544,8 +530,6 @@ async def test_crowdloan_with_target_async( cap=crowdloan_cap, end=current_block + 240, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "DepositTooLow" in response.message assert response.error["name"] == "DepositTooLow" @@ -558,8 +542,6 @@ async def test_crowdloan_with_target_async( cap=Balance.from_tao(10), end=current_block + 240, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "CapTooLow" in response.message assert response.error["name"] == "CapTooLow" @@ -572,8 +554,6 @@ async def test_crowdloan_with_target_async( cap=crowdloan_cap, end=current_block, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "CannotEndInPast" in response.message assert response.error["name"] == "CannotEndInPast" @@ -586,8 +566,6 @@ async def test_crowdloan_with_target_async( cap=crowdloan_cap, end=await async_subtensor.block + 49, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "BlockDurationTooShort" in response.message assert response.error["name"] == "BlockDurationTooShort" @@ -602,8 +580,6 @@ async def test_crowdloan_with_target_async( + crowdloan_constants.MaximumBlockDuration + 100, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert "BlockDurationTooLong" in response.message assert response.error["name"] == "BlockDurationTooLong" @@ -622,8 +598,6 @@ async def test_crowdloan_with_target_async( cap=crowdloan_cap, end=end_block, target_address=fred_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert response.success, response.message @@ -783,8 +757,6 @@ async def test_crowdloan_with_target_async( cap=crowdloan_cap, end=await async_subtensor.block + 240, target_address=dave_wallet.hotkey.ss58_address, - wait_for_inclusion=True, - wait_for_finalization=True, ) assert response.success, response.message @@ -927,9 +899,6 @@ def test_crowdloan_with_call( cap=crowdloan_cap, end=subtensor.block + 2400, call=crowdloan_call, - raise_error=True, - wait_for_inclusion=False, - wait_for_finalization=False, ) # keep it until `scalecodec` has a fix for `wait_for_inclusion=True` and `wait_for_finalization=True` @@ -1040,9 +1009,6 @@ async def test_crowdloan_with_call_async( cap=crowdloan_cap, end=end_block, call=crowdloan_call, - raise_error=True, - wait_for_inclusion=False, - wait_for_finalization=False, ) # keep it until `scalecodec` has a fix for `wait_for_inclusion=True` and `wait_for_finalization=True` From 2501753d837ae6756f21195d48759f89d730eb74 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 15:01:25 -0700 Subject: [PATCH 21/26] add unit tests for new subtensor methods --- tests/unit_tests/test_async_subtensor.py | 388 +++++++++++++++++++++++ tests/unit_tests/test_subtensor.py | 369 ++++++++++++++++++++- 2 files changed, 752 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index ff54790472..5b78eca540 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -4,6 +4,7 @@ import pytest from async_substrate_interface.types import ScaleObj from bittensor_wallet import Wallet +from scalecodec import GenericCall from bittensor import u64_normalized_float from bittensor.core import async_subtensor, settings @@ -4222,3 +4223,390 @@ async def test_get_block_info(subtensor, mocker): explorer=f"{settings.TAO_APP_BLOCK_EXPLORER}{fake_block}", ) assert result == mocked_BlockInfo.return_value + + +@pytest.mark.asyncio +async def test_contribute_crowdloan(mocker, subtensor): + """Tests subtensor `contribute_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + amount = mocker.Mock(spec=Balance) + + mocked_extrinsic = mocker.patch.object( + async_subtensor, "contribute_crowdloan_extrinsic" + ) + + # Call + response = await subtensor.contribute_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + ) + + # asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_create_crowdloan(mocker, subtensor): + """Tests subtensor `create_crowdloan` method.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + deposit = mocker.Mock(spec=Balance) + min_contribution = mocker.Mock(spec=Balance) + cap = mocker.Mock(spec=Balance) + end = mocker.Mock(spec=int) + call = mocker.Mock(spec=GenericCall) + target_address = mocker.Mock(spec=str) + + mocked_extrinsic = mocker.patch.object( + async_subtensor, "create_crowdloan_extrinsic" + ) + + # Call + response = await subtensor.create_crowdloan( + wallet=wallet, + deposit=deposit, + min_contribution=min_contribution, + cap=cap, + end=end, + call=call, + target_address=target_address, + ) + + # asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + deposit=deposit, + min_contribution=min_contribution, + cap=cap, + end=end, + call=call, + target_address=target_address, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +@pytest.mark.parametrize( + "method, extrinsic", + [ + ("dissolve_crowdloan", "dissolve_crowdloan_extrinsic"), + ("finalize_crowdloan", "finalize_crowdloan_extrinsic"), + ("refund_crowdloan", "refund_crowdloan_extrinsic"), + ("withdraw_crowdloan", "withdraw_crowdloan_extrinsic"), + ], +) +@pytest.mark.asyncio +async def test_crowdloan_methods_with_crowdloan_id_parameter( + mocker, subtensor, method, extrinsic +): + """Tests subtensor methods with the same list of parameters.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + + mocked_extrinsic = mocker.patch.object(async_subtensor, extrinsic) + + # Call + response = await getattr(subtensor, method)( + wallet=wallet, + crowdloan_id=crowdloan_id, + ) + + # asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_update_cap_crowdloan(mocker, subtensor): + """Tests subtensor `update_cap_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + new_cap = mocker.Mock(spec=Balance) + + mocked_extrinsic = mocker.patch.object( + async_subtensor, "update_cap_crowdloan_extrinsic" + ) + + # Call + response = await subtensor.update_cap_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + new_cap=new_cap, + ) + + # asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_cap=new_cap, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_update_end_crowdloan(mocker, subtensor): + """Tests subtensor `update_end_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + new_end = mocker.Mock(spec=int) + + mocked_extrinsic = mocker.patch.object( + async_subtensor, "update_end_crowdloan_extrinsic" + ) + + # Call + response = await subtensor.update_end_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + new_end=new_end, + ) + + # asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_end=new_end, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_update_min_contribution_crowdloan(mocker, subtensor): + """Tests subtensor `update_min_contribution_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + new_min_contribution = mocker.Mock(spec=Balance) + + mocked_extrinsic = mocker.patch.object( + async_subtensor, "update_min_contribution_crowdloan_extrinsic" + ) + + # Call + response = await subtensor.update_min_contribution_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + new_min_contribution=new_min_contribution, + ) + + # asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_min_contribution=new_min_contribution, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_get_crowdloan_constants(mocker, subtensor): + """Test subtensor `get_crowdloan_constants` method.""" + # Preps + fake_constant_name = mocker.Mock(spec=str) + mocked_crowdloan_constants = mocker.patch.object( + async_subtensor.CrowdloanConstants, + "constants_names", + return_value=[fake_constant_name], + ) + mocked_query_constant = mocker.patch.object(subtensor, "query_constant") + mocked_from_dict = mocker.patch.object( + async_subtensor.CrowdloanConstants, "from_dict" + ) + + # Call + result = await subtensor.get_crowdloan_constants() + + # Asserts + mocked_crowdloan_constants.assert_called_once() + mocked_query_constant.assert_awaited_once_with( + module_name="Crowdloan", + constant_name=fake_constant_name, + block=None, + block_hash=None, + reuse_block=False, + ) + mocked_from_dict.assert_called_once_with( + {fake_constant_name: mocked_query_constant.return_value.value} + ) + assert result == mocked_from_dict.return_value + + +@pytest.mark.asyncio +async def test_get_crowdloan_contributions(mocker, subtensor): + """Tests subtensor `get_crowdloan_contributions` method.""" + # Preps + fake_hk_array = mocker.Mock(spec=list) + fake_contribution = mocker.Mock(value=mocker.Mock(spec=Balance)) + fake_crowdloan_id = mocker.Mock(spec=int) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + + records = [(fake_hk_array, fake_contribution)] + fake_result = mocker.AsyncMock(autospec=list) + fake_result.records = records + fake_result.__aiter__.return_value = iter(records) + + mocked_query_map = mocker.patch.object( + subtensor.substrate, "query_map", return_value=fake_result + ) + + mocked_decode_account_id = mocker.patch.object(async_subtensor, "decode_account_id") + mocked_from_rao = mocker.patch.object(async_subtensor.Balance, "from_rao") + + # Call + result = await subtensor.get_crowdloan_contributions(fake_crowdloan_id) + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query_map.assert_awaited_once_with( + module="Crowdloan", + storage_function="Contributions", + params=[fake_crowdloan_id], + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == { + mocked_decode_account_id.return_value: mocked_from_rao.return_value + } + + +@pytest.mark.parametrize( + "query_return, expected_result", [(None, None), ("Some", "decode_crowdloan_entry")] +) +@pytest.mark.asyncio +async def test_get_crowdloan_by_id(mocker, subtensor, query_return, expected_result): + """Tests subtensor `get_crowdloan_by_id` method.""" + # Preps + fake_crowdloan_id = mocker.Mock(spec=int) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + + mocked_query_return = ( + None if query_return is None else mocker.Mock(value=query_return) + ) + mocked_query = mocker.patch.object( + subtensor.substrate, "query", return_value=mocked_query_return + ) + + mocked_decode_crowdloan_entry = mocker.patch.object( + subtensor, "_decode_crowdloan_entry" + ) + + # Call + result = await subtensor.get_crowdloan_by_id(fake_crowdloan_id) + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query.assert_awaited_once_with( + module="Crowdloan", + storage_function="Crowdloans", + params=[fake_crowdloan_id], + block_hash=mocked_determine_block_hash.return_value, + ) + assert ( + result == expected_result + if query_return is None + else mocked_decode_crowdloan_entry.return_value + ) + + +@pytest.mark.asyncio +async def test_get_crowdloan_next_id(mocker, subtensor): + """Tests subtensor `get_crowdloan_next_id` method.""" + # Preps + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object( + subtensor.substrate, "query", return_value=mocker.Mock(value=3) + ) + + # Call + result = await subtensor.get_crowdloan_next_id() + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query.assert_awaited_once_with( + module="Crowdloan", + storage_function="NextCrowdloanId", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == int(mocked_query.return_value.value) + + +@pytest.mark.asyncio +async def test_get_crowdloans(mocker, subtensor): + """Tests subtensor `get_crowdloans` method.""" + # Preps + fake_id = mocker.Mock(spec=int) + fake_crowdloan = mocker.Mock(value=mocker.Mock(spec=dict)) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + + records = [(fake_id, fake_crowdloan)] + fake_result = mocker.AsyncMock(autospec=list) + fake_result.records = records + fake_result.__aiter__.return_value = iter(records) + + mocked_query_map = mocker.patch.object( + subtensor.substrate, + "query_map", + return_value=fake_result, + ) + mocked_decode_crowdloan_entry = mocker.patch.object( + subtensor, "_decode_crowdloan_entry" + ) + + # Call + result = await subtensor.get_crowdloans() + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query_map.assert_awaited_once_with( + module="Crowdloan", + storage_function="Crowdloans", + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_decode_crowdloan_entry.assert_awaited_once_with( + crowdloan_id=fake_id, + data=fake_crowdloan.value, + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == [mocked_decode_crowdloan_entry.return_value] diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 905687720c..b5f228f925 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -1,13 +1,14 @@ import argparse -import unittest.mock as mock import datetime +import unittest.mock as mock from unittest.mock import MagicMock -from bittensor.core.types import ExtrinsicResponse + import pytest -from bittensor_wallet import Wallet +import websockets from async_substrate_interface import sync_substrate from async_substrate_interface.types import ScaleObj -import websockets +from bittensor_wallet import Wallet +from scalecodec import GenericCall from bittensor import StakeInfo from bittensor.core import settings @@ -18,6 +19,7 @@ from bittensor.core.settings import version_as_int from bittensor.core.subtensor import Subtensor from bittensor.core.types import AxonServeCallParams +from bittensor.core.types import ExtrinsicResponse from bittensor.utils import ( Certificate, u16_normalized_float, @@ -25,7 +27,6 @@ determine_chain_endpoint_and_network, ) from bittensor.utils.balance import Balance -from bittensor.core.types import ExtrinsicResponse U16_MAX = 65535 U64_MAX = 18446744073709551615 @@ -4348,3 +4349,361 @@ def test_get_block_info(subtensor, mocker): explorer=f"{settings.TAO_APP_BLOCK_EXPLORER}{fake_block}", ) assert result == mocked_BlockInfo.return_value + + +def test_contribute_crowdloan(mocker, subtensor): + """Tests subtensor `contribute_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + amount = mocker.Mock(spec=Balance) + + mocked_extrinsic = mocker.patch.object( + subtensor_module, "contribute_crowdloan_extrinsic" + ) + + # Call + response = subtensor.contribute_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + ) + + # asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +def test_create_crowdloan(mocker, subtensor): + """Tests subtensor `create_crowdloan` method.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + deposit = mocker.Mock(spec=Balance) + min_contribution = mocker.Mock(spec=Balance) + cap = mocker.Mock(spec=Balance) + end = mocker.Mock(spec=int) + call = mocker.Mock(spec=GenericCall) + target_address = mocker.Mock(spec=str) + + mocked_extrinsic = mocker.patch.object( + subtensor_module, "create_crowdloan_extrinsic" + ) + + # Call + response = subtensor.create_crowdloan( + wallet=wallet, + deposit=deposit, + min_contribution=min_contribution, + cap=cap, + end=end, + call=call, + target_address=target_address, + ) + + # asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + deposit=deposit, + min_contribution=min_contribution, + cap=cap, + end=end, + call=call, + target_address=target_address, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +@pytest.mark.parametrize( + "method, extrinsic", + [ + ("dissolve_crowdloan", "dissolve_crowdloan_extrinsic"), + ("finalize_crowdloan", "finalize_crowdloan_extrinsic"), + ("refund_crowdloan", "refund_crowdloan_extrinsic"), + ("withdraw_crowdloan", "withdraw_crowdloan_extrinsic"), + ], +) +def test_crowdloan_methods_with_crowdloan_id_parameter( + mocker, subtensor, method, extrinsic +): + """Tests subtensor methods with the same list of parameters.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + + mocked_extrinsic = mocker.patch.object(subtensor_module, extrinsic) + + # Call + response = getattr(subtensor, method)( + wallet=wallet, + crowdloan_id=crowdloan_id, + ) + + # asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +def test_update_cap_crowdloan(mocker, subtensor): + """Tests subtensor `update_cap_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + new_cap = mocker.Mock(spec=Balance) + + mocked_extrinsic = mocker.patch.object( + subtensor_module, "update_cap_crowdloan_extrinsic" + ) + + # Call + response = subtensor.update_cap_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + new_cap=new_cap, + ) + + # asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_cap=new_cap, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +def test_update_end_crowdloan(mocker, subtensor): + """Tests subtensor `update_end_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + new_end = mocker.Mock(spec=int) + + mocked_extrinsic = mocker.patch.object( + subtensor_module, "update_end_crowdloan_extrinsic" + ) + + # Call + response = subtensor.update_end_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + new_end=new_end, + ) + + # asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_end=new_end, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +def test_update_min_contribution_crowdloan(mocker, subtensor): + """Tests subtensor `update_min_contribution_crowdloan` method.""" + # Preps + wallet = mocker.Mock() + crowdloan_id = mocker.Mock() + new_min_contribution = mocker.Mock(spec=Balance) + + mocked_extrinsic = mocker.patch.object( + subtensor_module, "update_min_contribution_crowdloan_extrinsic" + ) + + # Call + response = subtensor.update_min_contribution_crowdloan( + wallet=wallet, + crowdloan_id=crowdloan_id, + new_min_contribution=new_min_contribution, + ) + + # asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + crowdloan_id=crowdloan_id, + new_min_contribution=new_min_contribution, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_extrinsic.return_value + + +def test_get_crowdloan_constants(mocker, subtensor): + """Test subtensor `get_crowdloan_constants` method.""" + # Preps + fake_constant_name = mocker.Mock(spec=str) + mocked_crowdloan_constants = mocker.patch.object( + subtensor_module.CrowdloanConstants, + "constants_names", + return_value=[fake_constant_name], + ) + mocked_query_constant = mocker.patch.object(subtensor, "query_constant") + mocked_from_dict = mocker.patch.object( + subtensor_module.CrowdloanConstants, "from_dict" + ) + + # Call + result = subtensor.get_crowdloan_constants() + + # Asserts + mocked_crowdloan_constants.assert_called_once() + mocked_query_constant.assert_called_once_with( + module_name="Crowdloan", + constant_name=fake_constant_name, + block=None, + ) + mocked_from_dict.assert_called_once_with( + {fake_constant_name: mocked_query_constant.return_value.value} + ) + assert result == mocked_from_dict.return_value + + +def test_get_crowdloan_contributions(mocker, subtensor): + """Tests subtensor `get_crowdloan_contributions` method.""" + # Preps + fake_hk_array = mocker.Mock(spec=list) + fake_contribution = mocker.Mock(value=mocker.Mock(spec=Balance)) + + fake_crowdloan_id = mocker.Mock(spec=int) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query_map = mocker.patch.object(subtensor.substrate, "query_map") + mocked_query_map.return_value.records = [(fake_hk_array, fake_contribution)] + mocked_decode_account_id = mocker.patch.object( + subtensor_module, "decode_account_id" + ) + mocked_from_rao = mocker.patch.object(subtensor_module.Balance, "from_rao") + + # Call + result = subtensor.get_crowdloan_contributions(fake_crowdloan_id) + + # Asserts + mocked_determine_block_hash.assert_called_once() + assert result == { + mocked_decode_account_id.return_value: mocked_from_rao.return_value + } + + +@pytest.mark.parametrize( + "query_return, expected_result", [(None, None), ("Some", "decode_crowdloan_entry")] +) +def test_get_crowdloan_by_id(mocker, subtensor, query_return, expected_result): + """Tests subtensor `get_crowdloan_by_id` method.""" + # Preps + fake_crowdloan_id = mocker.Mock(spec=int) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + + mocked_query_return = ( + None if query_return is None else mocker.Mock(value=query_return) + ) + mocked_query = mocker.patch.object( + subtensor.substrate, "query", return_value=mocked_query_return + ) + + mocked_decode_crowdloan_entry = mocker.patch.object( + subtensor, "_decode_crowdloan_entry" + ) + + # Call + result = subtensor.get_crowdloan_by_id(fake_crowdloan_id) + + # Asserts + mocked_determine_block_hash.assert_called_once() + mocked_query.assert_called_once_with( + module="Crowdloan", + storage_function="Crowdloans", + params=[fake_crowdloan_id], + block_hash=mocked_determine_block_hash.return_value, + ) + assert ( + result == expected_result + if query_return is None + else mocked_decode_crowdloan_entry.return_value + ) + + +def test_get_crowdloan_next_id(mocker, subtensor): + """Tests subtensor `get_crowdloan_next_id` method.""" + # Preps + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object( + subtensor.substrate, "query", return_value=mocker.Mock(value=3) + ) + + # Call + result = subtensor.get_crowdloan_next_id() + + # Asserts + mocked_determine_block_hash.assert_called_once() + mocked_query.assert_called_once_with( + module="Crowdloan", + storage_function="NextCrowdloanId", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == int(mocked_query.return_value.value) + + +def test_get_crowdloans(mocker, subtensor): + """Tests subtensor `get_crowdloans` method.""" + # Preps + fake_id = mocker.Mock(spec=int) + fake_crowdloan = mocker.Mock(value=mocker.Mock(spec=dict)) + + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query_map = mocker.patch.object( + subtensor.substrate, + "query_map", + return_value=mocker.Mock(records=[(fake_id, fake_crowdloan)]), + ) + mocked_decode_crowdloan_entry = mocker.patch.object( + subtensor, "_decode_crowdloan_entry" + ) + + # Call + result = subtensor.get_crowdloans() + + # Asserts + mocked_determine_block_hash.assert_called_once() + mocked_query_map.assert_called_once_with( + module="Crowdloan", + storage_function="Crowdloans", + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_decode_crowdloan_entry.assert_called_once_with( + crowdloan_id=fake_id, + data=fake_crowdloan.value, + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == [mocked_decode_crowdloan_entry.return_value] From ec19f17a12eb7385632268f9390d2b0e456c49cd Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 15 Oct 2025 15:38:57 -0700 Subject: [PATCH 22/26] add unit tests for extrinsics --- .../extrinsics/asyncex/test_crowdloan.py | 298 ++++++++++++++++++ tests/unit_tests/extrinsics/test_crowdloan.py | 292 +++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 tests/unit_tests/extrinsics/asyncex/test_crowdloan.py create mode 100644 tests/unit_tests/extrinsics/test_crowdloan.py diff --git a/tests/unit_tests/extrinsics/asyncex/test_crowdloan.py b/tests/unit_tests/extrinsics/asyncex/test_crowdloan.py new file mode 100644 index 0000000000..ecf92a119b --- /dev/null +++ b/tests/unit_tests/extrinsics/asyncex/test_crowdloan.py @@ -0,0 +1,298 @@ +from bittensor_wallet import Wallet +from scalecodec.types import GenericCall +import pytest +from bittensor.core.extrinsics.asyncex import crowdloan +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance + + +@pytest.mark.asyncio +async def test_contribute_crowdloan_extrinsic(subtensor, mocker): + """Test that `contribute_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_amount = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await crowdloan.contribute_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + amount=fake_amount, + ) + + # Assertions + mocked_compose_call.assert_awaited_once_with( + call_module="Crowdloan", + call_function="contribute", + call_params=crowdloan.CrowdloanParams.contribute( + fake_crowdloan_id, fake_amount + ), + ) + + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_create_crowdloan_extrinsic(subtensor, mocker): + """Test that `create_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_deposit = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + fake_min_contribution = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + fake_cap = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + fake_end = mocker.MagicMock(spec=int) + fake_call = mocker.MagicMock(spec=GenericCall) + fake_target_address = mocker.MagicMock(spec=str) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await crowdloan.create_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + deposit=fake_deposit, + min_contribution=fake_min_contribution, + cap=fake_cap, + end=fake_end, + call=fake_call, + target_address=fake_target_address, + ) + + # Assertions + mocked_compose_call.assert_awaited_once_with( + call_module="Crowdloan", + call_function="create", + call_params=crowdloan.CrowdloanParams.create( + fake_deposit, + fake_min_contribution, + fake_cap, + fake_end, + fake_call, + fake_target_address, + ), + ) + + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +@pytest.mark.parametrize( + "extrinsic, subtensor_function", + [ + ("dissolve_crowdloan_extrinsic", "dissolve"), + ("finalize_crowdloan_extrinsic", "finalize"), + ("refund_crowdloan_extrinsic", "refund"), + ("withdraw_crowdloan_extrinsic", "withdraw"), + ], +) +@pytest.mark.asyncio +async def test_same_params_extrinsics(subtensor, mocker, extrinsic, subtensor_function): + """Tests extrinsic with same parameters.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await getattr(crowdloan, extrinsic)( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + ) + + # Assertions + mocked_compose_call.assert_awaited_once_with( + call_module="Crowdloan", + call_function=subtensor_function, + call_params=getattr(crowdloan.CrowdloanParams, subtensor_function)( + fake_crowdloan_id + ), + ) + + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_update_cap_crowdloan_extrinsic(subtensor, mocker): + """Test that `update_cap_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_new_cap = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await crowdloan.update_cap_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + new_cap=fake_new_cap, + ) + + # Assertions + mocked_compose_call.assert_awaited_once_with( + call_module="Crowdloan", + call_function="update_cap", + call_params=crowdloan.CrowdloanParams.update_cap( + fake_crowdloan_id, fake_new_cap + ), + ) + + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_update_end_crowdloan_extrinsic(subtensor, mocker): + """Test that `update_end_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_new_end = mocker.MagicMock(spec=int) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await crowdloan.update_end_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + new_end=fake_new_end, + ) + + # Assertions + mocked_compose_call.assert_awaited_once_with( + call_module="Crowdloan", + call_function="update_end", + call_params=crowdloan.CrowdloanParams.update_end( + fake_crowdloan_id, fake_new_end + ), + ) + + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_update_min_contribution_crowdloan_extrinsic(subtensor, mocker): + """Test that `update_min_contribution_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_new_min_contribution = mocker.MagicMock( + spec=Balance, rao=mocker.Mock(spec=int) + ) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await crowdloan.update_min_contribution_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + new_min_contribution=fake_new_min_contribution, + ) + + # Assertions + mocked_compose_call.assert_awaited_once_with( + call_module="Crowdloan", + call_function="update_min_contribution", + call_params=crowdloan.CrowdloanParams.update_min_contribution( + fake_crowdloan_id, fake_new_min_contribution + ), + ) + + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message diff --git a/tests/unit_tests/extrinsics/test_crowdloan.py b/tests/unit_tests/extrinsics/test_crowdloan.py new file mode 100644 index 0000000000..0082243b84 --- /dev/null +++ b/tests/unit_tests/extrinsics/test_crowdloan.py @@ -0,0 +1,292 @@ +from bittensor_wallet import Wallet +from scalecodec.types import GenericCall +import pytest +from bittensor.core.extrinsics import crowdloan +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance + + +def test_contribute_crowdloan_extrinsic(subtensor, mocker): + """Test that `contribute_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_amount = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = crowdloan.contribute_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + amount=fake_amount, + ) + + # Assertions + mocked_compose_call.assert_called_once_with( + call_module="Crowdloan", + call_function="contribute", + call_params=crowdloan.CrowdloanParams.contribute( + fake_crowdloan_id, fake_amount + ), + ) + + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +def test_create_crowdloan_extrinsic(subtensor, mocker): + """Test that `create_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_deposit = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + fake_min_contribution = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + fake_cap = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + fake_end = mocker.MagicMock(spec=int) + fake_call = mocker.MagicMock(spec=GenericCall) + fake_target_address = mocker.MagicMock(spec=str) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = crowdloan.create_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + deposit=fake_deposit, + min_contribution=fake_min_contribution, + cap=fake_cap, + end=fake_end, + call=fake_call, + target_address=fake_target_address, + ) + + # Assertions + mocked_compose_call.assert_called_once_with( + call_module="Crowdloan", + call_function="create", + call_params=crowdloan.CrowdloanParams.create( + fake_deposit, + fake_min_contribution, + fake_cap, + fake_end, + fake_call, + fake_target_address, + ), + ) + + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +@pytest.mark.parametrize( + "extrinsic, subtensor_function", + [ + ("dissolve_crowdloan_extrinsic", "dissolve"), + ("finalize_crowdloan_extrinsic", "finalize"), + ("refund_crowdloan_extrinsic", "refund"), + ("withdraw_crowdloan_extrinsic", "withdraw"), + ], +) +def test_same_params_extrinsics(subtensor, mocker, extrinsic, subtensor_function): + """Tests extrinsic with same parameters.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = getattr(crowdloan, extrinsic)( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + ) + + # Assertions + mocked_compose_call.assert_called_once_with( + call_module="Crowdloan", + call_function=subtensor_function, + call_params=getattr(crowdloan.CrowdloanParams, subtensor_function)( + fake_crowdloan_id + ), + ) + + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +def test_update_cap_crowdloan_extrinsic(subtensor, mocker): + """Test that `update_cap_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_new_cap = mocker.MagicMock(spec=Balance, rao=mocker.Mock(spec=int)) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = crowdloan.update_cap_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + new_cap=fake_new_cap, + ) + + # Assertions + mocked_compose_call.assert_called_once_with( + call_module="Crowdloan", + call_function="update_cap", + call_params=crowdloan.CrowdloanParams.update_cap( + fake_crowdloan_id, fake_new_cap + ), + ) + + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +def test_update_end_crowdloan_extrinsic(subtensor, mocker): + """Test that `update_end_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_new_end = mocker.MagicMock(spec=int) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = crowdloan.update_end_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + new_end=fake_new_end, + ) + + # Assertions + mocked_compose_call.assert_called_once_with( + call_module="Crowdloan", + call_function="update_end", + call_params=crowdloan.CrowdloanParams.update_end( + fake_crowdloan_id, fake_new_end + ), + ) + + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message + + +def test_update_min_contribution_crowdloan_extrinsic(subtensor, mocker): + """Test that `update_min_contribution_crowdloan_extrinsic` correctly constructs and submits the extrinsic.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_crowdloan_id = mocker.Mock(spec=int) + fake_new_min_contribution = mocker.MagicMock( + spec=Balance, rao=mocker.Mock(spec=int) + ) + + mocked_compose_call = mocker.patch.object(subtensor, "compose_call") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = crowdloan.update_min_contribution_crowdloan_extrinsic( + subtensor=subtensor, + wallet=faked_wallet, + crowdloan_id=fake_crowdloan_id, + new_min_contribution=fake_new_min_contribution, + ) + + # Assertions + mocked_compose_call.assert_called_once_with( + call_module="Crowdloan", + call_function="update_min_contribution", + call_params=crowdloan.CrowdloanParams.update_min_contribution( + fake_crowdloan_id, fake_new_min_contribution + ), + ) + + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_compose_call.return_value, + wallet=faked_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + assert success is True + assert "Success" in message From 1112af7bdf7a568119199b360d9d814a560be423 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Thu, 16 Oct 2025 16:15:06 +0200 Subject: [PATCH 23/26] Revert patch process as it's now applied upstream. --- bittensor/core/settings.py | 22 ---------------------- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index 479127d8d8..809ee1f52c 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -3,7 +3,6 @@ import re from pathlib import Path -import scalecodec.types from munch import munchify ROOT_TAO_STAKE_WEIGHT = 0.18 @@ -166,24 +165,3 @@ def __apply_nest_asyncio(): __apply_nest_asyncio() - - -def __apply_scalecodec_patch(): - # the following patches the `scalecodec.types.Option.process` that allows for decoding certain extrinsics (specifically - # the ones used by crowdloan using Option. - # There is a PR up for this: https://github.com/JAMdotTech/py-scale-codec/pull/134 - # and this patch will be removed when this is applied/released. - - def patched_process(self): - option_byte = self.get_next_bytes(1) - - if self.sub_type and option_byte != b"\x00": - self.value_object = self.process_type(self.sub_type, metadata=self.metadata) - return self.value_object.value - - return None - - scalecodec.types.Option.process = patched_process - - -__apply_scalecodec_patch() diff --git a/pyproject.toml b/pyproject.toml index b00d271850..35f1d65025 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "retry==0.9.2", "requests>=2.0.0,<3.0", "pydantic>=2.3, <3", - "scalecodec==1.2.11", + "scalecodec==1.2.12", "uvicorn", "bittensor-drand>=1.0.0,<2.0.0", "bittensor-wallet>=4.0.0,<5.0", From 8ce77c6267a6c98762a935e59d8234693c4a97a8 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 16 Oct 2025 17:44:30 -0700 Subject: [PATCH 24/26] update docstring regarding refund --- bittensor/core/async_subtensor.py | 2 +- bittensor/core/extrinsics/asyncex/crowdloan.py | 2 +- bittensor/core/extrinsics/crowdloan.py | 2 +- bittensor/core/subtensor.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 2bb79b7fb5..c690dbee43 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -5489,7 +5489,7 @@ async def refund_crowdloan( ExtrinsicResponse: The result object of the extrinsic execution. Notes: - - Can be called by any signed account (not only the creator). + - Can be called by only creator signed account. - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. - Each call processes a limited number of refunds (`RefundContributorsLimit`). - If the campaign has too many contributors, multiple refund calls are required. diff --git a/bittensor/core/extrinsics/asyncex/crowdloan.py b/bittensor/core/extrinsics/asyncex/crowdloan.py index 89c7022053..11f663f043 100644 --- a/bittensor/core/extrinsics/asyncex/crowdloan.py +++ b/bittensor/core/extrinsics/asyncex/crowdloan.py @@ -274,7 +274,7 @@ async def refund_crowdloan_extrinsic( ExtrinsicResponse: The result object of the extrinsic execution. Notes: - - Can be called by any signed account (not only the creator). + - Can be called by only creator signed account. - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. - Each call processes a limited number of refunds (`RefundContributorsLimit`). - If the campaign has too many contributors, multiple refund calls are required. diff --git a/bittensor/core/extrinsics/crowdloan.py b/bittensor/core/extrinsics/crowdloan.py index 9ba1ae557e..b2dbe73619 100644 --- a/bittensor/core/extrinsics/crowdloan.py +++ b/bittensor/core/extrinsics/crowdloan.py @@ -274,7 +274,7 @@ def refund_crowdloan_extrinsic( ExtrinsicResponse: The result object of the extrinsic execution. Notes: - - Can be called by any signed account (not only the creator). + - Can be called by only creator signed account. - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. - Each call processes a limited number of refunds (`RefundContributorsLimit`). - If the campaign has too many contributors, multiple refund calls are required. diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index a4dcdc3d48..b43a045149 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -4271,7 +4271,7 @@ def refund_crowdloan( ExtrinsicResponse: The result object of the extrinsic execution. Notes: - - Can be called by any signed account (not only the creator). + - Can be called by only creator signed account. - Refunds contributors (excluding the creator) whose funds were locked in a failed campaign. - Each call processes a limited number of refunds (`RefundContributorsLimit`). - If the campaign has too many contributors, multiple refund calls are required. From 4a8752780054303d1bc47e0d994b516e6c8345ae Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 16 Oct 2025 17:44:42 -0700 Subject: [PATCH 25/26] improve e2e test --- tests/e2e_tests/test_crowdloan.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/e2e_tests/test_crowdloan.py b/tests/e2e_tests/test_crowdloan.py index 8741fae301..c2f5ac3e84 100644 --- a/tests/e2e_tests/test_crowdloan.py +++ b/tests/e2e_tests/test_crowdloan.py @@ -27,6 +27,7 @@ def test_crowdloan_with_target( - Validate post-finalization errors - Create second crowdloan for refund test - Contribute from Alice and Dave + - Verify that refund imposable from non creator account - Refund all contributors - Verify balances after refund - Dissolve refunded crowdloan @@ -382,7 +383,15 @@ def test_crowdloan_with_target( == bob_deposit + alice_contribute_amount + dave_contribution_amount ) - # refund crowdloan + # refund crowdloan from wrong account + response = subtensor.crowdloans.refund_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan, + ) + assert "InvalidOrigin" in response.message + assert response.error["name"] == "InvalidOrigin" + + # refund crowdloan from creator account response = subtensor.crowdloans.refund_crowdloan( wallet=bob_wallet, crowdloan_id=next_crowdloan, @@ -446,6 +455,7 @@ async def test_crowdloan_with_target_async( - Validate post-finalization errors - Create second crowdloan for refund test - Contribute from Alice and Dave + - Verify that refund imposable from non creator account - Refund all contributors - Verify balances after refund - Dissolve refunded crowdloan @@ -819,7 +829,15 @@ async def test_crowdloan_with_target_async( == bob_deposit + alice_contribute_amount + dave_contribution_amount ) - # refund crowdloan + # refund crowdloan from wrong account + response = await subtensor.crowdloans.refund_crowdloan( + wallet=charlie_wallet, + crowdloan_id=next_crowdloan, + ) + assert "InvalidOrigin" in response.message + assert response.error["name"] == "InvalidOrigin" + + # refund crowdloan from creator account response = await async_subtensor.crowdloans.refund_crowdloan( wallet=bob_wallet, crowdloan_id=next_crowdloan, From da3b402fd13ed4e0e152dd355d7572c750a2e56a Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 20 Oct 2025 12:49:00 -0700 Subject: [PATCH 26/26] fix async test --- tests/e2e_tests/test_crowdloan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e_tests/test_crowdloan.py b/tests/e2e_tests/test_crowdloan.py index c2f5ac3e84..472c743086 100644 --- a/tests/e2e_tests/test_crowdloan.py +++ b/tests/e2e_tests/test_crowdloan.py @@ -830,7 +830,7 @@ async def test_crowdloan_with_target_async( ) # refund crowdloan from wrong account - response = await subtensor.crowdloans.refund_crowdloan( + response = await async_subtensor.crowdloans.refund_crowdloan( wallet=charlie_wallet, crowdloan_id=next_crowdloan, )