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 diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 3e029e5b0b..c690dbee43 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 = True, + wait_for_finalization: bool = True, + ) -> 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 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. + """ + 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, 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..83e119ffbd --- /dev/null +++ b/bittensor/core/chain_data/crowdloan_info.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass +from typing import Optional + +from bittensor.core.chain_data.utils import decode_account_id +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. + + Note: + All Balance amounts are in RAO. + """ + + 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()}) diff --git a/bittensor/core/extrinsics/asyncex/crowdloan.py b/bittensor/core/extrinsics/asyncex/crowdloan.py new file mode 100644 index 0000000000..11f663f043 --- /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 = True, + wait_for_finalization: bool = True, +) -> "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 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. + """ + 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) diff --git a/bittensor/core/extrinsics/crowdloan.py b/bittensor/core/extrinsics/crowdloan.py new file mode 100644 index 0000000000..b2dbe73619 --- /dev/null +++ b/bittensor/core/extrinsics/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.subtensor import Subtensor + from bittensor.utils.balance import Balance + from scalecodec.types import GenericCall + + +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 + + check_balance_amount(amount) + + 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 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 = True, + wait_for_finalization: bool = True, +) -> "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 = 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 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 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 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. + """ + 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 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 + + check_balance_amount(new_cap) + + 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) + + +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_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 + + check_balance_amount(new_min_contribution) + + 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 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) 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, + } diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index ad0da7a617..809ee1f52c 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -1,5 +1,5 @@ -import os import importlib.metadata +import os import re from pathlib import Path diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index f5a96a91a5..b43a045149 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, @@ -43,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, @@ -196,6 +209,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 +1272,170 @@ 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: + query = self.query_constant( + module_name="Crowdloan", + constant_name=const_name, + block=block, + ) + + if query is not None: + result[const_name] = query.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"]: @@ -3749,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 = True, + wait_for_finalization: bool = True, + ) -> 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", @@ -3872,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 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. + """ + 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", @@ -5064,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, + ) 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..f07ba78f3b --- /dev/null +++ b/bittensor/extras/subtensor_api/crowdloans.py @@ -0,0 +1,25 @@ +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.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 762957145c..ecc0d470f3 100644 --- a/bittensor/extras/subtensor_api/utils.py +++ b/bittensor/extras/subtensor_api/utils.py @@ -19,6 +19,19 @@ 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 + ) + 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 @@ -167,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 @@ -203,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 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 diff --git a/tests/e2e_tests/test_crowdloan.py b/tests/e2e_tests/test_crowdloan.py new file mode 100644 index 0000000000..472c743086 --- /dev/null +++ b/tests/e2e_tests/test_crowdloan.py @@ -0,0 +1,1095 @@ +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( + 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 + - Verify that refund imposable from non creator account + - 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 + + # 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(15) + + # 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=fred_wallet.hotkey.ss58_address, + ) + 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=fred_wallet.hotkey.ss58_address, + ) + 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=fred_wallet.hotkey.ss58_address, + ) + 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=fred_wallet.hotkey.ss58_address, + ) + 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=fred_wallet.hotkey.ss58_address, + ) + assert "BlockDurationTooLong" in response.message + assert response.error["name"] == "BlockDurationTooLong" + + # === 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=end_block, + target_address=fred_wallet.hotkey.ss58_address, + ) + 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 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 = 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, + new_min_contribution=Balance.from_tao(5), + ) + assert response.success, response.message + + # check contribution not enough + response = 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 = 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 + ) + 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 + ) + + # contribution 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 + + # 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 + ) + assert "AlreadyFinalized" in response.message + assert response.error["name"] == "AlreadyFinalized" + + # 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" + + # 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, + ) + 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 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, + ) + 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 + + +@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 + - Verify that refund imposable from non creator account + - 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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 from wrong account + response = await async_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, + ) + 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 +): + """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, + ) + + # 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 + 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 + + +@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, + ) + + # 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 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 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] 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