diff --git a/.github/workflows/e2e-subtensor-tests.yaml b/.github/workflows/e2e-subtensor-tests.yaml index 93e64e9826..f19bc2d454 100644 --- a/.github/workflows/e2e-subtensor-tests.yaml +++ b/.github/workflows/e2e-subtensor-tests.yaml @@ -17,7 +17,7 @@ on: workflow_dispatch: inputs: docker_image_tag: - description: "Docker image tag" + description: "Standard docker image tag (ignored if custom tag is set below)" required: false type: choice default: "devnet-ready" @@ -26,6 +26,11 @@ on: - testnet - devnet - devnet-ready + custom_image_tag: + description: "Custom docker image tag — overrides selection above (e.g. pr-2441)" + required: false + type: string + default: "" # job to run tests in parallel jobs: @@ -109,16 +114,25 @@ jobs: id: set-image env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_CUSTOM_TAG: ${{ github.event.inputs.custom_image_tag }} + INPUT_CHOICE_TAG: ${{ github.event.inputs.docker_image_tag }} run: | echo "Event: $GITHUB_EVENT_NAME" echo "Branch: $GITHUB_REF_NAME" - # Check if docker_image_tag input is provided (for workflow_dispatch) if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - docker_tag_input="${{ github.event.inputs.docker_image_tag }}" - if [[ -n "$docker_tag_input" ]]; then - image="ghcr.io/opentensor/subtensor-localnet:${docker_tag_input}" - echo "Using Docker image tag from workflow_dispatch input: ${docker_tag_input}" + custom_tag=$(echo "$INPUT_CUSTOM_TAG" | xargs) + if [[ -n "$custom_tag" ]]; then + image="ghcr.io/opentensor/subtensor-localnet:${custom_tag}" + echo "Using custom docker image tag: ${custom_tag}" + echo "✅ Final selected image: $image" + echo "image=$image" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ -n "$INPUT_CHOICE_TAG" ]]; then + image="ghcr.io/opentensor/subtensor-localnet:${INPUT_CHOICE_TAG}" + echo "Using standard docker image tag: ${INPUT_CHOICE_TAG}" echo "✅ Final selected image: $image" echo "image=$image" >> "$GITHUB_OUTPUT" exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index f728b39f74..ad73bede2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 10.2.0 /2026-03-19 + +## What's Changed +* Rework coldkey swap by @basfroman in https://github.com/opentensor/bittensor/pull/3218 +* Make SDK consistent with new `Balancer swap` logic by @basfroman in https://github.com/opentensor/bittensor/pull/3250 +* Fixes for many chain changes by @basfroman in https://github.com/opentensor/bittensor/pull/3254 +* Add subnet buyback extrinsic support to SDK by @basfroman in https://github.com/opentensor/bittensor/pull/3249 +* Add py.typed and pkg tools by @thewhaleking in https://github.com/opentensor/bittensor/pull/3253 +* Rename `subnet_buyback` extrinsic `to add_stake_burn` by @basfroman in https://github.com/opentensor/bittensor/pull/3256 +* fixed fee assumption by @basfroman in https://github.com/opentensor/bittensor/pull/3258 +* `get_subnet_prices` uses new runtime api call + fallback by @basfroman in https://github.com/opentensor/bittensor/pull/3259 +* docs: update documentation URLs to docs.learnbittensor.org by @droppingbeans in https://github.com/opentensor/bittensor/pull/3257 +* docs: fix broken changelog URL and typo in README by @droppingbeans in https://github.com/opentensor/bittensor/pull/3263 +* docs: fix branch name 'develop' to 'staging' in hotfix workflow by @droppingbeans in https://github.com/opentensor/bittensor/pull/3266 +* Handle scaleobj from asi in fixed_to_float by @thewhaleking in https://github.com/opentensor/bittensor/pull/3265 +* fix batching test by @basfroman in https://github.com/opentensor/bittensor/pull/3267 +* Technical debt by @basfroman in https://github.com/opentensor/bittensor/pull/3274 +* Revert/balancer update by @ibraheem-abe in https://github.com/opentensor/bittensor/pull/3276 +* Update e2e gh workflow by @basfroman in https://github.com/opentensor/bittensor/pull/3277 +* Fix typo: pacakge → package in README by @droppingbeans in https://github.com/opentensor/bittensor/pull/3272 +* docs: fix typos in contrib documentation by @droppingbeans in https://github.com/opentensor/bittensor/pull/3271 +* Rework shielded transactions by @basfroman in https://github.com/opentensor/bittensor/pull/3269 +* Re enable alpha fees by @basfroman in https://github.com/opentensor/bittensor/pull/3284 +* Update/mev shield period by @ibraheem-abe in https://github.com/opentensor/bittensor/pull/3280 +* Adds signed commit info to contrib by @thewhaleking in https://github.com/opentensor/bittensor/pull/3289 +* Typing improvements related to InfoBase by @thewhaleking in https://github.com/opentensor/bittensor/pull/3288 +* Update: Pin btwallet requirement by @ibraheem-abe in https://github.com/opentensor/bittensor/pull/3290 + +## New Contributors +* @droppingbeans made their first contribution in https://github.com/opentensor/bittensor/pull/3257 + +**Full Changelog**: https://github.com/opentensor/bittensor/compare/v10.1.0...v10.2.0 + ## 10.1.0 /2026-01-15 ## What's Changed @@ -16,7 +49,7 @@ * @Dairus01 made their first contribution in https://github.com/opentensor/bittensor/pull/3231 * @Olexandr88 made their first contribution in https://github.com/opentensor/bittensor/pull/3238 -**Full Changelog**: https://github.com/opentensor/bittensor/compare/v10.1.0...v10.0.2 +**Full Changelog**: https://github.com/opentensor/bittensor/compare/v10.0.1...v10.1.0 ## 10.0.1 /2025-12-22 diff --git a/README.md b/README.md index 41e53a02dc..ec886da526 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## Internet-scale Neural Networks -[Discord](https://discord.gg/qasY3HA9F9) • [Network](https://taostats.io/) • [Research](https://bittensor.com/whitepaper) • [Documentation](https://docs.bittensor.com) +[Discord](https://discord.gg/qasY3HA9F9) • [Network](https://taostats.io/) • [Research](https://bittensor.com/whitepaper) • [Documentation](https://docs.learnbittensor.org) @@ -45,8 +45,8 @@ Welcome! Bittensor is an open source platform on which you can produce competiti The Opentensor Foundation (OTF) provides all the open source tools, including this Bittensor SDK, the codebase and the documentation, with step-by-step tutorials and guides, to enable you to participate in the Bittensor ecosystem. -- **Developer documentation**: https://docs.bittensor.com. -- **A Beginner's Q and A on Bittensor**: https://docs.bittensor.com/questions-and-answers. +- **Developer documentation**: https://docs.learnbittensor.org. +- **A Beginner's Q and A on Bittensor**: https://docs.learnbittensor.org/questions-and-answers. - **Bittensor whitepaper**: https://bittensor.com/whitepaper. This Bittensor SDK contains ready-to-use Python packages for interacting with the Bittensor ecosystem, writing subnet incentive mechanisms, subnet miners, subnet validators and querying the subtensor (the blockchain part of the Bittensor network). @@ -74,7 +74,7 @@ This Bittensor SDK codebase is for the Bittensor platform only, designed to help ## Release Notes -See [Bittensor SDK Release Notes](https://docs.bittensor.com/bittensor-rel-notes). +See [Bittensor SDK Release Notes](https://docs.learnbittensor.org/bittensor-rel-notes). --- @@ -98,7 +98,7 @@ python3 -m pip install --upgrade bittensor The macOS preinstalled CPython installation is compiled with LibreSSL instead of OpenSSL. There are a number of issues with LibreSSL, and as such is not fully supported by the libraries used by bittensor. Thus we highly recommend, if you are using a Mac, to first install Python from [Homebrew](https://brew.sh/). Additionally, the Rust FFI bindings -[if installing from precompiled wheels (default)] require the Homebrew-installed OpenSSL pacakge. If you choose to use +[if installing from precompiled wheels (default)] require the Homebrew-installed OpenSSL package. If you choose to use the preinstalled Python version from macOS, things may not work completely. ### Installation @@ -253,7 +253,7 @@ pytest tests/unit_tests - `LOCALNET_SH_PATH` - path to `localnet.sh` script in cloned subtensor repository (for legacy runner); - `BUILD_BINARY` - (`=0` or `=1`) - used with `LOCALNET_SH_PATH` for build or not before start localnet node (for legacy runner); - `USE_DOCKER` - (`=0` or `=1`) - used if you want to use specific runner to run e2e tests (for docker runner); -- `FAST_BLOCKS` - (`=0` or `=1`) - allows you to run a localnet node in fast or non-fast blocks mode (for both types of runers). +- `FAST_BLOCKS` - (`=0` or `=1`) - allows you to run a localnet node in fast or non-fast blocks mode (for both types of runners). - `SKIP_PULL` - used if you are using a Docker image, but for some reason you want to temporarily limit the logic of updating the image from the repository. #### Using `docker runner` (default for now): diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index d55a22fb15..d199c029fc 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -15,6 +15,9 @@ from scalecodec import GenericCall from bittensor.core.chain_data import ( + ColdkeySwapAnnouncementInfo, + ColdkeySwapConstants, + ColdkeySwapDisputeInfo, CrowdloanConstants, CrowdloanInfo, DelegateInfo, @@ -51,6 +54,11 @@ root_set_pending_childkey_cooldown_extrinsic, set_children_extrinsic, ) +from bittensor.core.extrinsics.asyncex.coldkey_swap import ( + announce_coldkey_swap_extrinsic, + dispute_coldkey_swap_extrinsic, + swap_coldkey_announced_extrinsic, +) from bittensor.core.extrinsics.asyncex.crowdloan import ( contribute_crowdloan_extrinsic, create_crowdloan_extrinsic, @@ -103,6 +111,7 @@ serve_axon_extrinsic, ) from bittensor.core.extrinsics.asyncex.staking import ( + add_stake_burn_extrinsic, add_stake_extrinsic, add_stake_multiple_extrinsic, set_auto_stake_extrinsic, @@ -152,6 +161,7 @@ ) from bittensor.utils.balance import ( Balance, + FixedPoint, check_balance_amount, fixed_to_float, ) @@ -1055,7 +1065,7 @@ async def blocks_since_last_step( reuse_block=reuse_block, params=[netuid], ) - return query.value if query is not None and hasattr(query, "value") else query + return cast(Optional[int], getattr(query, "value", query)) async def blocks_since_last_update( self, @@ -1319,13 +1329,12 @@ async def get_admin_freeze_window( - """ block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - return ( - await self.substrate.query( - module="SubtensorModule", - storage_function="AdminFreezeWindow", - block_hash=block_hash, - ) - ).value + query = await self.substrate.query( + module="SubtensorModule", + storage_function="AdminFreezeWindow", + block_hash=block_hash, + ) + return cast(int, getattr(query, "value", query)) async def get_all_subnets_info( self, @@ -1920,7 +1929,11 @@ async def get_children_pending( ), reuse_block_hash=reuse_block, ) - children, cooldown = response.value + pending_value = getattr(response, "value", response) + children, cooldown = cast( + tuple[list[tuple[int, Any]], int], + pending_value, + ) return ( [ @@ -1933,6 +1946,283 @@ async def get_children_pending( cooldown, ) + async def get_coldkey_swap_announcement( + self, + coldkey_ss58: str, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional["ColdkeySwapAnnouncementInfo"]: + """ + Retrieves coldkey swap announcement for a specific coldkey. + + This method queries the SubtensorModule.ColdkeySwapAnnouncements storage for an announcement made by the given + coldkey. Announcements allow a coldkey to declare its intention to swap to a new coldkey address after a delay + period. + + Parameters: + coldkey_ss58: SS58 address of the coldkey whose announcement to retrieve. + block: The blockchain block number for the query. If `None`, queries the latest block. + block_hash: The hash of the block at which to check the parameter. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. Do not set if using `block_hash` or `block`. + + Returns: + ColdkeySwapAnnouncementInfo if announcement exists, None otherwise. Contains the execution block and + new coldkey hash. + + Notes: + - If the coldkey has no announcement, returns None. + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + if query is None: + return None + return ColdkeySwapAnnouncementInfo.from_query( + coldkey_ss58=coldkey_ss58, query=cast(ScaleObj, query) + ) + + async def get_coldkey_swap_announcements( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list["ColdkeySwapAnnouncementInfo"]: + """ + Retrieves all coldkey swap announcements from the chain. + + This method queries the SubtensorModule.ColdkeySwapAnnouncements storage map across all coldkeys and returns a + list of all active announcements. + + Parameters: + block: The blockchain block number for the query. If `None`, queries the latest block. + block_hash: The hash of the block at which to check the parameter. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. Do not set if using `block_hash` or `block`. + + Returns: + List of ColdkeySwapAnnouncementInfo objects representing all active coldkey swap announcements on the chain. + + Notes: + - This method queries all announcements on the chain, which may be resource-intensive for large networks. + Consider using :meth:`get_coldkey_swap_announcement` for querying specific coldkeys. + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query_map = await self.substrate.query_map( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + return [ + ColdkeySwapAnnouncementInfo.from_record(record) + async for record in query_map + ] + + async def get_coldkey_swap_announcement_delay( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """ + Retrieves the ColdkeySwapAnnouncementDelay storage value. + + This method queries the SubtensorModule.ColdkeySwapAnnouncementDelay storage value, which defines the number + of blocks that must elapse after making an announcement before the swap can be executed. + + Parameters: + block: The blockchain block number for the query. If `None`, queries the latest block. + block_hash: The hash of the block at which to check the parameter. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. Do not set if using `block_hash` or `block`. + + Returns: + The number of blocks that must elapse before swap execution (integer). + + Notes: + - This is a storage value (can be changed via admin extrinsics), not a runtime constant. + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncementDelay", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + value = getattr(query, "value", query) + return cast(int, value) if value is not None else 0 + + async def get_coldkey_swap_reannouncement_delay( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """ + Retrieves the ColdkeySwapReannouncementDelay storage value. + + This method queries the SubtensorModule.ColdkeySwapReannouncementDelay storage value, which defines the number + of blocks that must elapse between the original announcement and a reannouncement. + + Parameters: + block: The blockchain block number for the query. If `None`, queries the latest block. + block_hash: The hash of the block at which to check the parameter. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. Do not set if using `block_hash` or `block`. + + Returns: + The number of blocks that must elapse before reannouncement (integer). + + Notes: + - This is a storage value (can be changed via admin extrinsics), not a runtime constant. + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapReannouncementDelay", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + value = getattr(query, "value", query) + return cast(int, value) if value is not None else 0 + + async def get_coldkey_swap_dispute( + self, + coldkey_ss58: str, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional["ColdkeySwapDisputeInfo"]: + """ + Retrieves coldkey swap dispute for a specific coldkey. + + This method queries the SubtensorModule.ColdkeySwapDisputes storage for a dispute recorded for the given + coldkey. When a coldkey swap is disputed, the account is frozen until a root-only reset clears it. + + Parameters: + coldkey_ss58: SS58 address of the coldkey whose dispute to retrieve. + block: The blockchain block number for the query. If `None`, queries the latest block. + block_hash: The hash of the block at which to check the parameter. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. Do not set if using `block_hash` or `block`. + + Returns: + ColdkeySwapDisputeInfo if dispute exists, None otherwise. Contains the disputed block number. + + Notes: + - If the coldkey has no dispute, returns None. + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + if query is None: + return None + return ColdkeySwapDisputeInfo.from_query( + coldkey_ss58=coldkey_ss58, query=cast(ScaleObj, query) + ) + + async def get_coldkey_swap_disputes( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list["ColdkeySwapDisputeInfo"]: + """ + Retrieves all coldkey swap disputes from the chain. + + This method queries the SubtensorModule.ColdkeySwapDisputes storage map across all coldkeys and returns a + list of all active disputes. + + Parameters: + block: The blockchain block number for the query. If `None`, queries the latest block. + block_hash: The hash of the block at which to check the parameter. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. Do not set if using `block_hash` or `block`. + + Returns: + List of ColdkeySwapDisputeInfo objects representing all active coldkey swap disputes on the chain. + + Notes: + - This method queries all disputes on the chain, which may be resource-intensive for large networks. + Consider using :meth:`get_coldkey_swap_dispute` for querying specific coldkeys. + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query_map = await self.substrate.query_map( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + return [ + ColdkeySwapDisputeInfo.from_record(record) async for record in query_map + ] + + async def get_coldkey_swap_constants( + self, + constants: Optional[list[str]] = None, + as_dict: bool = False, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Union["ColdkeySwapConstants", dict]: + """ + Fetches runtime configuration constants for coldkey swap operations. + + This method retrieves on-chain runtime constants that define cost requirements for coldkey swap operations. + Note: For delay values (ColdkeySwapAnnouncementDelay and ColdkeySwapReannouncementDelay), use the dedicated + query methods `get_coldkey_swap_announcement_delay()` and `get_coldkey_swap_reannouncement_delay()` instead, + as these are storage values, not runtime constants. + + Parameters: + constants: Optional list of specific constant names to fetch. If omitted, all constants defined in + `ColdkeySwapConstants.constants_names()` are queried. Valid constant names include: "KeySwapCost". + as_dict: If True, returns the constants as a dictionary instead of a `ColdkeySwapConstants` object. + block: The blockchain block number for the query. If `None`, queries the latest block. + block_hash: The hash of the block at which to check the parameter. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. Do not set if using `block_hash` or `block`. + + Returns: + If `as_dict` is False: ColdkeySwapConstants object containing all requested constants. + If `as_dict` is True: Dictionary mapping constant names to their values (integers for cost in RAO). + + Notes: + - All amounts are returned in RAO. Values reflect the current chain configuration at the specified block. + - KeySwapCost is a runtime constant (queryable via constants). + - See: + """ + result = {} + const_names = constants or ColdkeySwapConstants.constants_names() + + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + + for const_name in const_names: + # Query as runtime constant + query = await self.query_constant( + module_name="SubtensorModule", + constant_name=const_name, + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + if query is not None: + result[const_name] = query.value + + constants_obj = ColdkeySwapConstants.from_dict(result) + + return constants_obj.to_dict() if as_dict else constants_obj + async def get_commitment( self, netuid: int, @@ -2017,7 +2307,9 @@ async def get_commitment_metadata( block_hash=block_hash, reuse_block_hash=reuse_block, ) - return commit_data + if commit_data is None: + return "" + return cast(Union[str, dict], getattr(commit_data, "value", commit_data)) async def get_crowdloan_constants( self, @@ -2186,7 +2478,8 @@ async def get_crowdloan_next_id( storage_function="NextCrowdloanId", block_hash=block_hash, ) - return int(result.value or 0) + value = getattr(result, "value", result) + return int(value or 0) async def get_crowdloans( self, @@ -2548,7 +2841,7 @@ async def get_hotkey_owner( if hk_owner_query: exists = await self.does_hotkey_exist(hotkey_ss58, block_hash=block_hash) hotkey_owner = hk_owner_query if exists else None - return hotkey_owner + return cast(Optional[str], getattr(hotkey_owner, "value", hotkey_owner)) async def get_last_bonds_reset( self, @@ -3068,120 +3361,6 @@ async def get_mev_shield_next_key( return public_key_bytes - async def get_mev_shield_submission( - self, - submission_id: str, - block: Optional[int] = None, - block_hash: Optional[str] = None, - reuse_block: bool = False, - ) -> Optional[dict[str, str | int | bytes]]: - """ - Retrieves Submission from the MevShield pallet storage. - - If submission_id is provided, returns a single submission. If submission_id is None, returns all submissions from - the storage map. - - Parameters: - submission_id: The hash ID of the submission. Can be a hex string with "0x" prefix or bytes. If None, - returns all submissions. - block: The blockchain block number for the query. - block_hash: The hash of the block to retrieve the stake from. Do not specify if using block or reuse_block. - reuse_block: Whether to use the last-used block. Do not set if using block_hash or block. - - Returns: - If submission_id is provided: A dictionary containing the submission data if found, None otherwise. The - dictionary contains: - - author: The SS58 address of the account that submitted the encrypted extrinsic - - commitment: The blake2_256 hash of the payload_core (as hex string with "0x" prefix) - - ciphertext: The encrypted blob as bytes (format: [u16 kem_len][kem_ct][nonce24][aead_ct]) - - submitted_in: The block number when the submission was created - - If submission_id is None: A dictionary mapping submission IDs (as hex strings) to submission dictionaries. - - Note: - If a specific submission does not exist in storage, this function returns None. If querying all submissions - and none exist, returns an empty dictionary. - """ - block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - submission_id = ( - submission_id[2:] if submission_id.startswith("0x") else submission_id - ) - submission_id_bytes = bytes.fromhex(submission_id) - - query = await self.substrate.query( - module="MevShield", - storage_function="Submissions", - params=[submission_id_bytes], - block_hash=block_hash, - ) - - if query is None or not isinstance(query, dict): - return None - - autor = decode_account_id(query.get("author")) - commitment = bytes(query.get("commitment")[0]) - ciphertext = bytes(query.get("ciphertext")[0]) - submitted_in = query.get("submitted_in") - - return { - "author": autor, - "commitment": commitment, - "ciphertext": ciphertext, - "submitted_in": submitted_in, - } - - async def get_mev_shield_submissions( - self, - block: Optional[int] = None, - block_hash: Optional[str] = None, - reuse_block: bool = False, - ) -> Optional[dict[str, dict[str, str | int]]]: - """ - Retrieves all encrypted submissions from the MevShield pallet storage. - - This function queries the MevShield.Submissions storage map and returns all pending encrypted submissions that - have been submitted via submit_encrypted but not yet executed via execute_revealed. - - Parameters: - block: The blockchain block number for the query. If None, uses the current block. - block_hash: The hash of the block to retrieve the submissions from. Do not specify if using block or reuse_block. - reuse_block: Whether to use the last-used block. Do not set if using block_hash or block. - - Returns: - A dictionary mapping wrapper_id (as hex string with "0x" prefix) to submission data dictionaries. Each - submission dictionary contains: - - author: The SS58 address of the account that submitted the encrypted extrinsic - - commitment: The blake2_256 hash of the payload_core as bytes (32 bytes) - - ciphertext: The encrypted blob as bytes (format: [u16 kem_len][kem_ct][nonce24][aead_ct]) - - submitted_in: The block number when the submission was created - - Returns None if no submissions exist in storage at the specified block. - - Note: - Submissions are automatically pruned after KEY_EPOCH_HISTORY blocks (100 blocks) by the pallet's - on_initialize hook. Only submissions that have been submitted but not yet executed will be present in - storage. - """ - block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - query = await self.substrate.query_map( - module="MevShield", - storage_function="Submissions", - block_hash=block_hash, - ) - - result = {} - async for q in query: - key, value = q - value = value.value - result["0x" + bytes(key[0]).hex()] = { - "author": decode_account_id(value.get("author")), - "commitment": bytes(value.get("commitment")[0]), - "ciphertext": bytes(value.get("ciphertext")[0]), - "submitted_in": value.get("submitted_in"), - } - - return result if result else None - async def get_minimum_required_stake(self): """Returns the minimum required stake threshold for nominator cleanup operations. @@ -3563,7 +3742,8 @@ async def get_proxy_announcement( block_hash=block_hash, reuse_block_hash=reuse_block, ) - return ProxyAnnouncementInfo.from_dict(query.value[0]) + query_value = getattr(query, "value", query) + return ProxyAnnouncementInfo.from_dict(cast(list[Any], query_value)[0]) async def get_proxy_announcements( self, @@ -3779,9 +3959,11 @@ async def get_root_claim_type( block_hash=block_hash, reuse_block_hash=reuse_block, ) + query_value = getattr(query, "value", query) + claim_type = cast(dict[str, Any], query_value) # Query returns enum as dict: {"Swap": ()} or {"Keep": ()} or {"KeepSubnets": {"subnets": [1, 2, 3]}} - variant_name = next(iter(query.keys())) - variant_value = query[variant_name] + variant_name = next(iter(claim_type.keys())) + variant_value = claim_type[variant_name] # For simple variants (Swap, Keep), value is empty tuple, return string if not variant_value or variant_value == (): @@ -3827,7 +4009,8 @@ async def get_root_alpha_dividends_per_subnet( block_hash=block_hash, reuse_block_hash=reuse_block, ) - return Balance.from_rao(query.value).set_unit(netuid=netuid) + value = getattr(query, "value", query) + return Balance.from_rao(cast(int, value)).set_unit(netuid=netuid) async def get_root_claimable_rate( self, @@ -3895,7 +4078,8 @@ async def get_root_claimable_all_rates( block_hash=block_hash, reuse_block_hash=reuse_block, ) - bits_list = next(iter(query.value)) + query_value = getattr(query, "value", query) + bits_list = next(iter(cast(list[list[tuple[int, FixedPoint]]], query_value))) return {bits[0]: fixed_to_float(bits[1], frac_bits=32) for bits in bits_list} async def get_root_claimable_stake( @@ -3994,7 +4178,8 @@ async def get_root_claimed( block_hash=block_hash, reuse_block_hash=reuse_block, ) - return Balance.from_rao(query.value).set_unit(netuid=netuid) + value = getattr(query, "value", query) + return Balance.from_rao(cast(int, value)).set_unit(netuid=netuid) async def get_stake( self, @@ -4006,54 +4191,30 @@ async def get_stake( reuse_block: bool = False, ) -> Balance: """ - Returns the stake under a coldkey - hotkey pairing. + Returns the amount of Alpha staked by a specific coldkey to a specific hotkey within a given subnet. Parameters: - hotkey_ss58: The SS58 address of the hotkey. - coldkey_ss58: The SS58 address of the coldkey. - netuid: The subnet ID. - block: The block number at which to query the stake information. - block_hash: The hash of the block to retrieve the stake from. Do not specify if using block - or reuse_block - reuse_block: Whether to use the last-used block. Do not set if using `block_hash` or `block`. + coldkey_ss58: The SS58 address of the coldkey that delegated the stake. This address owns the stake. + hotkey_ss58: The SS58 address of the hotkey which the stake is on. + netuid: The unique identifier of the subnet to query. + block: The specific block number at which to retrieve the stake information. + or `reuse_block`. + block_hash: The hash of the block to retrieve the stake from. Do not specify if using `block` + or `reuse_block`. + reuse_block: Whether to use the last-used block hash. Do not set if using `block_hash` or `block`. Returns: - Balance: The stake under the coldkey - hotkey pairing. + An object representing the amount of Alpha (TAO ONLY if the subnet's netuid is 0) currently staked from the + specified coldkey to the specified hotkey within the given subnet. """ block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - - alpha_shares = await self.query_subtensor( - name="Alpha", - block=block, - block_hash=block_hash, - reuse_block=reuse_block, + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_info_for_hotkey_coldkey_netuid", params=[hotkey_ss58, coldkey_ss58, netuid], - ) - hotkey_alpha_result = await self.query_subtensor( - name="TotalHotkeyAlpha", - block=block, - block_hash=block_hash, - reuse_block=reuse_block, - params=[hotkey_ss58, netuid], - ) - hotkey_shares = await self.query_subtensor( - name="TotalHotkeyShares", - block=block, block_hash=block_hash, - reuse_block=reuse_block, - params=[hotkey_ss58, netuid], ) - - hotkey_alpha: int = getattr(hotkey_alpha_result, "value", 0) - alpha_shares_as_float = fixed_to_float(alpha_shares) - hotkey_shares_as_float = fixed_to_float(hotkey_shares) - - if hotkey_shares_as_float == 0: - return Balance.from_rao(0).set_unit(netuid=netuid) - - stake = alpha_shares_as_float / hotkey_shares_as_float * hotkey_alpha - - return Balance.from_rao(int(stake)).set_unit(netuid=netuid) + return StakeInfo.from_dict(result).stake async def get_stake_add_fee( self, @@ -4304,7 +4465,7 @@ async def get_stake_weight( params=[netuid], block_hash=block_hash, ) - return [u16_normalized_float(w) for w in result] + return [u16_normalized_float(w) for w in cast(list[int], result or [])] async def get_start_call_delay( self, @@ -4463,13 +4624,14 @@ async def get_subnet_owner_hotkey( Returns: The hotkey of the subnet owner if available; None otherwise. """ - return await self.query_subtensor( + query = await self.query_subtensor( name="SubnetOwnerHotkey", params=[netuid], block=block, block_hash=block_hash, reuse_block=reuse_block, ) + return cast(Optional[str], getattr(query, "value", query)) async def get_subnet_price( self, @@ -4598,7 +4760,7 @@ async def get_subnet_validator_permits( block_hash=block_hash, reuse_block=reuse_block, ) - return query.value if query is not None and hasattr(query, "value") else query + return cast(Optional[list[bool]], getattr(query, "value", query)) async def get_timelocked_weight_commits( self, @@ -4819,12 +4981,15 @@ async def get_vote_data( network, particularly how proposals are received and acted upon by the governing body. """ block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - vote_data: dict[str, Any] = await self.substrate.query( - module="Triumvirate", - storage_function="Voting", - params=[proposal_hash], - block_hash=block_hash, - reuse_block_hash=reuse_block, + vote_data = cast( + Optional[dict[str, Any]], + await self.substrate.query( + module="Triumvirate", + storage_function="Voting", + params=[proposal_hash], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ), ) if vote_data is None: @@ -4864,7 +5029,7 @@ async def get_uid_for_hotkey_on_subnet( block_hash=block_hash, reuse_block_hash=reuse_block, ) - return getattr(result, "value", result) + return cast(Optional[int], getattr(result, "value", result)) async def filter_netuids_by_registered_hotkeys( self, @@ -5463,23 +5628,21 @@ async def query_identity( parameters. """ block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - identity_info = cast( - dict, - await self.substrate.query( - module="SubtensorModule", - storage_function="IdentitiesV2", - params=[coldkey_ss58], - block_hash=block_hash, - reuse_block_hash=reuse_block, - ), + identity_info = await self.substrate.query( + module="SubtensorModule", + storage_function="IdentitiesV2", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, ) if not identity_info: return None try: + identity_data = getattr(identity_info, "value", identity_info) return ChainIdentity.from_dict( - decode_hex_identity_dict(identity_info), + decode_hex_identity_dict(cast(dict[str, Any], identity_data)), ) except TypeError: return None @@ -5710,7 +5873,9 @@ async def handler(block_data: dict): return True return None - current_block = await self.substrate.get_block() + current_block = cast(Optional[dict[str, Any]], await self.substrate.get_block()) + if current_block is None: + return False current_block_hash = current_block.get("header", {}).get("hash") if block is not None: @@ -6144,14 +6309,13 @@ async def add_stake( wait_for_revealed_execution=wait_for_revealed_execution, ) - async def add_liquidity( + async def add_stake_burn( self, wallet: "Wallet", netuid: int, - liquidity: Balance, - price_low: Balance, - price_high: Balance, - hotkey_ss58: Optional[str] = None, + hotkey_ss58: str, + amount: Balance, + limit_price: Optional[Balance] = None, *, mev_protection: bool = DEFAULT_MEV_PROTECTION, period: Optional[int] = DEFAULT_PERIOD, @@ -6161,41 +6325,40 @@ async def add_liquidity( wait_for_revealed_execution: bool = True, ) -> ExtrinsicResponse: """ - Adds liquidity to the specified price range. + Executes a subnet buyback by staking TAO and immediately burning the resulting Alpha. + + Only the subnet owner can call this method, and it is rate-limited to one call per subnet tempo. Parameters: - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - liquidity: The amount of liquidity to be added. - price_low: The lower bound of the price tick range. In TAO. - price_high: The upper bound of the price tick range. In TAO. - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. + wallet: The wallet used to sign the extrinsic (must be the subnet owner). + netuid: The unique identifier of the subnet. + hotkey_ss58: The `SS58` address of the hotkey account to stake to. + amount: The amount of TAO to use for the buyback. + limit_price: Optional limit price expressed in units of RAO per one Alpha. mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators decrypt and execute it. If `False`, submits the transaction directly without encryption. - 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. + 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. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. Returns: ExtrinsicResponse: The result object of the extrinsic execution. - - Note: - Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity` - method to enable/disable user liquidity. """ - return await add_liquidity_extrinsic( + check_balance_amount(amount) + if limit_price is not None: + check_balance_amount(limit_price) + return await add_stake_burn_extrinsic( subtensor=self, wallet=wallet, netuid=netuid, - liquidity=liquidity, - price_low=price_low, - price_high=price_high, hotkey_ss58=hotkey_ss58, + amount=amount, + limit_price=limit_price, mev_protection=mev_protection, period=period, raise_error=raise_error, @@ -6259,6 +6422,66 @@ async def add_stake_multiple( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def add_liquidity( + self, + wallet: "Wallet", + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + hotkey_ss58: Optional[str] = None, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Adds liquidity to the specified price range. + + Parameters: + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The UID of the target subnet for which the call is being initiated. + liquidity: The amount of liquidity to be added. + price_low: The lower bound of the price tick range. In TAO. + price_high: The upper bound of the price tick range. In TAO. + hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + 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. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Note: + Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity` + method to enable/disable user liquidity. + """ + return await add_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + liquidity=liquidity, + price_low=price_low, + price_high=price_high, + hotkey_ss58=hotkey_ss58, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def add_proxy( self, wallet: "Wallet", @@ -6329,6 +6552,62 @@ async def add_proxy( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def announce_coldkey_swap( + self, + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Announces a coldkey swap by submitting the BlakeTwo256 hash of the new coldkey. + + This method allows a coldkey to declare its intention to swap to a new coldkey address. The announcement must be + made before the actual swap can be executed, and a delay period must pass before execution is allowed. + After making an announcement, all transactions from the coldkey are blocked except for `swap_coldkey_announced`. + + Parameters: + wallet: Bittensor wallet object (should be the current coldkey wallet). + new_coldkey_ss58: SS58 address of the new coldkey that will replace the current one. + mev_protection: If ``True``, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If ``False``, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - A swap cost is charged when making the first announcement (not when reannouncing). + - After making an announcement, all transactions from the coldkey are blocked except for + `swap_coldkey_announced`. + - The swap can only be executed after the delay period has passed (check via + `get_coldkey_swap_announcement`). + - See: + """ + return await announce_coldkey_swap_extrinsic( + subtensor=self, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def announce_proxy( self, wallet: "Wallet", @@ -6792,6 +7071,50 @@ async def create_pure_proxy( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def dispute_coldkey_swap( + self, + wallet: "Wallet", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Disputes the coldkey swap announcement for the current coldkey. + + Callable by the coldkey that has an active swap announcement. Marks the swap as disputed. The account is blocked + until root calls reset_coldkey_swap. + + Parameters: + wallet: Bittensor wallet object (should be the current coldkey with an active announcement). + mev_protection: If ``True``, encrypts and submits the transaction through the MEV Shield pallet. + period: The number of blocks during which the transaction will remain valid. + raise_error: Raises a relevant exception rather than returning ``False`` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The coldkey must have an active swap announcement. + - After disputing, only root can clear the state via reset_coldkey_swap. + """ + return await dispute_coldkey_swap_extrinsic( + subtensor=self, + wallet=wallet, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def dissolve_crowdloan( self, wallet: "Wallet", @@ -8962,6 +9285,59 @@ async def transfer( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def swap_coldkey_announced( + self, + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Executes a previously announced coldkey swap. + + This method executes a coldkey swap that was previously announced via `announce_coldkey_swap`. The new coldkey + address must match the hash that was announced, and the delay period must have passed. + + Parameters: + wallet: Bittensor wallet object (should be the current coldkey wallet that made the announcement). + new_coldkey_ss58: SS58 address of the new coldkey to swap to. This must match the hash that was announced. + mev_protection: If ``True``, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If ``False``, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The new coldkey hash must match the hash that was announced. + - The delay period must have passed (check via `get_coldkey_swap_announcement`). + - All assets, stakes, subnet ownerships, and hotkey associations are transferred from the old coldkey to the new one. + - See: + """ + return await swap_coldkey_announced_extrinsic( + subtensor=self, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def transfer_stake( self, wallet: "Wallet", diff --git a/bittensor/core/chain_data/__init__.py b/bittensor/core/chain_data/__init__.py index 7cb0840746..0982c5cde2 100644 --- a/bittensor/core/chain_data/__init__.py +++ b/bittensor/core/chain_data/__init__.py @@ -7,39 +7,47 @@ from .axon_info import AxonInfo from .chain_identity import ChainIdentity -from .crowdloan_info import CrowdloanInfo, CrowdloanConstants -from .delegate_info import DelegateInfo, DelegatedInfo +from .coldkey_swap import ( + ColdkeySwapAnnouncementInfo, + ColdkeySwapConstants, + ColdkeySwapDisputeInfo, +) +from .crowdloan_info import CrowdloanConstants, CrowdloanInfo +from .delegate_info import DelegatedInfo, DelegateInfo from .delegate_info_lite import DelegateInfoLite from .dynamic_info import DynamicInfo from .ip_info import IPInfo from .metagraph_info import ( MetagraphInfo, MetagraphInfoEmissions, - MetagraphInfoPool, MetagraphInfoParams, + MetagraphInfoPool, SelectiveMetagraphIndex, ) from .neuron_info import NeuronInfo from .neuron_info_lite import NeuronInfoLite from .prometheus_info import PrometheusInfo from .proposal_vote_data import ProposalVoteData -from .proxy import ProxyConstants, ProxyInfo, ProxyType, ProxyAnnouncementInfo +from .proxy import ProxyAnnouncementInfo, ProxyConstants, ProxyInfo, ProxyType from .root_claim import RootClaimType from .scheduled_coldkey_swap_info import ScheduledColdkeySwapInfo -from .stake_info import StakeInfo from .sim_swap import SimSwapResult +from .stake_info import StakeInfo from .subnet_hyperparameters import SubnetHyperparameters from .subnet_identity import SubnetIdentity from .subnet_info import SubnetInfo from .subnet_state import SubnetState -from .weight_commit_info import WeightCommitInfo from .utils import decode_account_id, process_stake_data +from .weight_commit_info import WeightCommitInfo ProposalCallData = GenericCall __all__ = [ "AxonInfo", "ChainIdentity", + "ColdkeySwapAnnouncementInfo", + "ColdkeySwapConstants", + "ColdkeySwapDisputeInfo", "CrowdloanInfo", "CrowdloanConstants", "DelegateInfo", diff --git a/bittensor/core/chain_data/coldkey_swap.py b/bittensor/core/chain_data/coldkey_swap.py new file mode 100644 index 0000000000..e638569e53 --- /dev/null +++ b/bittensor/core/chain_data/coldkey_swap.py @@ -0,0 +1,187 @@ +from dataclasses import asdict, dataclass, fields +from typing import Optional + +from async_substrate_interface.types import ScaleObj + +from bittensor.core.chain_data.utils import decode_account_id + + +@dataclass +class ColdkeySwapAnnouncementInfo: + """ + Information about a coldkey swap announcement. + + This class contains information about a pending coldkey swap announcement. Announcements are used when a coldkey + wants to declare its intention to swap to a new coldkey address. The announcement must be made before the actual + swap can be executed, allowing time for verification and security checks. + + Attributes: + coldkey: The SS58 address of the coldkey that made the announcement. + execution_block: The block number when the swap can be executed (after the delay period has passed). + new_coldkey_hash: The BlakeTwo256 hash of the new coldkey AccountId (hex string with 0x prefix). This hash + must match the actual new coldkey when the swap is executed. + + Notes: + - The announcement is stored on-chain and can be queried via `get_coldkey_swap_announcement()`. + - After making an announcement, all transactions from coldkey are blocked except for `swap_coldkey_announced`. + - The swap can only be executed after the `execution_block` has been reached. + - See: + """ + + coldkey: str + execution_block: int + new_coldkey_hash: str + + @classmethod + def from_query( + cls, coldkey_ss58: str, query: "ScaleObj" + ) -> Optional["ColdkeySwapAnnouncementInfo"]: + """ + Creates a ColdkeySwapAnnouncementInfo object from a Substrate query result. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey that made the announcement. + query: Query result from Substrate `query()` call to `ColdkeySwapAnnouncements` storage function. + + Returns: + ColdkeySwapAnnouncementInfo if announcement exists, None otherwise. + """ + if not getattr(query, "value", None): + return None + + execution_block = query.value[0] + new_coldkey_hash = "0x" + bytes(query.value[1][0]).hex() + return cls( + coldkey=coldkey_ss58, + execution_block=execution_block, + new_coldkey_hash=new_coldkey_hash, + ) + + @classmethod + def from_record(cls, record: tuple) -> "ColdkeySwapAnnouncementInfo": + """ + Creates a ColdkeySwapAnnouncementInfo object from a query_map record. + + Parameters: + record: Data item from query_map records call to ColdkeySwapAnnouncements storage function. Structure is + [key, value] where key is the coldkey AccountId and value contains (BlockNumber, Hash) tuple. + + Returns: + ColdkeySwapAnnouncementInfo object with announcement details for the coldkey from the record. + """ + coldkey_ss58 = decode_account_id(record[0]) + announcement_data = record[1] + return cls.from_query(coldkey_ss58, announcement_data) + + +@dataclass +class ColdkeySwapDisputeInfo: + """ + Information about a coldkey swap dispute. + + This class contains information about a disputed coldkey swap. When a coldkey swap is disputed, + the account is frozen until the triumvirate resolves it via a root-only reset. + + Attributes: + coldkey: The SS58 address of the coldkey that was disputed. + disputed_block: The block number when the dispute was recorded. + + Notes: + - The dispute is stored on-chain in ColdkeySwapDisputes storage. + - While disputed, the coldkey can only perform announce_coldkey_swap, swap_coldkey_announced, + or dispute_coldkey_swap (or MEV-protected calls). + - See: + """ + + coldkey: str + disputed_block: int + + @classmethod + def from_query( + cls, coldkey_ss58: str, query: "ScaleObj" + ) -> Optional["ColdkeySwapDisputeInfo"]: + """ + Creates a ColdkeySwapDisputeInfo object from a Substrate query result. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey that was disputed. + query: Query result from Substrate `query()` call to `ColdkeySwapDisputes` storage function. + + Returns: + ColdkeySwapDisputeInfo if dispute exists, None otherwise. + """ + if not getattr(query, "value", None): + return None + return cls(coldkey=coldkey_ss58, disputed_block=int(query.value)) + + @classmethod + def from_record(cls, record: tuple) -> "ColdkeySwapDisputeInfo": + """ + Creates a ColdkeySwapDisputeInfo object from a query_map record. + + Parameters: + record: Data item from query_map records call to ColdkeySwapDisputes storage function. Structure is + [key, value] where key is the coldkey AccountId and value is the disputed block number. + + Returns: + ColdkeySwapDisputeInfo object with dispute details for the coldkey from the record. + """ + coldkey_ss58 = decode_account_id(record[0]) + val = record[1] + disputed_block = ( + int(val.value) if getattr(val, "value", None) is not None else int(val) + ) + return cls(coldkey=coldkey_ss58, disputed_block=disputed_block) + + +@dataclass +class ColdkeySwapConstants: + """ + Represents runtime constants for coldkey swap operations in the SubtensorModule. + + This class contains runtime constants that define cost requirements for coldkey swap operations. + Note: For delay values (ColdkeySwapAnnouncementDelay and ColdkeySwapReannouncementDelay), use the dedicated + query methods `get_coldkey_swap_announcement_delay()` and `get_coldkey_swap_reannouncement_delay()` instead, + as these are storage values, not runtime constants. + + Attributes: + KeySwapCost: The cost in RAO required to make a coldkey swap announcement. This cost is charged when making the + first announcement (not when reannouncing). This is a runtime constant (queryable via constants). + + Notes: + - All amounts are in RAO. + - Values reflect the current chain configuration at the time of retrieval. + - See: + """ + + KeySwapCost: Optional[int] + + @classmethod + def constants_names(cls) -> list[str]: + """Returns the list of all constant field names defined in this dataclass. + + Returns: + List of constant field names as strings. + """ + return [f.name for f in fields(cls)] + + @classmethod + def from_dict(cls, data: dict) -> "ColdkeySwapConstants": + """ + Creates a ColdkeySwapConstants instance from a dictionary of decoded chain constants. + + Parameters: + data: Dictionary mapping constant names to their decoded values (returned by `Subtensor.query_constant()`). + + Returns: + ColdkeySwapConstants object with constants filled in. Fields not found in data will be set to `None`. + """ + return cls(**{name: data.get(name) for name in cls.constants_names()}) + + def to_dict(self) -> dict: + """Converts the ColdkeySwapConstants instance to a dictionary. + + Returns: + Dictionary mapping constant names to their values. + """ + return asdict(self) diff --git a/bittensor/core/chain_data/info_base.py b/bittensor/core/chain_data/info_base.py index e2718619af..f858d03137 100644 --- a/bittensor/core/chain_data/info_base.py +++ b/bittensor/core/chain_data/info_base.py @@ -1,11 +1,13 @@ from dataclasses import dataclass -from typing import Any, TypeVar +from typing import Any from bittensor.core.errors import SubstrateRequestException -# NOTE: once Python 3.10+ is required, we can use `typing.Self` instead of this for better ide integration and type hinting. -# This current generic does not play so nice with the inherited type hinting. -T = TypeVar("T", bound="InfoBase") +try: + from typing import Self +except ImportError: + # fallback to typing_extensions if Python < 3.11 + from typing_extensions import Self @dataclass @@ -13,7 +15,7 @@ class InfoBase: """Base dataclass for info objects.""" @classmethod - def from_dict(cls, decoded: dict) -> T: + def from_dict(cls, decoded: dict) -> Self: try: return cls._from_dict(decoded) except KeyError as e: @@ -22,9 +24,9 @@ def from_dict(cls, decoded: dict) -> T: ) @classmethod - def list_from_dicts(cls, any_list: list[Any]) -> list[T]: + def list_from_dicts(cls, any_list: list[Any]) -> list[Self]: return [cls.from_dict(any_) for any_ in any_list] @classmethod - def _from_dict(cls, decoded: dict) -> T: - return cls(**decoded) + def _from_dict(cls, decoded: dict) -> Self: + return cls(**decoded) # type: ignore[call-arg] diff --git a/bittensor/core/chain_data/proxy.py b/bittensor/core/chain_data/proxy.py index 6ddb63961c..8e663d26b3 100644 --- a/bittensor/core/chain_data/proxy.py +++ b/bittensor/core/chain_data/proxy.py @@ -31,14 +31,12 @@ class ProxyType(str, Enum): NonTransfer: Allows all operations except those involving token transfers. Prohibited operations: - All Balances module calls - transfer_stake - - schedule_swap_coldkey - - swap_coldkey NonFungible: Allows all operations except token-related operations and registrations. Prohibited operations: - All Balances module calls - All staking operations (add_stake, remove_stake, unstake_all, swap_stake, move_stake, transfer_stake) - Registration operations (burned_register, root_register) - - Key swap operations (schedule_swap_coldkey, swap_coldkey, swap_hotkey) + - Key swap operations (announce_coldkey_swap, swap_coldkey_announced, swap_hotkey) Staking: Allows only staking-related operations. Permitted operations: - add_stake, add_stake_limit diff --git a/bittensor/core/chain_data/scheduled_coldkey_swap_info.py b/bittensor/core/chain_data/scheduled_coldkey_swap_info.py index 361a366c37..6b3693f5df 100644 --- a/bittensor/core/chain_data/scheduled_coldkey_swap_info.py +++ b/bittensor/core/chain_data/scheduled_coldkey_swap_info.py @@ -5,7 +5,8 @@ from scalecodec.utils.ss58 import ss58_encode from bittensor.core.chain_data.info_base import InfoBase -from bittensor.core.chain_data.utils import from_scale_encoding, ChainDataType +from bittensor.core.chain_data.utils import ChainDataType, from_scale_encoding +from bittensor.utils import deprecated_message @dataclass @@ -26,6 +27,8 @@ class ScheduledColdkeySwapInfo(InfoBase): @classmethod def _from_dict(cls, decoded: dict) -> "ScheduledColdkeySwapInfo": """Returns a ScheduledColdkeySwapInfo object from decoded chain data.""" + # TODO: remove this logic in the next major release (include all references) + deprecated_message() return cls( arbitration_block=decoded["arbitration_block"], new_coldkey=ss58_encode(decoded["new_coldkey"], SS58_FORMAT), @@ -35,6 +38,8 @@ def _from_dict(cls, decoded: dict) -> "ScheduledColdkeySwapInfo": @classmethod def decode_account_id_list(cls, vec_u8: list[int]) -> Optional[list[str]]: """Decodes a list of AccountIds from vec_u8.""" + # TODO: remove this logic in the next major release (include all references) + deprecated_message() decoded = from_scale_encoding( vec_u8, ChainDataType.ScheduledColdkeySwapInfo.AccountId, is_vec=True ) diff --git a/bittensor/core/errors.py b/bittensor/core/errors.py index 438faab0b4..53e2f982ce 100644 --- a/bittensor/core/errors.py +++ b/bittensor/core/errors.py @@ -57,6 +57,8 @@ "TxRateLimitExceeded", "UnknownSynapseError", "UnstakeError", + "SHIELD_VALIDATION_ERRORS", + "map_shield_error", ] @@ -263,3 +265,32 @@ def __init__( ): self.message = message super().__init__(self.message, synapse) + + +SHIELD_VALIDATION_ERRORS = { + "Custom error: 23": ( + "Failed to parse shielded transaction: the ciphertext has an invalid format." + ), + "Custom error: 24": ( + "Invalid encryption key: the key_hash in the ciphertext does not match any known key. " + "The key may have rotated between reading NextKey and submitting the transaction." + ), +} + + +def map_shield_error(raw_message: str) -> str: + """Map a raw shield validation error to a human-readable description. + + Checks the message against known Custom error codes from CheckShieldedTxValidity, + then falls back to detecting a generic ``"invalid"`` subscription status. + Returns the original message unchanged if nothing matches. + """ + for marker, description in SHIELD_VALIDATION_ERRORS.items(): + if marker in raw_message: + return description + if "'result': 'invalid'" in raw_message.lower(): + return ( + "MEV Shield extrinsic rejected as invalid. " + "The key may have rotated between reading NextKey and submission." + ) + return raw_message diff --git a/bittensor/core/extrinsics/asyncex/coldkey_swap.py b/bittensor/core/extrinsics/asyncex/coldkey_swap.py new file mode 100644 index 0000000000..db44be4253 --- /dev/null +++ b/bittensor/core/extrinsics/asyncex/coldkey_swap.py @@ -0,0 +1,411 @@ +from typing import TYPE_CHECKING, Optional + +from bittensor_wallet import Keypair + +from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule +from bittensor.core.extrinsics.utils import ( + compute_coldkey_hash, + verify_coldkey_hash, +) +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils import deprecated_message +from bittensor.utils.btlogging import logging + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + + from bittensor.core.async_subtensor import AsyncSubtensor + + +async def announce_coldkey_swap_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Announces a coldkey swap by submitting the BlakeTwo256 hash of the new coldkey. + + This extrinsic allows a coldkey to declare its intention to swap to a new coldkey address. The announcement + must be made before the actual swap can be executed, and a delay period must pass before execution is allowed. + After making an announcement, all transactions from the coldkey are blocked except for `swap_coldkey_announced`. + + Parameters: + subtensor: AsyncSubtensor instance with the connection to the chain. + wallet: Bittensor wallet object (should be the current coldkey wallet). + new_coldkey_ss58: SS58 address of the new coldkey that will replace the current one. + mev_protection: If ``True``, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If ``False``, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - A swap cost is charged when making the first announcement (not when reannouncing). + - After making an announcement, all transactions from the coldkey are blocked except for `swap_coldkey_announced`. + - The swap can only be executed after the delay period has passed (check via `get_coldkey_swap_announcement`). + - See: + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + # Compute hash of new coldkey + new_coldkey = Keypair(ss58_address=new_coldkey_ss58) + new_coldkey_hash = compute_coldkey_hash(new_coldkey) + + logging.debug( + f"Announcing coldkey swap: current=[blue]{wallet.coldkeypub.ss58_address}[/blue], " + f"new=[blue]{new_coldkey_ss58}[/blue], " + f"hash=[blue]{new_coldkey_hash}[/blue] " + f"on [blue]{subtensor.network}[/blue]." + ) + + call = await SubtensorModule(subtensor).announce_coldkey_swap( + new_coldkey_hash=new_coldkey_hash + ) + + if mev_protection: + response = await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + if response.success: + logging.debug("[green]Coldkey swap announced successfully.[/green]") + else: + logging.error(f"[red]{response.message}[/red]") + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def dispute_coldkey_swap_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Disputes the coldkey swap announcement for the current coldkey. + + Callable by the coldkey that has an active swap announcement. Marks the swap as disputed. The account is blocked + until root calls reset_coldkey_swap. + + Parameters: + subtensor: AsyncSubtensor instance with the connection to the chain. + wallet: Bittensor wallet object (should be the current coldkey with an active announcement). + mev_protection: If ``True``, encrypts and submits the transaction through the MEV Shield pallet. + period: The number of blocks during which the transaction will remain valid. + raise_error: Raises a relevant exception rather than returning ``False`` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The coldkey must have an active swap announcement. + - After disputing, only root can clear the state via reset_coldkey_swap. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = await SubtensorModule(subtensor).dispute_coldkey_swap() + + if mev_protection: + response = await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def swap_coldkey_announced_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Executes a previously announced coldkey swap. + + This extrinsic executes a coldkey swap that was previously announced via `announce_coldkey_swap_extrinsic`. + The new coldkey address must match the hash that was announced, and the delay period must have passed. + + Parameters: + subtensor: AsyncSubtensor instance with the connection to the chain. + wallet: Bittensor wallet object (should be the current coldkey wallet that made the announcement). + new_coldkey_ss58: SS58 address of the new coldkey to swap to. This must match the hash that was announced. + mev_protection: If ``True``, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If ``False``, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The new coldkey hash must match the hash that was announced. + - The delay period must have passed (check via `get_coldkey_swap_announcement`). + - All assets, stakes, subnet ownerships, and hotkey associations are transferred from the old coldkey to the new + one. + - See: + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + # Verify announcement exists and hash matches + announcement = await subtensor.get_coldkey_swap_announcement( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + + if announcement is None: + error_msg = "No coldkey swap announcement found. Make an announcement first using announce_coldkey_swap_extrinsic." + if raise_error: + raise ValueError(error_msg) + return ExtrinsicResponse( + success=False, + message=error_msg, + extrinsic_receipt=None, + ) + + new_coldkey = Keypair(ss58_address=new_coldkey_ss58) + if not verify_coldkey_hash(new_coldkey, announcement.new_coldkey_hash): + error_msg = ( + f"New coldkey hash does not match announcement. " + f"Expected: {announcement.new_coldkey_hash}, " + f"Got: {compute_coldkey_hash(new_coldkey)}" + ) + if raise_error: + raise ValueError(error_msg) + return ExtrinsicResponse( + success=False, + message=error_msg, + extrinsic_receipt=None, + ) + + # Check if delay has passed + current_block = await subtensor.get_current_block() + if current_block < announcement.execution_block: + error_msg = ( + f"Swap too early. Current block: {current_block}, " + f"Execution block: {announcement.execution_block}. " + f"Wait for {announcement.execution_block - current_block} more blocks." + ) + if raise_error: + raise ValueError(error_msg) + return ExtrinsicResponse( + success=False, + message=error_msg, + extrinsic_receipt=None, + ) + + logging.debug( + f"Executing coldkey swap: current=[blue]{wallet.coldkeypub.ss58_address}[/blue], " + f"new=[blue]{new_coldkey_ss58}[/blue] " + f"on [blue]{subtensor.network}[/blue]." + ) + + call = await SubtensorModule(subtensor).swap_coldkey_announced( + new_coldkey=new_coldkey_ss58 + ) + + if mev_protection: + response = await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + if response.success: + logging.debug("[green]Coldkey swap executed successfully.[/green]") + else: + logging.error(f"[red]{response.message}[/red]") + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def remove_coldkey_swap_announcement_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Removes a coldkey swap announcement. + + This extrinsic can only called by root. It removes a pending coldkey swap announcement for the specified coldkey. + + Parameters: + subtensor: AsyncSubtensor instance with the connection to the chain. + wallet: Bittensor wallet object (must be root/admin wallet). + coldkey_ss58: SS58 address of the coldkey to remove the swap announcement for. + mev_protection: If ``True``, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If ``False``, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - This function can only called by root. + - See: + """ + # TODO: remove this logic in the next major release (include all references) + deprecated_message() + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Removing coldkey swap announcement: coldkey=[blue]{coldkey_ss58}[/blue] " + f"on [blue]{subtensor.network}[/blue]." + ) + + call = await SubtensorModule(subtensor).remove_coldkey_swap_announcement( + coldkey=coldkey_ss58 + ) + + if mev_protection: + response = await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + if response.success: + logging.debug( + "[green]Coldkey swap announcement removed successfully.[/green]" + ) + else: + logging.error(f"[red]{response.message}[/red]") + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) diff --git a/bittensor/core/extrinsics/asyncex/liquidity.py b/bittensor/core/extrinsics/asyncex/liquidity.py index aa828f2c1a..5f6a3fc78a 100644 --- a/bittensor/core/extrinsics/asyncex/liquidity.py +++ b/bittensor/core/extrinsics/asyncex/liquidity.py @@ -4,6 +4,7 @@ from bittensor.core.extrinsics.pallets import Swap from bittensor.core.settings import DEFAULT_MEV_PROTECTION from bittensor.core.types import ExtrinsicResponse +from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message from bittensor.utils.balance import Balance from bittensor.utils.liquidity import price_to_tick @@ -56,6 +57,12 @@ async def add_liquidity_extrinsic( Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: unlock_type = "coldkey" if hotkey_ss58 else "both" if not ( @@ -138,6 +145,12 @@ async def modify_liquidity_extrinsic( Note: Modifying is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: unlock_type = "coldkey" if hotkey_ss58 else "both" if not ( @@ -217,6 +230,12 @@ async def remove_liquidity_extrinsic( Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: unlock_type = "coldkey" if hotkey_ss58 else "both" if not ( @@ -290,6 +309,12 @@ async def toggle_user_liquidity_extrinsic( Returns: ExtrinsicResponse: The result object of the extrinsic execution. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: if not ( unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) diff --git a/bittensor/core/extrinsics/asyncex/mev_shield.py b/bittensor/core/extrinsics/asyncex/mev_shield.py index 8aa8dd7043..d4bf360997 100644 --- a/bittensor/core/extrinsics/asyncex/mev_shield.py +++ b/bittensor/core/extrinsics/asyncex/mev_shield.py @@ -3,11 +3,14 @@ from typing import TYPE_CHECKING, Optional from async_substrate_interface import AsyncExtrinsicReceipt +from async_substrate_interface.errors import SubstrateRequestException +from bittensor.core.errors import map_shield_error from bittensor.core.extrinsics.pallets import MevShield from bittensor.core.extrinsics.utils import ( - get_mev_commitment_and_ciphertext, + get_mev_shielded_ciphertext, get_event_data_by_event_name, + resolve_mev_shield_period, ) from bittensor.core.types import ExtrinsicResponse from bittensor.utils import format_error_message @@ -22,7 +25,6 @@ async def wait_for_extrinsic_by_hash( subtensor: "AsyncSubtensor", extrinsic_hash: str, - shield_id: str, submit_block_hash: str, timeout_blocks: int = 3, ) -> Optional["AsyncExtrinsicReceipt"]: @@ -30,15 +32,11 @@ async def wait_for_extrinsic_by_hash( Wait for the result of a MeV Shield encrypted extrinsic. After submit_encrypted succeeds, the block author will decrypt and submit the inner extrinsic directly. This - function polls subsequent blocks looking for either: - - an extrinsic matching the provided hash (success) - OR - - a markDecryptionFailed extrinsic with matching shield ID (failure) + function polls subsequent blocks looking for an extrinsic matching the provided hash. Args: subtensor: SubtensorInterface instance. extrinsic_hash: The hash of the inner extrinsic to find. - shield_id: The wrapper ID from EncryptedSubmitted event (for detecting decryption failures). submit_block_hash: Block hash where submit_encrypted was included. timeout_blocks: Max blocks to wait. @@ -59,34 +57,14 @@ async def wait_for_extrinsic_by_hash( block_hash = await subtensor.substrate.get_block_hash(current_block) extrinsics = await subtensor.substrate.get_extrinsics(block_hash) - result_idx = None for idx, extrinsic in enumerate(extrinsics): - # Success: Inner extrinsic executed if f"0x{extrinsic.extrinsic_hash.hex()}" == extrinsic_hash: - result_idx = idx - break - - # Failure: Decryption failed - call = extrinsic.value.get("call", {}) - if ( - call.get("call_module") == "MevShield" - and call.get("call_function") == "mark_decryption_failed" - ): - call_args = call.get("call_args", []) - for arg in call_args: - if arg.get("name") == "id" and arg.get("value") == shield_id: - result_idx = idx - break - if result_idx is not None: - break - - if result_idx is not None: - return AsyncExtrinsicReceipt( - substrate=subtensor.substrate, - block_hash=block_hash, - block_number=current_block, - extrinsic_idx=result_idx, - ) + return AsyncExtrinsicReceipt( + substrate=subtensor.substrate, + block_hash=block_hash, + block_number=current_block, + extrinsic_idx=idx, + ) current_block += 1 @@ -125,7 +103,7 @@ async def submit_encrypted_extrinsic( wait_for_finalization: Whether to wait for the finalization of the transaction. wait_for_revealed_execution: Whether to wait for the executed event, indicating that validators have successfully decrypted and executed the inner call. If True, the function will poll subsequent blocks for - the event matching this submission's commitment. + the extrinsic matching this submission. blocks_for_revealed_execution: Maximum number of blocks to poll for the executed event after inclusion. The function checks blocks from start_block to start_block + blocks_for_revealed_execution. Returns immediately if the event is found before the block limit is reached. @@ -138,13 +116,8 @@ async def submit_encrypted_extrinsic( SubstrateRequestException: If the extrinsic fails to be submitted or included. Note: - The encryption uses the public key from NextKey storage, which rotates every block. The payload structure is: - payload_core = signer_bytes (32B) + key_hash (32B Blake2-256 hash of NextKey) + SCALE(call) - plaintext = payload_core + b"\\x01" + signature (64B for sr25519) - commitment = blake2_256(payload_core) - - The key_hash binds the transaction to the key epoch at submission time and replaces nonce-based replay - protection. + The encryption uses the public key from NextKey storage, which rotates every block. The ciphertext wire format + is: [key_hash(16)][u16 kem_len LE][kem_ct][nonce24][aead_ct], where key_hash = twox_128(NextKey). """ try: if sign_with not in ("coldkey", "hotkey"): @@ -174,7 +147,8 @@ async def submit_encrypted_extrinsic( inner_signing_keypair = getattr(wallet, sign_with) - era = "00" if period is None else {"period": period} + effective_period = resolve_mev_shield_period(period) + era = {"period": effective_period} current_nonce = await subtensor.substrate.get_account_next_index( account_address=inner_signing_keypair.ss58_address @@ -186,15 +160,12 @@ async def submit_encrypted_extrinsic( call=call, keypair=inner_signing_keypair, nonce=next_nonce, era=era ) - mev_commitment, mev_ciphertext, payload_core = ( - get_mev_commitment_and_ciphertext( - signed_ext=signed_extrinsic, - ml_kem_768_public_key=ml_kem_768_public_key, - ) + mev_ciphertext = get_mev_shielded_ciphertext( + signed_ext=signed_extrinsic, + ml_kem_768_public_key=ml_kem_768_public_key, ) extrinsic_call = await MevShield(subtensor).submit_encrypted( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) @@ -203,7 +174,7 @@ async def submit_encrypted_extrinsic( sign_with=sign_with, call=extrinsic_call, nonce=current_nonce, - period=period, + period=effective_period, raise_error=raise_error, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -211,10 +182,8 @@ async def submit_encrypted_extrinsic( if response.success: response.data = { - "commitment": mev_commitment, "ciphertext": mev_ciphertext, "ml_kem_768_public_key": ml_kem_768_public_key, - "payload_core": payload_core, "signed_extrinsic_hash": f"0x{signed_extrinsic.extrinsic_hash.hex()}", } if wait_for_revealed_execution: @@ -230,12 +199,9 @@ async def submit_encrypted_extrinsic( error=RuntimeError("EncryptedSubmitted event not found."), ) - shield_id = event["attributes"]["id"] - response.mev_extrinsic = await wait_for_extrinsic_by_hash( subtensor=subtensor, extrinsic_hash=f"0x{signed_extrinsic.extrinsic_hash.hex()}", - shield_id=shield_id, submit_block_hash=response.extrinsic_receipt.block_hash, timeout_blocks=blocks_for_revealed_execution, ) @@ -251,7 +217,7 @@ async def submit_encrypted_extrinsic( response.message = format_error_message( await response.mev_extrinsic.error_message ) - response.error = RuntimeError(response.message) + response.error = SubstrateRequestException(response.message) response.success = False if raise_error: raise response.error @@ -260,6 +226,10 @@ async def submit_encrypted_extrinsic( "[green]Encrypted extrinsic submitted successfully.[/green]" ) else: + response.message = map_shield_error(str(response.message)) + response.error = SubstrateRequestException(response.message) + if raise_error: + raise response.error logging.error(f"[red]{response.message}[/red]") return response diff --git a/bittensor/core/extrinsics/asyncex/staking.py b/bittensor/core/extrinsics/asyncex/staking.py index 1ff60d8dae..e23c3ab5d3 100644 --- a/bittensor/core/extrinsics/asyncex/staking.py +++ b/bittensor/core/extrinsics/asyncex/staking.py @@ -18,6 +18,189 @@ from bittensor.core.async_subtensor import AsyncSubtensor +async def add_stake_burn_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + hotkey_ss58: str, + amount: Balance, + limit_price: Optional[Balance] = None, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Executes a subnet buyback by staking TAO and immediately burning the resulting Alpha. + + Parameters: + subtensor: Subtensor instance with the connection to the chain. + wallet: Bittensor wallet object. + netuid: The unique identifier of the subnet. + hotkey_ss58: The `ss58` address of the hotkey account to stake to. + amount: Amount to stake as Bittensor balance in TAO always. + limit_price: Optional limit price expressed in units of RAO per one Alpha. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Raises: + SubstrateRequestException: Raised if the extrinsic fails to be included in the block within the timeout. + + Notes: + The `data` field in the returned `ExtrinsicResponse` contains extra information about the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + if not isinstance(amount, Balance): + raise BalanceTypeError("`amount` must be an instance of Balance.") + + if limit_price is not None and not isinstance(limit_price, Balance): + raise BalanceTypeError("`limit_price` must be an instance of Balance.") + + old_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + block_hash = await subtensor.substrate.get_chain_head() + + # Get current stake and existential deposit + old_stake, existential_deposit = await asyncio.gather( + subtensor.get_stake( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + block_hash=block_hash, + ), + subtensor.get_existential_deposit(block_hash=block_hash), + ) + + # Leave existential balance to keep key alive. + if old_balance <= existential_deposit: + return ExtrinsicResponse( + False, + f"Balance ({old_balance}) is not enough to cover existential deposit `{existential_deposit}`.", + ).with_log() + + # Leave existential balance to keep key alive. + if amount > old_balance - existential_deposit: + # If we are staking all, we need to leave at least the existential deposit. + amount = old_balance - existential_deposit + + # Check enough to stake. + if amount > old_balance: + message = "Not enough stake" + logging.debug(f":cross_mark: [red]{message}:[/red]") + logging.debug(f"\t\tbalance:{old_balance}") + logging.debug(f"\t\tamount: {amount}") + logging.debug(f"\t\twallet: {wallet.name}") + return ExtrinsicResponse(False, f"{message}.").with_log() + + if limit_price is None: + logging.debug( + f"Subnet buyback on: [blue]netuid: [green]{netuid}[/green], amount: [green]{amount}[/green], " + f"hotkey: [green]{hotkey_ss58}[/green] on [blue]{subtensor.network}[/blue]." + ) + else: + logging.debug( + f"Subnet buyback with limit: [blue]netuid: [green]{netuid}[/green], " + f"amount: [green]{amount}[/green], " + f"limit price: [green]{limit_price}[/green], " + f"hotkey: [green]{hotkey_ss58}[/green] on [blue]{subtensor.network}[/blue]." + ) + + call = await SubtensorModule(subtensor).add_stake_burn( + netuid=netuid, + hotkey=hotkey_ss58, + amount=amount.rao, + limit=None if limit_price is None else limit_price.rao, + ) + + block_hash_before = await subtensor.get_block_hash() + if mev_protection: + response = await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + nonce_key="coldkeypub", + use_nonce=True, + period=period, + raise_error=raise_error, + ) + if response.success: + sim_swap = await subtensor.sim_swap( + origin_netuid=0, + destination_netuid=netuid, + amount=amount, + block_hash=block_hash_before, + ) + response.transaction_tao_fee = sim_swap.tao_fee + response.transaction_alpha_fee = sim_swap.alpha_fee.set_unit(netuid) + + if not wait_for_finalization and not wait_for_inclusion: + return response + logging.debug("[green]Finalized.[/green]") + + new_block_hash = await subtensor.substrate.get_chain_head() + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance( + wallet.coldkeypub.ss58_address, block_hash=new_block_hash + ), + subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + block_hash=new_block_hash, + ), + ) + + logging.debug( + f"Balance: [blue]{old_balance}[/blue] :arrow_right: [green]{new_balance}[/green]" + ) + logging.debug( + f"Stake: [blue]{old_stake}[/blue] :arrow_right: [green]{new_stake}[/green]" + ) + response.data = { + "balance_before": old_balance, + "balance_after": new_balance, + "stake_before": old_stake, + "stake_after": new_stake, + } + return response + + logging.error(f"[red]{response.message}[/red]") + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + async def add_stake_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", diff --git a/bittensor/core/extrinsics/asyncex/sudo.py b/bittensor/core/extrinsics/asyncex/sudo.py index 5a03d6e6d7..935a656d2e 100644 --- a/bittensor/core/extrinsics/asyncex/sudo.py +++ b/bittensor/core/extrinsics/asyncex/sudo.py @@ -1,12 +1,113 @@ -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from bittensor.core.extrinsics.asyncex.utils import sudo_call_extrinsic -from bittensor.core.types import Weights as MaybeSplit, ExtrinsicResponse +from bittensor.core.types import Weights as MaybeSplit from bittensor.utils.weight_utils import convert_maybe_split_to_u16 if TYPE_CHECKING: from bittensor_wallet import Wallet + from bittensor.core.async_subtensor import AsyncSubtensor + from bittensor.core.types import ExtrinsicResponse + + +async def reset_coldkey_swap_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + coldkey_ss58: str, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Resets the coldkey swap state for the given coldkey (root only). + + Clears the coldkey swap announcement and dispute for the specified coldkey. Only callable by root. + + Parameters: + subtensor: AsyncSubtensor instance. + wallet: Bittensor wallet object (must be root/admin wallet). + coldkey_ss58: SS58 address of the coldkey to reset the swap for. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - This function can only be called by root. + """ + return await sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_module="SubtensorModule", + call_function="reset_coldkey_swap", + call_params={"coldkey": coldkey_ss58}, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def swap_coldkey_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + old_coldkey_ss58: str, + new_coldkey_ss58: str, + swap_cost: int, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Performs a root-only coldkey swap without an announcement. + + Only callable by root. Transfers all stake and associations from old_coldkey to new_coldkey; `swap_cost` (in RAO) is + charged from old_coldkey. Use 0 for no charge. + + Parameters: + subtensor: AsyncSubtensor instance. + wallet: Bittensor wallet object (must be root/admin wallet). + old_coldkey_ss58: SS58 address of the coldkey to swap from. + new_coldkey_ss58: SS58 address of the coldkey to swap to. + swap_cost: Cost in RAO charged from old_coldkey (use 0 for no charge). + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - This function can only called by root. + """ + return await sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_module="SubtensorModule", + call_function="swap_coldkey", + call_params={ + "old_coldkey": old_coldkey_ss58, + "new_coldkey": new_coldkey_ss58, + "swap_cost": swap_cost, + }, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) async def sudo_set_admin_freeze_window_extrinsic( @@ -18,7 +119,7 @@ async def sudo_set_admin_freeze_window_extrinsic( raise_error: bool = False, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, -) -> ExtrinsicResponse: +) -> "ExtrinsicResponse": """ Sets the admin freeze window length (in blocks) at the end of a tempo. @@ -143,3 +244,85 @@ async def sudo_set_mechanism_emission_split_extrinsic( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) + + +async def sudo_set_coldkey_swap_announcement_delay_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + duration: int, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Sets the announcement delay for coldkey swap. + + Parameters: + subtensor: AsyncSubtensor instance. + wallet: Bittensor Wallet instance. + duration: The announcement delay in blocks. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + call_function = "sudo_set_coldkey_swap_announcement_delay" + call_params = {"duration": duration} + return await sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_function=call_function, + call_params=call_params, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def sudo_set_coldkey_swap_reannouncement_delay_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + duration: int, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Sets the reannouncement delay for coldkey swap. + + Parameters: + subtensor: AsyncSubtensor instance. + wallet: Bittensor Wallet instance. + duration: The reannouncement delay in blocks. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + call_function = "sudo_set_coldkey_swap_reannouncement_delay" + call_params = {"duration": duration} + return await sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_function=call_function, + call_params=call_params, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) diff --git a/bittensor/core/extrinsics/coldkey_swap.py b/bittensor/core/extrinsics/coldkey_swap.py new file mode 100644 index 0000000000..dcd695075f --- /dev/null +++ b/bittensor/core/extrinsics/coldkey_swap.py @@ -0,0 +1,411 @@ +from typing import TYPE_CHECKING, Optional + +from bittensor_wallet import Keypair + +from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule +from bittensor.core.extrinsics.utils import ( + compute_coldkey_hash, + verify_coldkey_hash, +) +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils import deprecated_message +from bittensor.utils.btlogging import logging + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + + from bittensor.core.subtensor import Subtensor + + +def announce_coldkey_swap_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Announces a coldkey swap by submitting the BlakeTwo256 hash of the new coldkey. + + This extrinsic allows a coldkey to declare its intention to swap to a new coldkey address. The announcement + must be made before the actual swap can be executed, and a delay period must pass before execution is allowed. + After making an announcement, all transactions from the coldkey are blocked except for `swap_coldkey_announced`. + + Parameters: + subtensor: Subtensor instance with the connection to the chain. + wallet: Bittensor wallet object (should be the current coldkey wallet). + new_coldkey_ss58: SS58 address of the new coldkey that will replace the current one. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - A swap cost is charged when making the first announcement (not when reannouncing). + - After making an announcement, all transactions from the coldkey are blocked except for `swap_coldkey_announced`. + - The swap can only be executed after the delay period has passed (check via `get_coldkey_swap_announcement`). + - See: + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + # Compute hash of new coldkey + new_keypair = Keypair( + ss58_address=new_coldkey_ss58, + ) + new_coldkey_hash = compute_coldkey_hash(new_keypair) + + logging.debug( + f"Announcing coldkey swap: current=[blue]{wallet.coldkeypub.ss58_address}[/blue], " + f"new=[blue]{new_coldkey_ss58}[/blue], " + f"hash=[blue]{new_coldkey_hash}[/blue] " + f"on [blue]{subtensor.network}[/blue]." + ) + + call = SubtensorModule(subtensor).announce_coldkey_swap( + new_coldkey_hash=new_coldkey_hash + ) + + if mev_protection: + response = submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + if response.success: + logging.debug("[green]Coldkey swap announced successfully.[/green]") + else: + logging.error(f"[red]{response.message}[/red]") + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def dispute_coldkey_swap_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Disputes the coldkey swap announcement for the current coldkey. + + Callable by the coldkey that has an active swap announcement. Marks the swap as disputed. The account is blocked + until root calls reset_coldkey_swap. + + Parameters: + subtensor: Subtensor instance with the connection to the chain. + wallet: Bittensor wallet object (should be the current coldkey with an active announcement). + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet. + period: The number of blocks during which the transaction will remain valid. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The coldkey must have an active swap announcement. + - After disputing, only root can clear the state via reset_coldkey_swap. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = SubtensorModule(subtensor).dispute_coldkey_swap() + + if mev_protection: + response = submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def swap_coldkey_announced_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Executes a previously announced coldkey swap. + + This extrinsic executes a coldkey swap that was previously announced via `announce_coldkey_swap_extrinsic`. + The new coldkey address must match the hash that was announced, and the delay period must have passed. + + Parameters: + subtensor: Subtensor instance with the connection to the chain. + wallet: Bittensor wallet object (should be the current coldkey wallet that made the announcement). + new_coldkey_ss58: SS58 address of the new coldkey to swap to. This must match the hash that was announced. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The new coldkey hash must match the hash that was announced. + - The delay period must have passed (check via `get_coldkey_swap_announcement`). + - All assets, stakes, subnet ownerships, and hotkey associations are transferred from the old coldkey to the new one. + - See: + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + # Verify announcement exists and hash matches + announcement = subtensor.get_coldkey_swap_announcement( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + + if announcement is None: + error_msg = "No coldkey swap announcement found. Make an announcement first using announce_coldkey_swap_extrinsic." + if raise_error: + raise ValueError(error_msg) + return ExtrinsicResponse( + success=False, + message=error_msg, + extrinsic_receipt=None, + ) + + new_coldkey = Keypair(ss58_address=new_coldkey_ss58) + if not verify_coldkey_hash(new_coldkey, announcement.new_coldkey_hash): + error_msg = ( + f"New coldkey hash does not match announcement. " + f"Expected: {announcement.new_coldkey_hash}, " + f"Got: {compute_coldkey_hash(new_coldkey)}" + ) + if raise_error: + raise ValueError(error_msg) + return ExtrinsicResponse( + success=False, + message=error_msg, + extrinsic_receipt=None, + ) + + # Check if delay has passed + current_block = subtensor.get_current_block() + if current_block < announcement.execution_block: + error_msg = ( + f"Swap too early. Current block: {current_block}, " + f"Execution block: {announcement.execution_block}. " + f"Wait for {announcement.execution_block - current_block} more blocks." + ) + error = ValueError(error_msg) + if raise_error: + raise error + return ExtrinsicResponse( + success=False, message=error_msg, extrinsic_receipt=None, error=error + ) + + logging.debug( + f"Executing coldkey swap: current=[blue]{wallet.coldkeypub.ss58_address}[/blue], " + f"new=[blue]{new_coldkey_ss58}[/blue] " + f"on [blue]{subtensor.network}[/blue]." + ) + + call = SubtensorModule(subtensor).swap_coldkey_announced( + new_coldkey=new_coldkey_ss58 + ) + + if mev_protection: + response = submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + if response.success: + logging.debug("[green]Coldkey swap executed successfully.[/green]") + else: + logging.error(f"[red]{response.message}[/red]") + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def remove_coldkey_swap_announcement_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Removes a coldkey swap announcement. + + This extrinsic can only called by root. It removes a pending coldkey swap announcement for the specified coldkey. + + Parameters: + subtensor: Subtensor instance with the connection to the chain. + wallet: Bittensor wallet object (must be root/admin wallet). + coldkey_ss58: SS58 address of the coldkey to remove the swap announcement for. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - This function can only called by root. + - See: + """ + # TODO: remove this logic in the next major release (include all references) + deprecated_message() + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Removing coldkey swap announcement: coldkey=[blue]{coldkey_ss58}[/blue] " + f"on [blue]{subtensor.network}[/blue]." + ) + + call = SubtensorModule(subtensor).remove_coldkey_swap_announcement( + coldkey=coldkey_ss58 + ) + + if mev_protection: + response = submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + period=period, + raise_error=raise_error, + ) + + if response.success: + logging.debug( + "[green]Coldkey swap announcement removed successfully.[/green]" + ) + else: + logging.error(f"[red]{response.message}[/red]") + + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) diff --git a/bittensor/core/extrinsics/liquidity.py b/bittensor/core/extrinsics/liquidity.py index 3dc8632339..bff99f1f18 100644 --- a/bittensor/core/extrinsics/liquidity.py +++ b/bittensor/core/extrinsics/liquidity.py @@ -4,6 +4,7 @@ from bittensor.core.extrinsics.pallets import Swap from bittensor.core.settings import DEFAULT_MEV_PROTECTION from bittensor.core.types import ExtrinsicResponse +from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message from bittensor.utils.balance import Balance from bittensor.utils.liquidity import price_to_tick @@ -56,6 +57,12 @@ def add_liquidity_extrinsic( Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: unlock_type = "coldkey" if hotkey_ss58 else "both" if not ( @@ -138,6 +145,12 @@ def modify_liquidity_extrinsic( Note: Modifying is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: unlock_type = "coldkey" if hotkey_ss58 else "both" if not ( @@ -217,6 +230,12 @@ def remove_liquidity_extrinsic( Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: unlock_type = "coldkey" if hotkey_ss58 else "both" if not ( @@ -290,6 +309,12 @@ def toggle_user_liquidity_extrinsic( Returns: ExtrinsicResponse: The result object of the extrinsic execution. """ + deprecated_message( + message="User liquidity is currently disabled on the chain. " + "Calling this method will result in a 'UserLiquidityDisabled' error.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) try: if not ( unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) diff --git a/bittensor/core/extrinsics/mev_shield.py b/bittensor/core/extrinsics/mev_shield.py index a21a397270..fd618a6ee8 100644 --- a/bittensor/core/extrinsics/mev_shield.py +++ b/bittensor/core/extrinsics/mev_shield.py @@ -3,11 +3,15 @@ from typing import TYPE_CHECKING, Optional from async_substrate_interface import ExtrinsicReceipt +from async_substrate_interface.errors import SubstrateRequestException + from bittensor.utils import format_error_message +from bittensor.core.errors import map_shield_error from bittensor.core.extrinsics.pallets import MevShield from bittensor.core.extrinsics.utils import ( - get_mev_commitment_and_ciphertext, + get_mev_shielded_ciphertext, get_event_data_by_event_name, + resolve_mev_shield_period, ) from bittensor.core.types import ExtrinsicResponse from bittensor.utils.btlogging import logging @@ -21,7 +25,6 @@ def wait_for_extrinsic_by_hash( subtensor: "Subtensor", extrinsic_hash: str, - shield_id: str, submit_block_hash: str, timeout_blocks: int = 3, ) -> Optional["ExtrinsicReceipt"]: @@ -29,15 +32,11 @@ def wait_for_extrinsic_by_hash( Wait for the result of a MeV Shield encrypted extrinsic. After submit_encrypted succeeds, the block author will decrypt and submit the inner extrinsic directly. This - function polls subsequent blocks looking for either: - - an extrinsic matching the provided hash (success) - OR - - a markDecryptionFailed extrinsic with matching shield ID (failure) + function polls subsequent blocks looking for an extrinsic matching the provided hash. Args: subtensor: SubtensorInterface instance. extrinsic_hash: The hash of the inner extrinsic to find. - shield_id: The wrapper ID from EncryptedSubmitted event (for detecting decryption failures). submit_block_hash: Block hash where submit_encrypted was included. timeout_blocks: Max blocks to wait. @@ -58,34 +57,14 @@ def wait_for_extrinsic_by_hash( block_hash = subtensor.substrate.get_block_hash(current_block) extrinsics = subtensor.substrate.get_extrinsics(block_hash) - result_idx = None for idx, extrinsic in enumerate(extrinsics): - # Success: Inner extrinsic executed if f"0x{extrinsic.extrinsic_hash.hex()}" == extrinsic_hash: - result_idx = idx - break - - # Failure: Decryption failed - call = extrinsic.value.get("call", {}) - if ( - call.get("call_module") == "MevShield" - and call.get("call_function") == "mark_decryption_failed" - ): - call_args = call.get("call_args", []) - for arg in call_args: - if arg.get("name") == "id" and arg.get("value") == shield_id: - result_idx = idx - break - if result_idx is not None: - break - - if result_idx is not None: - return ExtrinsicReceipt( - substrate=subtensor.substrate, - block_hash=block_hash, - block_number=current_block, - extrinsic_idx=result_idx, - ) + return ExtrinsicReceipt( + substrate=subtensor.substrate, + block_hash=block_hash, + block_number=current_block, + extrinsic_idx=idx, + ) current_block += 1 @@ -124,7 +103,7 @@ def submit_encrypted_extrinsic( wait_for_finalization: Whether to wait for the finalization of the transaction. wait_for_revealed_execution: Whether to wait for the executed event, indicating that validators have successfully decrypted and executed the inner call. If True, the function will poll subsequent blocks for - the event matching this submission's commitment. + the extrinsic matching this submission. blocks_for_revealed_execution: Maximum number of blocks to poll for the executed event after inclusion. The function checks blocks from start_block to start_block + blocks_for_revealed_execution. Returns immediately if the event is found before the block limit is reached. @@ -137,13 +116,8 @@ def submit_encrypted_extrinsic( SubstrateRequestException: If the extrinsic fails to be submitted or included. Note: - The encryption uses the public key from NextKey storage, which rotates every block. The payload structure is: - payload_core = signer_bytes (32B) + key_hash (32B Blake2-256 hash of NextKey) + SCALE(call) - plaintext = payload_core + b"\\x01" + signature (64B for sr25519) - commitment = blake2_256(payload_core) - - The key_hash binds the transaction to the key epoch at submission time and replaces nonce-based replay - protection. + The encryption uses the public key from NextKey storage, which rotates every block. The ciphertext wire format + is: [key_hash(16)][u16 kem_len LE][kem_ct][nonce24][aead_ct], where key_hash = twox_128(NextKey). """ try: if sign_with not in ["coldkey", "hotkey"]: @@ -173,7 +147,8 @@ def submit_encrypted_extrinsic( inner_signing_keypair = getattr(wallet, sign_with) - era = "00" if period is None else {"period": period} + effective_period = resolve_mev_shield_period(period) + era = {"period": effective_period} current_nonce = subtensor.substrate.get_account_next_index( account_address=inner_signing_keypair.ss58_address @@ -183,15 +158,12 @@ def submit_encrypted_extrinsic( call=call, keypair=inner_signing_keypair, nonce=next_nonce, era=era ) - mev_commitment, mev_ciphertext, payload_core = ( - get_mev_commitment_and_ciphertext( - signed_ext=signed_extrinsic, - ml_kem_768_public_key=ml_kem_768_public_key, - ) + mev_ciphertext = get_mev_shielded_ciphertext( + signed_ext=signed_extrinsic, + ml_kem_768_public_key=ml_kem_768_public_key, ) extrinsic_call = MevShield(subtensor).submit_encrypted( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) @@ -200,7 +172,7 @@ def submit_encrypted_extrinsic( sign_with=sign_with, call=extrinsic_call, nonce=current_nonce, - period=period, + period=effective_period, raise_error=raise_error, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -208,10 +180,8 @@ def submit_encrypted_extrinsic( if response.success: response.data = { - "commitment": mev_commitment, "ciphertext": mev_ciphertext, "ml_kem_768_public_key": ml_kem_768_public_key, - "payload_core": payload_core, "signed_extrinsic_hash": f"0x{signed_extrinsic.extrinsic_hash.hex()}", } @@ -228,12 +198,9 @@ def submit_encrypted_extrinsic( error=RuntimeError("EncryptedSubmitted event not found."), ) - shield_id = event["attributes"]["id"] - response.mev_extrinsic = wait_for_extrinsic_by_hash( subtensor=subtensor, extrinsic_hash=f"0x{signed_extrinsic.extrinsic_hash.hex()}", - shield_id=shield_id, submit_block_hash=response.extrinsic_receipt.block_hash, timeout_blocks=blocks_for_revealed_execution, ) @@ -249,7 +216,7 @@ def submit_encrypted_extrinsic( response.message = format_error_message( response.mev_extrinsic.error_message # type: ignore ) - response.error = RuntimeError(response.message) + response.error = SubstrateRequestException(response.message) response.success = False if raise_error: raise response.error @@ -258,6 +225,10 @@ def submit_encrypted_extrinsic( "[green]Encrypted extrinsic submitted successfully.[/green]" ) else: + response.message = map_shield_error(str(response.message)) + response.error = SubstrateRequestException(response.message) + if raise_error: + raise response.error logging.error(f"[red]{response.message}[/red]") return response diff --git a/bittensor/core/extrinsics/pallets/mev_shield.py b/bittensor/core/extrinsics/pallets/mev_shield.py index cef4995bc8..ee2a5f8a8a 100644 --- a/bittensor/core/extrinsics/pallets/mev_shield.py +++ b/bittensor/core/extrinsics/pallets/mev_shield.py @@ -15,14 +15,12 @@ class MevShield(_BasePallet): Example: # Sync usage call = MevShield(subtensor).submit_encrypted( - commitment="0x1234...", ciphertext=b"encrypted_data..." ) response = subtensor.sign_and_send_extrinsic(call=call, ...) # Async usage call = await MevShield(async_subtensor).submit_encrypted( - commitment="0x1234...", ciphertext=b"encrypted_data..." ) response = await async_subtensor.sign_and_send_extrinsic(call=call, ...) @@ -30,7 +28,6 @@ class MevShield(_BasePallet): def submit_encrypted( self, - commitment: str, ciphertext: bytes, ) -> Call: """Returns GenericCall instance for MevShield function submit_encrypted. @@ -39,21 +36,18 @@ def submit_encrypted( transaction pool until it is included in a block and decrypted by validators. Parameters: - commitment: The blake2_256 hash of the payload_core (signer + nonce + SCALE(call)). Must be a hex string - with "0x" prefix. ciphertext: The encrypted blob containing the payload and signature. - Format: [u16 kem_len LE][kem_ct][nonce24][aead_ct] + Format: [key_hash(16)][u16 kem_len LE][kem_ct][nonce24][aead_ct] Maximum size: 8192 bytes. Returns: GenericCall instance ready for extrinsic submission. Note: - The commitment is used to verify the ciphertext's content at decryption time. The ciphertext is encrypted - using ML-KEM-768 + XChaCha20Poly1305 with the public key from the NextKey storage item, which rotates every - block. + The ciphertext is encrypted using ML-KEM-768 + XChaCha20Poly1305 with the public key from the NextKey + storage item, which rotates every block. The key_hash prefix (twox_128 of the public key) is validated + on-chain by CheckShieldedTxValidity. """ return self.create_composed_call( - commitment=commitment, ciphertext=ciphertext, ) diff --git a/bittensor/core/extrinsics/pallets/subtensor_module.py b/bittensor/core/extrinsics/pallets/subtensor_module.py index 8a3344be55..0da15461cb 100644 --- a/bittensor/core/extrinsics/pallets/subtensor_module.py +++ b/bittensor/core/extrinsics/pallets/subtensor_module.py @@ -1,9 +1,11 @@ from dataclasses import dataclass from typing import Literal, Optional -from bittensor.core.types import UIDs, Weights, Salt -from bittensor.utils import Certificate -from .base import CallBuilder as _BasePallet, Call +from bittensor.core.types import Salt, UIDs, Weights +from bittensor.utils import Certificate, deprecated_message + +from .base import Call +from .base import CallBuilder as _BasePallet @dataclass @@ -47,6 +49,31 @@ def add_stake( amount_staked=amount_staked, ) + def add_stake_burn( + self, + netuid: int, + hotkey: str, + amount: int, + limit: Optional[int] = None, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.add_stake_burn. + + Parameters: + netuid: The netuid of the subnet to buy back on. + hotkey: The hotkey SS58 address associated with the buyback. + amount: Amount of TAO in RAO to use for the buyback. + limit: Optional limit price expressed in units of RAO per one Alpha. + + Returns: + GenericCall instance. + """ + return self.create_composed_call( + netuid=netuid, + hotkey=hotkey, + amount=amount, + limit=limit, + ) + def add_stake_limit( self, netuid: int, @@ -75,6 +102,20 @@ def add_stake_limit( allow_partial=allow_partial, ) + def announce_coldkey_swap( + self, + new_coldkey_hash: str, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.announce_coldkey_swap. + + Parameters: + new_coldkey_hash: The BlakeTwo256 hash of the new coldkey AccountId (hex string with 0x prefix). + + Returns: + GenericCall instance. + """ + return self.create_composed_call(new_coldkey_hash=new_coldkey_hash) + def burned_register( self, netuid: int, @@ -314,6 +355,77 @@ def remove_stake_full_limit( netuid=netuid, hotkey=hotkey, limit_price=limit_price ) + def dispute_coldkey_swap(self) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.dispute_coldkey_swap. + + Callable by the coldkey that has an active swap announcement. Marks the swap as disputed; + the account is blocked until root calls reset_coldkey_swap. + + Returns: + GenericCall instance. + """ + return self.create_composed_call() + + def reset_coldkey_swap(self, coldkey: str) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.reset_coldkey_swap. + + Only callable by root. Clears the coldkey swap announcement and dispute for the given coldkey. + + Parameters: + coldkey: SS58 address of the coldkey to reset the swap for. + + Returns: + GenericCall instance. + """ + return self.create_composed_call(coldkey=coldkey) + + def swap_coldkey( + self, + old_coldkey: str, + new_coldkey: str, + swap_cost: int, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.swap_coldkey. + + Only callable by root. Performs a coldkey swap without an announcement; swap_cost is charged + from old_coldkey in RAO. + + Parameters: + old_coldkey: SS58 address of the coldkey to swap from. + new_coldkey: SS58 address of the coldkey to swap to. + swap_cost: Cost in RAO charged from old_coldkey (use 0 for no charge). + + Returns: + GenericCall instance. + """ + return self.create_composed_call( + old_coldkey=old_coldkey, + new_coldkey=new_coldkey, + swap_cost=swap_cost, + ) + + def remove_coldkey_swap_announcement( + self, + coldkey: str, + ) -> Call: + """Returns GenericCall that resets coldkey swap for the given coldkey (root only). + + Deprecated. Use :meth:`reset_coldkey_swap` instead. This shim exists for compatibility; + the runtime call is SubtensorModule.reset_coldkey_swap, which clears both announcement + and dispute. + + Parameters: + coldkey: SS58 address of the coldkey to reset the swap for. + + Returns: + GenericCall instance. + """ + # TODO: remove this logic in the next major release (include all references) + deprecated_message() + return self.create_composed_call( + call_function="reset_coldkey_swap", coldkey=coldkey + ) + def reveal_mechanism_weights( self, netuid: int, @@ -633,6 +745,21 @@ def swap_stake_limit( allow_partial=allow_partial, ) + def swap_coldkey_announced( + self, + new_coldkey: str, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.swap_coldkey_announced. + + Parameters: + new_coldkey: SS58 address of the new coldkey to swap to. The BlakeTwo256 hash of this coldkey must match + the hash that was announced. + + Returns: + GenericCall instance. + """ + return self.create_composed_call(new_coldkey=new_coldkey) + def transfer_stake( self, destination_coldkey: str, diff --git a/bittensor/core/extrinsics/staking.py b/bittensor/core/extrinsics/staking.py index a8f3d4c913..d84928d95e 100644 --- a/bittensor/core/extrinsics/staking.py +++ b/bittensor/core/extrinsics/staking.py @@ -17,6 +17,185 @@ from bittensor.core.subtensor import Subtensor +def add_stake_burn_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + hotkey_ss58: str, + amount: Balance, + limit_price: Optional[Balance] = None, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Executes a subnet buyback by staking TAO and immediately burning the resulting Alpha. + + Parameters: + subtensor: Subtensor instance with the connection to the chain. + wallet: Bittensor wallet object. + netuid: The unique identifier of the subnet. + hotkey_ss58: The `ss58` address of the hotkey account to stake to. + amount: Amount to stake as Bittensor balance in TAO always. + limit_price: Optional limit price expressed in units of RAO per one Alpha. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Raises: + SubstrateRequestException: Raised if the extrinsic fails to be included in the block within the timeout. + + Notes: + The `data` field in the returned `ExtrinsicResponse` contains extra information about the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + if not isinstance(amount, Balance): + raise BalanceTypeError("`amount` must be an instance of Balance.") + + if limit_price is not None and not isinstance(limit_price, Balance): + raise BalanceTypeError("`limit_price` must be an instance of Balance.") + + old_balance = subtensor.get_balance(wallet.coldkeypub.ss58_address) + block = subtensor.get_current_block() + + # Get current stake and existential deposit + old_stake = subtensor.get_stake( + hotkey_ss58=hotkey_ss58, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid, + block=block, + ) + existential_deposit = subtensor.get_existential_deposit(block=block) + + # Leave existential balance to keep key alive. + if old_balance <= existential_deposit: + return ExtrinsicResponse( + False, + f"Balance ({old_balance}) is not enough to cover existential deposit `{existential_deposit}`.", + ).with_log() + + # Leave existential balance to keep key alive. + if amount > old_balance - existential_deposit: + # If we are staking all, we need to leave at least the existential deposit. + amount = old_balance - existential_deposit + + # Check enough to stake. + if amount > old_balance: + message = "Not enough stake" + logging.debug(f":cross_mark: [red]{message}:[/red]") + logging.debug(f"\t\tbalance:{old_balance}") + logging.debug(f"\t\tamount: {amount}") + logging.debug(f"\t\twallet: {wallet.name}") + return ExtrinsicResponse(False, f"{message}.").with_log() + + if limit_price is None: + logging.debug( + f"Subnet buyback on: [blue]netuid: [green]{netuid}[/green], amount: [green]{amount}[/green], " + f"hotkey: [green]{hotkey_ss58}[/green] on [blue]{subtensor.network}[/blue]." + ) + else: + logging.debug( + f"Subnet buyback with limit: [blue]netuid: [green]{netuid}[/green], " + f"amount: [green]{amount}[/green], " + f"limit price: [green]{limit_price}[/green], " + f"hotkey: [green]{hotkey_ss58}[/green] on [blue]{subtensor.network}[/blue]." + ) + + call = SubtensorModule(subtensor).add_stake_burn( + netuid=netuid, + hotkey=hotkey_ss58, + amount=amount.rao, + limit=None if limit_price is None else limit_price.rao, + ) + + block_before = subtensor.block + if mev_protection: + response = submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + response = subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + use_nonce=True, + nonce_key="coldkeypub", + period=period, + raise_error=raise_error, + ) + if response.success: + sim_swap = subtensor.sim_swap( + origin_netuid=0, + destination_netuid=netuid, + amount=amount, + block=block_before, + ) + response.transaction_tao_fee = sim_swap.tao_fee + response.transaction_alpha_fee = sim_swap.alpha_fee.set_unit(netuid) + + if not wait_for_finalization and not wait_for_inclusion: + return response + logging.debug("[green]Finalized.[/green]") + + new_block = subtensor.get_current_block() + new_balance = subtensor.get_balance( + wallet.coldkeypub.ss58_address, block=new_block + ) + new_stake = subtensor.get_stake( + coldkey_ss58=wallet.coldkeypub.ss58_address, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + block=new_block, + ) + + logging.debug( + f"Balance: [blue]{old_balance}[/blue] :arrow_right: [green]{new_balance}[/green]" + ) + logging.debug( + f"Stake: [blue]{old_stake}[/blue] :arrow_right: [green]{new_stake}[/green]" + ) + response.data = { + "balance_before": old_balance, + "balance_after": new_balance, + "stake_before": old_stake, + "stake_after": new_stake, + } + return response + + logging.error(f"[red]{response.message}[/red]") + return response + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + def add_stake_extrinsic( subtensor: "Subtensor", wallet: "Wallet", diff --git a/bittensor/core/extrinsics/sudo.py b/bittensor/core/extrinsics/sudo.py index 32d795287c..5859a0697a 100644 --- a/bittensor/core/extrinsics/sudo.py +++ b/bittensor/core/extrinsics/sudo.py @@ -1,4 +1,4 @@ -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from bittensor.core.extrinsics.utils import sudo_call_extrinsic from bittensor.core.types import Weights as MaybeSplit @@ -6,10 +6,110 @@ if TYPE_CHECKING: from bittensor_wallet import Wallet + from bittensor.core.subtensor import Subtensor from bittensor.core.types import ExtrinsicResponse +def reset_coldkey_swap_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + coldkey_ss58: str, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Resets the coldkey swap state for the given coldkey (root only). + + Clears the coldkey swap announcement and dispute for the specified coldkey. Only callable by root. + + Parameters: + subtensor: Subtensor instance. + wallet: Bittensor wallet object (must be root/admin wallet). + coldkey_ss58: SS58 address of the coldkey to reset the swap for. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - This function can only called by root. + """ + return sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_module="SubtensorModule", + call_function="reset_coldkey_swap", + call_params={"coldkey": coldkey_ss58}, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +def swap_coldkey_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + old_coldkey_ss58: str, + new_coldkey_ss58: str, + swap_cost: int, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Performs a root-only coldkey swap without an announcement. + + Only callable by root. Transfers all stake and associations from old_coldkey to new_coldkey; `swap_cost` (in RAO) is + charged from old_coldkey. Use 0 for no charge. + + Parameters: + subtensor: Subtensor instance. + wallet: Bittensor wallet object (must be root/admin wallet). + old_coldkey_ss58: SS58 address of the coldkey to swap from. + new_coldkey_ss58: SS58 address of the coldkey to swap to. + swap_cost: Cost in RAO charged from old_coldkey (use 0 for no charge). + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - This function can only called by root. + """ + return sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_module="SubtensorModule", + call_function="swap_coldkey", + call_params={ + "old_coldkey": old_coldkey_ss58, + "new_coldkey": new_coldkey_ss58, + "swap_cost": swap_cost, + }, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + def sudo_set_admin_freeze_window_extrinsic( subtensor: "Subtensor", wallet: "Wallet", @@ -144,3 +244,85 @@ def sudo_set_mechanism_emission_split_extrinsic( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) + + +def sudo_set_coldkey_swap_announcement_delay_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + duration: int, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Sets the announcement delay for coldkey swap. + + Parameters: + subtensor: Subtensor instance. + wallet: Bittensor Wallet instance. + duration: The announcement delay in blocks. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + call_function = "sudo_set_coldkey_swap_announcement_delay" + call_params = {"duration": duration} + return sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_function=call_function, + call_params=call_params, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +def sudo_set_coldkey_swap_reannouncement_delay_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + duration: int, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> "ExtrinsicResponse": + """ + Sets the reannouncement delay for coldkey swap. + + Parameters: + subtensor: Subtensor instance. + wallet: Bittensor Wallet instance. + duration: The reannouncement delay in blocks. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + call_function = "sudo_set_coldkey_swap_reannouncement_delay" + call_params = {"duration": duration} + return sudo_call_extrinsic( + subtensor=subtensor, + wallet=wallet, + call_function=call_function, + call_params=call_params, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) diff --git a/bittensor/core/extrinsics/utils.py b/bittensor/core/extrinsics/utils.py index fdeffdb9c8..a491d82da8 100644 --- a/bittensor/core/extrinsics/utils.py +++ b/bittensor/core/extrinsics/utils.py @@ -5,11 +5,18 @@ from typing import TYPE_CHECKING, Optional, Union from bittensor_drand import encrypt_mlkem768 +from bittensor_wallet import Keypair from bittensor.core.extrinsics.pallets import Sudo +from bittensor.core.settings import MAX_MEV_SHIELD_PERIOD from bittensor.core.types import ExtrinsicResponse from bittensor.utils.balance import Balance +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.chain_data import StakeInfo + from bittensor.core.subtensor import Subtensor + from scalecodec.types import GenericExtrinsic # TODO: Michael/Roman add the link to the docs once it's ready.' MEV_HOTKEY_USAGE_WARNING = ( @@ -18,11 +25,22 @@ " the transaction." ) -if TYPE_CHECKING: - from bittensor_wallet import Wallet - from bittensor.core.chain_data import StakeInfo - from bittensor.core.subtensor import Subtensor - from scalecodec.types import GenericExtrinsic + +def resolve_mev_shield_period(period: Optional[int]) -> int: + """Return effective era period for MEV Shield extrinsics. + + MEV Shield extrinsics must use a short-lived era. If period is omitted or + exceeds the MEV limit, the maximum allowed MEV period is applied. + + Parameters: + period: The period to resolve. + + Returns: + The effective period (in blocks). + """ + if period is None or period > MAX_MEV_SHIELD_PERIOD: + return MAX_MEV_SHIELD_PERIOD + return period def get_old_stakes( @@ -223,40 +241,25 @@ def apply_pure_proxy_data( return response.with_log("warning") -def get_mev_commitment_and_ciphertext( +def get_mev_shielded_ciphertext( signed_ext: "GenericExtrinsic", ml_kem_768_public_key: bytes, -) -> tuple[str, bytes, bytes]: +) -> bytes: """ - Builds MEV Shield payload and encrypts it using ML-KEM-768 + XChaCha20Poly1305. + Encrypts a signed extrinsic for MEV Shield submission. - This function constructs the payload structure required for MEV Shield encryption and performs the encryption - process. The payload binds the transaction to a specific key epoch using the key_hash, which replaces nonce-based - replay protection. + This function extracts the raw extrinsic bytes and encrypts them using ML-KEM-768 + XChaCha20Poly1305 with the + twox_128 key hash prepended for on-chain validation. Parameters: signed_ext: The signed GenericExtrinsic object representing the inner call to be encrypted and executed. - ml_kem_768_public_key: The ML-KEM-768 public key bytes (1184 bytes) from NextKey storage. This key is used for - encryption and its hash binds the transaction to the key epoch. + ml_kem_768_public_key: The ML-KEM-768 public key bytes (1184 bytes) from NextKey storage. Returns: - A tuple containing: - - commitment_hex: Hex string of the Blake2-256 hash of payload_core (32 bytes). - - ciphertext: Encrypted blob containing plaintext. - - payload_core: Raw payload bytes before encryption. + The encrypted ciphertext bytes in wire format: [key_hash(16)][u16 kem_len LE][kem_ct][nonce24][aead_ct] """ - payload_core = signed_ext.data.data - - plaintext = bytes(payload_core) - - # Getting ciphertext (encrypting plaintext using ML-KEM-768) - ciphertext = encrypt_mlkem768(ml_kem_768_public_key, plaintext) - - # Compute commitment: blake2_256(payload_core) - commitment_hash = hashlib.blake2b(payload_core, digest_size=32).digest() - commitment_hex = "0x" + commitment_hash.hex() - - return commitment_hex, ciphertext, payload_core + plaintext = bytes(signed_ext.data.data) + return encrypt_mlkem768(ml_kem_768_public_key, plaintext, include_key_hash=True) def get_event_data_by_event_name(events: list, event_name: str) -> Optional[dict]: @@ -287,3 +290,47 @@ def get_event_data_by_event_name(events: list, event_name: str) -> Optional[dict ): return event return None + + +def compute_coldkey_hash(keypair: "Keypair") -> str: + """ + Computes BlakeTwo256 hash of a coldkey AccountId. + + This function extracts the AccountId (32-byte public key) from an SS58 address and computes its BlakeTwo256 hash. + The hash is used in coldkey swap announcements to verify the new coldkey address when executing the swap. + + Parameters: + keypair: keypair for getting hash. + + Returns: + Hex string with 0x prefix representing the BlakeTwo256 hash of the AccountId. + + Notes: + - The hash is computed from the AccountId (public key bytes), not from the SS58 string. + - This matches the hash computation used in the Subtensor runtime. + - See: + """ + hash_bytes = hashlib.blake2b(keypair.public_key, digest_size=32).digest() + return "0x" + hash_bytes.hex() + + +def verify_coldkey_hash(keypair: "Keypair", expected_hash: str) -> bool: + """ + Verifies that a coldkey SS58 address matches the expected BlakeTwo256 hash. + + This function computes the hash of the coldkey AccountId and compares it with the expected hash. Used to verify that + the new coldkey address in a swap announcement matches the announced hash. + + Parameters: + keypair: keypair whose hash needs to be verified. + expected_hash: Expected BlakeTwo256 hash (hex string with 0x prefix). + + Returns: + True if the computed hash matches the expected hash, False otherwise. + + Notes: + - Both hashes are compared in lowercase to handle case differences. + - See: + """ + computed_hash = compute_coldkey_hash(keypair) + return computed_hash.lower() == expected_hash.lower() diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index 1d7ed87932..06c900062b 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -70,6 +70,10 @@ # details https://paritytech.github.io/polkadot-sdk/master/src/sp_runtime/generic/era.rs.html#65-72 DEFAULT_PERIOD = 128 +# Maximum period (in blocks) for MEV Shield-protected extrinsics Era. +# This keeps encrypted submissions short-lived in the mempool. +MAX_MEV_SHIELD_PERIOD = 8 + # Default MEV Shield protection setting for extrinsics. # When enabled, transactions are encrypted to protect against Miner Extractable Value (MEV) attacks. DEFAULT_MEV_PROTECTION = os.getenv("BT_MEV_PROTECTION", "").lower() in ( diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 996401ea92..53f594e4f8 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -14,6 +14,9 @@ from bittensor.core.axon import Axon from bittensor.core.chain_data import ( + ColdkeySwapAnnouncementInfo, + ColdkeySwapConstants, + ColdkeySwapDisputeInfo, CrowdloanConstants, CrowdloanInfo, DelegatedInfo, @@ -50,6 +53,11 @@ root_set_pending_childkey_cooldown_extrinsic, set_children_extrinsic, ) +from bittensor.core.extrinsics.coldkey_swap import ( + announce_coldkey_swap_extrinsic, + dispute_coldkey_swap_extrinsic, + swap_coldkey_announced_extrinsic, +) from bittensor.core.extrinsics.crowdloan import ( contribute_crowdloan_extrinsic, create_crowdloan_extrinsic, @@ -102,6 +110,7 @@ serve_axon_extrinsic, ) from bittensor.core.extrinsics.staking import ( + add_stake_burn_extrinsic, add_stake_extrinsic, add_stake_multiple_extrinsic, set_auto_stake_extrinsic, @@ -486,7 +495,7 @@ def _runtime_call_with_fallback( def get_hyperparameter( self, param_name: str, netuid: int, block: Optional[int] = None - ) -> Optional[Any]: + ) -> Any | None: """Retrieves a specified hyperparameter for a specific subnet. This method queries the blockchain for subnet-specific hyperparameters such as difficulty, tempo, immunity @@ -860,7 +869,7 @@ def blocks_since_last_step( query = self.query_subtensor( name="BlocksSinceLastStep", block=block, params=[netuid] ) - return query.value if query is not None and hasattr(query, "value") else query + return cast(Optional[int], getattr(query, "value", query)) def blocks_since_last_update( self, netuid: int, uid: int, block: Optional[int] = None @@ -1051,11 +1060,12 @@ def get_admin_freeze_window(self, block: Optional[int] = None) -> int: - """ - return self.substrate.query( + query = self.substrate.query( module="SubtensorModule", storage_function="AdminFreezeWindow", block_hash=self.determine_block_hash(block), - ).value + ) + return cast(int, getattr(query, "value", query)) def get_all_subnets_info(self, block: Optional[int] = None) -> list["SubnetInfo"]: """Retrieves detailed information about all subnets within the Bittensor network. @@ -1342,7 +1352,7 @@ def get_balance(self, address: str, block: Optional[int] = None) -> Balance: params=[address], block_hash=self.determine_block_hash(block), ) - return Balance(balance["data"]["free"]) + return Balance(cast(dict[str, Any], balance)["data"]["free"]) def get_balances( self, @@ -1537,12 +1547,16 @@ def get_children_pending( - """ - children, cooldown = self.substrate.query( + pending_query = self.substrate.query( module="SubtensorModule", storage_function="PendingChildKeys", params=[netuid, hotkey_ss58], block_hash=self.determine_block_hash(block), - ).value + ) + children, cooldown = cast( + tuple[list[tuple[int, Any]], int], + getattr(pending_query, "value", pending_query), + ) return ( [ @@ -1555,6 +1569,240 @@ def get_children_pending( cooldown, ) + def get_coldkey_swap_announcement( + self, + coldkey_ss58: str, + block: Optional[int] = None, + ) -> Optional["ColdkeySwapAnnouncementInfo"]: + """ + Retrieves coldkey swap announcement for a specific coldkey. + + This method queries the SubtensorModule.ColdkeySwapAnnouncements storage for an announcement made by the given + coldkey. Announcements allow a coldkey to declare its intention to swap to a new coldkey address after a delay + period. + + Parameters: + coldkey_ss58: SS58 address of the coldkey whose announcement to retrieve. + block: The blockchain block number for the query. If None, queries the latest block. + + Returns: + ColdkeySwapAnnouncementInfo if announcement exists, None otherwise. Contains the execution block and + new coldkey hash. + + Notes: + - If the coldkey has no announcement, returns None. + - See: + """ + block_hash = self.determine_block_hash(block) + query = self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + params=[coldkey_ss58], + block_hash=block_hash, + ) + if query is None: + return None + return ColdkeySwapAnnouncementInfo.from_query( + coldkey_ss58=coldkey_ss58, query=cast(ScaleObj, query) + ) + + def get_coldkey_swap_announcements( + self, + block: Optional[int] = None, + ) -> list["ColdkeySwapAnnouncementInfo"]: + """ + Retrieves all coldkey swap announcements from the chain. + + This method queries the SubtensorModule.ColdkeySwapAnnouncements storage map across all coldkeys and returns a + list of all active announcements. + + Parameters: + block: The blockchain block number for the query. If None, queries the latest block. + + Returns: + List of ColdkeySwapAnnouncementInfo objects representing all active coldkey swap announcements on the chain. + + Notes: + - This method queries all announcements on the chain, which may be resource-intensive for large networks. + Consider using :meth:`get_coldkey_swap_announcement` for querying specific coldkeys. + - See: + """ + block_hash = self.determine_block_hash(block) + query_map = self.substrate.query_map( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + block_hash=block_hash, + ) + return [ColdkeySwapAnnouncementInfo.from_record(record) for record in query_map] + + def get_coldkey_swap_announcement_delay( + self, + block: Optional[int] = None, + ) -> int: + """ + Retrieves the ColdkeySwapAnnouncementDelay storage value. + + This method queries the SubtensorModule.ColdkeySwapAnnouncementDelay storage value, which defines the number + of blocks that must elapse after making an announcement before the swap can be executed. + + Parameters: + block: The blockchain block number for the query. If None, queries the latest block. + + Returns: + The number of blocks that must elapse before swap execution (integer). + + Notes: + - This is a storage value (can be changed via admin extrinsics), not a runtime constant. + - See: + """ + block_hash = self.determine_block_hash(block) + query = self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncementDelay", + block_hash=block_hash, + ) + value = getattr(query, "value", query) + return cast(int, value) if value is not None else 0 + + def get_coldkey_swap_constants( + self, + constants: Optional[list[str]] = None, + as_dict: bool = False, + block: Optional[int] = None, + ) -> Union["ColdkeySwapConstants", dict]: + """ + Fetches runtime configuration constants for coldkey swap operations. + + This method retrieves on-chain runtime constants that define cost requirements for coldkey swap operations. + Note: For delay values (ColdkeySwapAnnouncementDelay and ColdkeySwapReannouncementDelay), use the dedicated + query methods `get_coldkey_swap_announcement_delay()` and `get_coldkey_swap_reannouncement_delay()` instead, + as these are storage values, not runtime constants. + + Parameters: + constants: Optional list of specific constant names to fetch. If omitted, all constants defined in + `ColdkeySwapConstants.constants_names()` are queried. Valid constant names include: "KeySwapCost". + as_dict: If True, returns the constants as a dictionary instead of a `ColdkeySwapConstants` object. + block: The blockchain block number for the query. If None, queries the latest block. + + Returns: + If `as_dict` is False: ColdkeySwapConstants object containing all requested constants. + If `as_dict` is True: Dictionary mapping constant names to their values (integers for cost in RAO). + + Notes: + - All amounts are returned in RAO. Values reflect the current chain configuration at the specified block. + - KeySwapCost is a runtime constant (queryable via constants). + - See: + """ + result = {} + const_names = constants or ColdkeySwapConstants.constants_names() + + for const_name in const_names: + # Query as runtime constant + query = self.query_constant( + module_name="SubtensorModule", + constant_name=const_name, + block=block, + ) + if query is not None: + result[const_name] = query.value + + constants_obj = ColdkeySwapConstants.from_dict(result) + + return constants_obj.to_dict() if as_dict else constants_obj + + def get_coldkey_swap_reannouncement_delay( + self, + block: Optional[int] = None, + ) -> int: + """ + Retrieves the ColdkeySwapReannouncementDelay storage value. + + This method queries the SubtensorModule.ColdkeySwapReannouncementDelay storage value, which defines the number + of blocks that must elapse between the original announcement and a reannouncement. + + Parameters: + block: The blockchain block number for the query. If None, queries the latest block. + + Returns: + The number of blocks that must elapse before reannouncement (integer). + + Notes: + - This is a storage value (can be changed via admin extrinsics), not a runtime constant. + - See: + """ + block_hash = self.determine_block_hash(block) + query = self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapReannouncementDelay", + block_hash=block_hash, + ) + value = getattr(query, "value", query) + return cast(int, value) if value is not None else 0 + + def get_coldkey_swap_dispute( + self, + coldkey_ss58: str, + block: Optional[int] = None, + ) -> Optional["ColdkeySwapDisputeInfo"]: + """ + Retrieves coldkey swap dispute for a specific coldkey. + + This method queries the SubtensorModule.ColdkeySwapDisputes storage for a dispute recorded for the given + coldkey. When a coldkey swap is disputed, the account is frozen until a root-only reset clears it. + + Parameters: + coldkey_ss58: SS58 address of the coldkey whose dispute to retrieve. + block: The blockchain block number for the query. If None, queries the latest block. + + Returns: + ColdkeySwapDisputeInfo if dispute exists, None otherwise. Contains the disputed block number. + + Notes: + - If the coldkey has no dispute, returns None. + - See: + """ + block_hash = self.determine_block_hash(block) + query = self.substrate.query( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + params=[coldkey_ss58], + block_hash=block_hash, + ) + if query is None: + return None + return ColdkeySwapDisputeInfo.from_query( + coldkey_ss58=coldkey_ss58, query=cast(ScaleObj, query) + ) + + def get_coldkey_swap_disputes( + self, + block: Optional[int] = None, + ) -> list["ColdkeySwapDisputeInfo"]: + """ + Retrieves all coldkey swap disputes from the chain. + + This method queries the SubtensorModule.ColdkeySwapDisputes storage map across all coldkeys and returns a + list of all active disputes. + + Parameters: + block: The blockchain block number for the query. If None, queries the latest block. + + Returns: + List of ColdkeySwapDisputeInfo objects representing all active coldkey swap disputes on the chain. + + Notes: + - This method queries all disputes on the chain, which may be resource-intensive for large networks. + Consider using :meth:`get_coldkey_swap_dispute` for querying specific coldkeys. + - See: + """ + block_hash = self.determine_block_hash(block) + query_map = self.substrate.query_map( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + block_hash=block_hash, + ) + return [ColdkeySwapDisputeInfo.from_record(record) for record in query_map] + def get_commitment(self, netuid: int, uid: int, block: Optional[int] = None) -> str: """Retrieves the on-chain commitment for a specific neuron in the Bittensor network. @@ -1615,7 +1863,9 @@ def get_commitment_metadata( params=[netuid, hotkey_ss58], block_hash=self.determine_block_hash(block), ) - return commit_data + if commit_data is None: + return "" + return cast(Union[str, dict], getattr(commit_data, "value", commit_data)) def get_crowdloan_constants( self, @@ -1757,7 +2007,8 @@ def get_crowdloan_next_id( storage_function="NextCrowdloanId", block_hash=block_hash, ) - return int(result.value or 0) + value = cast(int, getattr(result, "value", result)) + return int(value) or 0 def get_crowdloans( self, @@ -2045,7 +2296,7 @@ def get_hotkey_owner( if hk_owner_query: exists = self.does_hotkey_exist(hotkey_ss58, block=block) hotkey_owner = hk_owner_query if exists else None - return hotkey_owner + return cast(Optional[str], getattr(hotkey_owner, "value", hotkey_owner)) def get_last_bonds_reset( self, netuid: int, hotkey_ss58: str, block: Optional[int] = None @@ -2515,112 +2766,6 @@ def get_mev_shield_next_key(self, block: Optional[int] = None) -> Optional[bytes return public_key_bytes - def get_mev_shield_submission( - self, - submission_id: str, - block: Optional[int] = None, - ) -> Optional[dict[str, str | int | bytes]]: - """ - Retrieves Submission from the MevShield pallet storage. - - If submission_id is provided, returns a single submission. If submission_id is None, returns all submissions from - the storage map. - - Parameters: - submission_id: The hash ID of the submission. Can be a hex string with "0x" prefix or bytes. If None, - returns all submissions. - block: The blockchain block number at which to perform the query. If None, uses the current block. - - Returns: - If submission_id is provided: A dictionary containing the submission data if found, None otherwise. The - dictionary contains: - - author: The SS58 address of the account that submitted the encrypted extrinsic - - commitment: The blake2_256 hash of the payload_core (as hex string with "0x" prefix) - - ciphertext: The encrypted blob as bytes (format: [u16 kem_len][kem_ct][nonce24][aead_ct]) - - submitted_in: The block number when the submission was created - - If submission_id is None: A dictionary mapping submission IDs (as hex strings) to submission dictionaries. - - Note: - If a specific submission does not exist in storage, this function returns None. If querying all submissions - and none exist, returns an empty dictionary. - """ - block_hash = self.determine_block_hash(block=block) - submission_id = ( - submission_id[2:] if submission_id.startswith("0x") else submission_id - ) - submission_id_bytes = bytes.fromhex(submission_id) - - query = self.substrate.query( - module="MevShield", - storage_function="Submissions", - params=[submission_id_bytes], - block_hash=block_hash, - ) - - if query is None or not isinstance(query, dict): - return None - - autor = decode_account_id(query.get("author")) - commitment = bytes(query.get("commitment")[0]) - ciphertext = bytes(query.get("ciphertext")[0]) - submitted_in = query.get("submitted_in") - - return { - "author": autor, - "commitment": commitment, - "ciphertext": ciphertext, - "submitted_in": submitted_in, - } - - def get_mev_shield_submissions( - self, - block: Optional[int] = None, - ) -> Optional[dict[str, dict[str, str | int]]]: - """ - Retrieves all encrypted submissions from the MevShield pallet storage. - - This function queries the MevShield.Submissions storage map and returns all pending encrypted submissions that - have been submitted via submit_encrypted but not yet executed via execute_revealed. - - Parameters: - block: The blockchain block number for the query. If None, uses the current block. - - Returns: - A dictionary mapping wrapper_id (as hex string with "0x" prefix) to submission data dictionaries. Each - submission dictionary contains: - - author: The SS58 address of the account that submitted the encrypted extrinsic - - commitment: The blake2_256 hash of the payload_core as bytes (32 bytes) - - ciphertext: The encrypted blob as bytes (format: [u16 kem_len][kem_ct][nonce24][aead_ct]) - - submitted_in: The block number when the submission was created - - Returns None if no submissions exist in storage at the specified block. - - Note: - Submissions are automatically pruned after KEY_EPOCH_HISTORY blocks (100 blocks) by the pallet's - on_initialize hook. Only submissions that have been submitted but not yet executed will be present in - storage. - """ - block_hash = self.determine_block_hash(block=block) - query = self.substrate.query_map( - module="MevShield", - storage_function="Submissions", - block_hash=block_hash, - ) - - result = {} - for q in query: - key, value = q - value = value.value - result["0x" + bytes(key[0]).hex()] = { - "author": decode_account_id(value.get("author")), - "commitment": bytes(value.get("commitment")[0]), - "ciphertext": bytes(value.get("ciphertext")[0]), - "submitted_in": value.get("submitted_in"), - } - - return result if result else None - def get_minimum_required_stake(self) -> Balance: """Returns the minimum required stake threshold for nominator cleanup operations. @@ -2932,7 +3077,8 @@ def get_proxy_announcement( params=[delegate_account_ss58], block_hash=block_hash, ) - return ProxyAnnouncementInfo.from_dict(query.value[0]) + query_value = getattr(query, "value", query) + return ProxyAnnouncementInfo.from_dict(cast(list[Any], query_value)[0]) def get_proxy_announcements( self, @@ -3122,9 +3268,11 @@ def get_root_claim_type( params=[coldkey_ss58], block_hash=self.determine_block_hash(block), ) + query_value = getattr(query, "value", query) + claim_type = cast(dict[str, Any], query_value) # Query returns enum as dict: {"Swap": ()} or {"Keep": ()} or {"KeepSubnets": {"subnets": [1, 2, 3]}} - variant_name = next(iter(query.keys())) - variant_value = query[variant_name] + variant_name = next(iter(claim_type.keys())) + variant_value = claim_type[variant_name] # For simple variants (Swap, Keep), value is empty tuple, return string if not variant_value or variant_value == (): @@ -3164,7 +3312,8 @@ def get_root_alpha_dividends_per_subnet( params=[netuid, hotkey_ss58], block_hash=self.determine_block_hash(block), ) - return Balance.from_rao(query.value).set_unit(netuid=netuid) + value = getattr(query, "value", query) + return Balance.from_rao(cast(int, value)).set_unit(netuid=netuid) def get_root_claimable_rate( self, @@ -3221,7 +3370,8 @@ def get_root_claimable_all_rates( params=[hotkey_ss58], block_hash=self.determine_block_hash(block), ) - bits_list = next(iter(query.value)) + query_value = getattr(query, "value", query) + bits_list = next(iter(cast(list[list[tuple[int, FixedPoint]]], query_value))) return {bits[0]: fixed_to_float(bits[1], frac_bits=32) for bits in bits_list} def get_root_claimable_stake( @@ -3303,7 +3453,8 @@ def get_root_claimed( params=[netuid, hotkey_ss58, coldkey_ss58], block_hash=self.determine_block_hash(block), ) - return Balance.from_rao(query.value).set_unit(netuid=netuid) + value = getattr(query, "value", query) + return Balance.from_rao(cast(int, value)).set_unit(netuid=netuid) def get_stake( self, @@ -3314,11 +3465,10 @@ def get_stake( ) -> Balance: """ Returns the amount of Alpha staked by a specific coldkey to a specific hotkey within a given subnet. - This function retrieves the delegated stake balance, referred to as the 'Alpha' value. Parameters: coldkey_ss58: The SS58 address of the coldkey that delegated the stake. This address owns the stake. - hotkey_ss58: The ss58 address of the hotkey which the stake is on. + hotkey_ss58: The SS58 address of the hotkey which the stake is on. netuid: The unique identifier of the subnet to query. block: The specific block number at which to retrieve the stake information. @@ -3326,38 +3476,13 @@ def get_stake( An object representing the amount of Alpha (TAO ONLY if the subnet's netuid is 0) currently staked from the specified coldkey to the specified hotkey within the given subnet. """ - alpha_shares_query = self.query_module( - module="SubtensorModule", - name="Alpha", - block=block, + result = self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_info_for_hotkey_coldkey_netuid", params=[hotkey_ss58, coldkey_ss58, netuid], - ) - alpha_shares = alpha_shares_query - - hotkey_alpha_obj: ScaleObj = self.query_module( - module="SubtensorModule", - name="TotalHotkeyAlpha", - block=block, - params=[hotkey_ss58, netuid], - ) - hotkey_alpha = hotkey_alpha_obj.value - - hotkey_shares = self.query_module( - module="SubtensorModule", - name="TotalHotkeyShares", block=block, - params=[hotkey_ss58, netuid], ) - - alpha_shares_as_float = fixed_to_float(alpha_shares) - hotkey_shares_as_float = fixed_to_float(hotkey_shares) - - if hotkey_shares_as_float == 0: - return Balance.from_rao(0).set_unit(netuid=netuid) - - stake = alpha_shares_as_float / hotkey_shares_as_float * hotkey_alpha - - return Balance.from_rao(int(stake)).set_unit(netuid=netuid) + return StakeInfo.from_dict(result).stake def get_stake_for_coldkey_and_hotkey( self, @@ -3538,7 +3663,7 @@ def get_stake_weight(self, netuid: int, block: Optional[int] = None) -> list[flo params=[netuid], block_hash=block_hash, ) - return [u16_normalized_float(w) for w in result] + return [u16_normalized_float(w) for w in cast(list[int], result or [])] def get_start_call_delay(self, block: Optional[int] = None) -> int: """ @@ -3656,9 +3781,10 @@ def get_subnet_owner_hotkey( Returns: The hotkey of the subnet owner if available; `None` otherwise. """ - return self.query_subtensor( + query = self.query_subtensor( name="SubnetOwnerHotkey", params=[netuid], block=block ) + return cast(Optional[str], getattr(query, "value", query)) def get_subnet_price( self, @@ -3769,7 +3895,7 @@ def get_subnet_validator_permits( params=[netuid], block=block, ) - return query.value if query is not None and hasattr(query, "value") else query + return cast(Optional[list[bool]], getattr(query, "value", query)) def get_timelocked_weight_commits( self, @@ -3936,11 +4062,14 @@ def get_vote_data( This function is important for tracking and understanding the decision-making processes within the Bittensor network, particularly how proposals are received and acted upon by the governing body. """ - vote_data: dict[str, Any] = self.substrate.query( - module="Triumvirate", - storage_function="Voting", - params=[proposal_hash], - block_hash=self.determine_block_hash(block), + vote_data = cast( + Optional[dict[str, Any]], + self.substrate.query( + module="Triumvirate", + storage_function="Voting", + params=[proposal_hash], + block_hash=self.determine_block_hash(block), + ), ) if vote_data is None: @@ -3971,7 +4100,7 @@ def get_uid_for_hotkey_on_subnet( params=[netuid, hotkey_ss58], block_hash=self.determine_block_hash(block), ) - return getattr(result, "value", result) + return cast(Optional[int], getattr(result, "value", result)) def filter_netuids_by_registered_hotkeys( self, @@ -4446,8 +4575,13 @@ def query_identity( return None try: + identity_data = ( + identity_info.value + if hasattr(identity_info, "value") + else identity_info + ) return ChainIdentity.from_dict( - decode_hex_identity_dict(identity_info), + decode_hex_identity_dict(cast(dict[str, Any], identity_data)), ) except TypeError: return None @@ -4603,7 +4737,9 @@ def handler(block_data: dict): return True return None - current_block = self.substrate.get_block() + current_block = cast(Optional[dict[str, Any]], self.substrate.get_block()) + if current_block is None: + return False current_block_hash = current_block.get("header", {}).get("hash") if block is not None: target_block = block @@ -4876,8 +5012,8 @@ def sign_and_send_extrinsic( raise ChainError.from_error(response_error_message) extrinsic_response.success = False - extrinsic_response.message = format_error_message(response_error_message) - extrinsic_response.error = response_error_message + extrinsic_response.message = format_error_message(response_error_message) # type: ignore + extrinsic_response.error = response_error_message # type: ignore return extrinsic_response except SubstrateRequestException as error: @@ -5001,14 +5137,13 @@ def add_stake( wait_for_revealed_execution=wait_for_revealed_execution, ) - def add_liquidity( + def add_stake_burn( self, wallet: "Wallet", netuid: int, - liquidity: Balance, - price_low: Balance, - price_high: Balance, - hotkey_ss58: Optional[str] = None, + hotkey_ss58: str, + amount: Balance, + limit_price: Optional[Balance] = None, *, mev_protection: bool = DEFAULT_MEV_PROTECTION, period: Optional[int] = DEFAULT_PERIOD, @@ -5018,40 +5153,40 @@ def add_liquidity( wait_for_revealed_execution: bool = True, ) -> ExtrinsicResponse: """ - Adds liquidity to the specified price range. + Executes a subnet buyback by staking TAO and immediately burning the resulting Alpha. + + Only the subnet owner can call this method, and it is rate-limited to one call per subnet tempo. Parameters: - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - liquidity: The amount of liquidity to be added. - price_low: The lower bound of the price tick range. In TAO. - price_high: The upper bound of the price tick range. In TAO. - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. + wallet: The wallet used to sign the extrinsic (must be the subnet owner). + netuid: The unique identifier of the subnet. + hotkey_ss58: The `SS58` address of the hotkey account to stake to. + amount: The amount of TAO to use for the buyback. + limit_price: Optional limit price expressed in units of RAO per one Alpha. mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators decrypt and execute it. If `False`, submits the transaction directly without encryption. - 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. + 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. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. Returns: ExtrinsicResponse: The result object of the extrinsic execution. - - Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity` - method to enable/disable user liquidity. """ - return add_liquidity_extrinsic( + check_balance_amount(amount) + if limit_price is not None: + check_balance_amount(limit_price) + return add_stake_burn_extrinsic( subtensor=self, wallet=wallet, netuid=netuid, - liquidity=liquidity, - price_low=price_low, - price_high=price_high, hotkey_ss58=hotkey_ss58, + amount=amount, + limit_price=limit_price, mev_protection=mev_protection, period=period, raise_error=raise_error, @@ -5115,6 +5250,65 @@ def add_stake_multiple( wait_for_revealed_execution=wait_for_revealed_execution, ) + def add_liquidity( + self, + wallet: "Wallet", + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + hotkey_ss58: Optional[str] = None, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Adds liquidity to the specified price range. + + Parameters: + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The UID of the target subnet for which the call is being initiated. + liquidity: The amount of liquidity to be added. + price_low: The lower bound of the price tick range. In TAO. + price_high: The upper bound of the price tick range. In TAO. + hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + 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. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call `toggle_user_liquidity` + method to enable/disable user liquidity. + """ + return add_liquidity_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + liquidity=liquidity, + price_low=price_low, + price_high=price_high, + hotkey_ss58=hotkey_ss58, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def add_proxy( self, wallet: "Wallet", @@ -5175,6 +5369,62 @@ def add_proxy( wait_for_revealed_execution=wait_for_revealed_execution, ) + def announce_coldkey_swap( + self, + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Announces a coldkey swap by submitting the BlakeTwo256 hash of the new coldkey. + + This method allows a coldkey to declare its intention to swap to a new coldkey address. The announcement must be + made before the actual swap can be executed, and a delay period must pass before execution is allowed. + After making an announcement, all transactions from the coldkey are blocked except for `swap_coldkey_announced`. + + Parameters: + wallet: Bittensor wallet object (should be the current coldkey wallet). + new_coldkey_ss58: SS58 address of the new coldkey that will replace the current one. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - A swap cost is charged when making the first announcement (not when reannouncing). + - After making an announcement, all transactions from the coldkey are blocked except for ` + swap_coldkey_announced`. + - The swap can only be executed after the delay period has passed (check via + `get_coldkey_swap_announcement`). + - See: + """ + return announce_coldkey_swap_extrinsic( + subtensor=self, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def announce_proxy( self, wallet: "Wallet", @@ -5630,6 +5880,50 @@ def create_pure_proxy( wait_for_revealed_execution=wait_for_revealed_execution, ) + def dispute_coldkey_swap( + self, + wallet: "Wallet", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Disputes the coldkey swap announcement for the current coldkey. + + Callable by the coldkey that has an active swap announcement. Marks the swap as disputed. The account is blocked + until root calls reset_coldkey_swap. + + Parameters: + wallet: Bittensor wallet object (should be the current coldkey with an active announcement). + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet. + period: The number of blocks during which the transaction will remain valid. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The coldkey must have an active swap announcement. + - After disputing, only root can clear the state via reset_coldkey_swap. + """ + return dispute_coldkey_swap_extrinsic( + subtensor=self, + wallet=wallet, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def dissolve_crowdloan( self, wallet: "Wallet", @@ -7690,6 +7984,59 @@ def transfer( wait_for_revealed_execution=wait_for_revealed_execution, ) + def swap_coldkey_announced( + self, + wallet: "Wallet", + new_coldkey_ss58: str, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Executes a previously announced coldkey swap. + + This method executes a coldkey swap that was previously announced via `announce_coldkey_swap`. The new coldkey + address must match the hash that was announced, and the delay period must have passed. + + Parameters: + wallet: Bittensor wallet object (should be the current coldkey wallet that made the announcement). + new_coldkey_ss58: SS58 address of the new coldkey to swap to. This must match the hash that was announced. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + 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 inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Notes: + - The new coldkey hash must match the hash that was announced. + - The delay period must have passed (check via `get_coldkey_swap_announcement`). + - All assets, stakes, subnet ownerships, and hotkey associations are transferred from the old coldkey to the new one. + - See: + """ + return swap_coldkey_announced_extrinsic( + subtensor=self, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def transfer_stake( self, wallet: "Wallet", diff --git a/bittensor/extras/dev_framework/calls/non_sudo_calls.py b/bittensor/extras/dev_framework/calls/non_sudo_calls.py index 1606be4b5e..62fb1166f0 100644 --- a/bittensor/extras/dev_framework/calls/non_sudo_calls.py +++ b/bittensor/extras/dev_framework/calls/non_sudo_calls.py @@ -11,7 +11,7 @@ Note: Any manual changes will be overwritten the next time the generator is run. - Subtensor spec version: 365 + Subtensor spec version: 376 """ from collections import namedtuple @@ -27,6 +27,9 @@ ADD_STAKE = namedtuple( "ADD_STAKE", ["wallet", "pallet", "hotkey", "netuid", "amount_staked"] ) # args: [hotkey: T::AccountId, netuid: NetUid, amount_staked: TaoCurrency] | Pallet: SubtensorModule +ADD_STAKE_BURN = namedtuple( + "ADD_STAKE_BURN", ["wallet", "pallet", "hotkey", "netuid", "amount", "limit"] +) # args: [hotkey: T::AccountId, netuid: NetUid, amount: TaoCurrency, limit: Option] | Pallet: SubtensorModule ADD_STAKE_LIMIT = namedtuple( "ADD_STAKE_LIMIT", [ @@ -42,6 +45,9 @@ ANNOUNCE = namedtuple( "ANNOUNCE", ["wallet", "pallet", "real", "call_hash"] ) # args: [real: AccountIdLookupOf, call_hash: CallHashOf] | Pallet: Proxy +ANNOUNCE_COLDKEY_SWAP = namedtuple( + "ANNOUNCE_COLDKEY_SWAP", ["wallet", "pallet", "new_coldkey_hash"] +) # args: [new_coldkey_hash: T::Hash] | Pallet: SubtensorModule ANNOUNCE_NEXT_KEY = namedtuple( "ANNOUNCE_NEXT_KEY", ["wallet", "pallet", "public_key"] ) # args: [public_key: BoundedVec>] | Pallet: MevShield @@ -256,6 +262,9 @@ "pallet", ], ) # args: [] | Pallet: Swap +DISABLE_VOTING_POWER_TRACKING = namedtuple( + "DISABLE_VOTING_POWER_TRACKING", ["wallet", "pallet", "netuid"] +) # args: [netuid: NetUid] | Pallet: SubtensorModule DISABLE_WHITELIST = namedtuple( "DISABLE_WHITELIST", ["wallet", "pallet", "disabled"] ) # args: [disabled: bool] | Pallet: EVM @@ -265,12 +274,22 @@ DISPATCH_AS_FALLIBLE = namedtuple( "DISPATCH_AS_FALLIBLE", ["wallet", "pallet", "as_origin", "call"] ) # args: [as_origin: Box, call: Box<::RuntimeCall>] | Pallet: Utility +DISPUTE_COLDKEY_SWAP = namedtuple( + "DISPUTE_COLDKEY_SWAP", + [ + "wallet", + "pallet", + ], +) # args: [] | Pallet: SubtensorModule DISSOLVE = namedtuple( "DISSOLVE", ["wallet", "pallet", "crowdloan_id"] ) # args: [crowdloan_id: CrowdloanId] | Pallet: Crowdloan DISSOLVE_NETWORK = namedtuple( "DISSOLVE_NETWORK", ["wallet", "pallet", "coldkey", "netuid"] ) # args: [coldkey: T::AccountId, netuid: NetUid] | Pallet: SubtensorModule +ENABLE_VOTING_POWER_TRACKING = namedtuple( + "ENABLE_VOTING_POWER_TRACKING", ["wallet", "pallet", "netuid"] +) # args: [netuid: NetUid] | Pallet: SubtensorModule ENSURE_UPDATED = namedtuple( "ENSURE_UPDATED", ["wallet", "pallet", "hashes"] ) # args: [hashes: Vec] | Pallet: Preimage @@ -543,6 +562,9 @@ REQUEST_PREIMAGE = namedtuple( "REQUEST_PREIMAGE", ["wallet", "pallet", "hash"] ) # args: [hash: T::Hash] | Pallet: Preimage +RESET_COLDKEY_SWAP = namedtuple( + "RESET_COLDKEY_SWAP", ["wallet", "pallet", "coldkey"] +) # args: [coldkey: T::AccountId] | Pallet: SubtensorModule REVEAL_MECHANISM_WEIGHTS = namedtuple( "REVEAL_MECHANISM_WEIGHTS", ["wallet", "pallet", "netuid", "mecid", "uids", "values", "salt", "version_key"], @@ -735,6 +757,9 @@ SWAP_COLDKEY = namedtuple( "SWAP_COLDKEY", ["wallet", "pallet", "old_coldkey", "new_coldkey", "swap_cost"] ) # args: [old_coldkey: T::AccountId, new_coldkey: T::AccountId, swap_cost: TaoCurrency] | Pallet: SubtensorModule +SWAP_COLDKEY_ANNOUNCED = namedtuple( + "SWAP_COLDKEY_ANNOUNCED", ["wallet", "pallet", "new_coldkey"] +) # args: [new_coldkey: T::AccountId] | Pallet: SubtensorModule SWAP_HOTKEY = namedtuple( "SWAP_HOTKEY", ["wallet", "pallet", "hotkey", "new_hotkey", "netuid"] ) # args: [hotkey: T::AccountId, new_hotkey: T::AccountId, netuid: Option] | Pallet: SubtensorModule diff --git a/bittensor/extras/dev_framework/calls/pallets.py b/bittensor/extras/dev_framework/calls/pallets.py index feeb55559a..72f0dc092f 100644 --- a/bittensor/extras/dev_framework/calls/pallets.py +++ b/bittensor/extras/dev_framework/calls/pallets.py @@ -1,5 +1,5 @@ """ " -Subtensor spec version: 365 +Subtensor spec version: 376 """ System = "System" diff --git a/bittensor/extras/dev_framework/calls/sudo_calls.py b/bittensor/extras/dev_framework/calls/sudo_calls.py index 693a6b1e2a..7b29a05d99 100644 --- a/bittensor/extras/dev_framework/calls/sudo_calls.py +++ b/bittensor/extras/dev_framework/calls/sudo_calls.py @@ -11,7 +11,7 @@ Note: Any manual changes will be overwritten the next time the generator is run. - Subtensor spec version: 365 + Subtensor spec version: 376 """ from collections import namedtuple @@ -56,8 +56,12 @@ SUDO_SET_CK_BURN = namedtuple( "SUDO_SET_CK_BURN", ["wallet", "pallet", "sudo", "burn"] ) # args: [burn: u64] | Pallet: AdminUtils -SUDO_SET_COLDKEY_SWAP_SCHEDULE_DURATION = namedtuple( - "SUDO_SET_COLDKEY_SWAP_SCHEDULE_DURATION", ["wallet", "pallet", "sudo", "duration"] +SUDO_SET_COLDKEY_SWAP_ANNOUNCEMENT_DELAY = namedtuple( + "SUDO_SET_COLDKEY_SWAP_ANNOUNCEMENT_DELAY", ["wallet", "pallet", "sudo", "duration"] +) # args: [duration: BlockNumberFor] | Pallet: AdminUtils +SUDO_SET_COLDKEY_SWAP_REANNOUNCEMENT_DELAY = namedtuple( + "SUDO_SET_COLDKEY_SWAP_REANNOUNCEMENT_DELAY", + ["wallet", "pallet", "sudo", "duration"], ) # args: [duration: BlockNumberFor] | Pallet: AdminUtils SUDO_SET_COMMIT_REVEAL_VERSION = namedtuple( "SUDO_SET_COMMIT_REVEAL_VERSION", ["wallet", "pallet", "sudo", "version"] @@ -117,6 +121,9 @@ SUDO_SET_MAX_DIFFICULTY = namedtuple( "SUDO_SET_MAX_DIFFICULTY", ["wallet", "pallet", "sudo", "netuid", "max_difficulty"] ) # args: [netuid: NetUid, max_difficulty: u64] | Pallet: AdminUtils +SUDO_SET_MAX_MECHANISM_COUNT = namedtuple( + "SUDO_SET_MAX_MECHANISM_COUNT", ["wallet", "pallet", "sudo", "max_mechanism_count"] +) # args: [max_mechanism_count: MechId] | Pallet: AdminUtils SUDO_SET_MAX_REGISTRATIONS_PER_BLOCK = namedtuple( "SUDO_SET_MAX_REGISTRATIONS_PER_BLOCK", ["wallet", "pallet", "sudo", "netuid", "max_registrations_per_block"], @@ -258,6 +265,9 @@ SUDO_SET_TX_RATE_LIMIT = namedtuple( "SUDO_SET_TX_RATE_LIMIT", ["wallet", "pallet", "sudo", "tx_rate_limit"] ) # args: [tx_rate_limit: u64] | Pallet: AdminUtils +SUDO_SET_VOTING_POWER_EMA_ALPHA = namedtuple( + "SUDO_SET_VOTING_POWER_EMA_ALPHA", ["wallet", "pallet", "sudo", "netuid", "alpha"] +) # args: [netuid: NetUid, alpha: u64] | Pallet: SubtensorModule SUDO_SET_WEIGHTS_SET_RATE_LIMIT = namedtuple( "SUDO_SET_WEIGHTS_SET_RATE_LIMIT", ["wallet", "pallet", "sudo", "netuid", "weights_set_rate_limit"], diff --git a/bittensor/extras/subtensor_api/extrinsics.py b/bittensor/extras/subtensor_api/extrinsics.py index 69b3cde77f..43fafc79ad 100644 --- a/bittensor/extras/subtensor_api/extrinsics.py +++ b/bittensor/extras/subtensor_api/extrinsics.py @@ -1,6 +1,7 @@ from typing import Union -from bittensor.core.subtensor import Subtensor as _Subtensor + from bittensor.core.async_subtensor import AsyncSubtensor as _AsyncSubtensor +from bittensor.core.subtensor import Subtensor as _Subtensor class Extrinsics: @@ -9,7 +10,10 @@ class Extrinsics: def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.add_liquidity = subtensor.add_liquidity self.add_stake = subtensor.add_stake + self.add_stake_burn = subtensor.add_stake_burn self.add_stake_multiple = subtensor.add_stake_multiple + self.announce_coldkey_swap = subtensor.announce_coldkey_swap + self.dispute_coldkey_swap = subtensor.dispute_coldkey_swap self.burned_register = subtensor.burned_register self.claim_root = subtensor.claim_root self.commit_weights = subtensor.commit_weights @@ -36,6 +40,7 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.set_commitment = subtensor.set_commitment self.set_root_claim_type = subtensor.set_root_claim_type self.start_call = subtensor.start_call + self.swap_coldkey_announced = subtensor.swap_coldkey_announced self.swap_stake = subtensor.swap_stake self.toggle_user_liquidity = subtensor.toggle_user_liquidity self.transfer = subtensor.transfer diff --git a/bittensor/extras/subtensor_api/mev_shield.py b/bittensor/extras/subtensor_api/mev_shield.py index 8484e1a766..5d8e2b396d 100644 --- a/bittensor/extras/subtensor_api/mev_shield.py +++ b/bittensor/extras/subtensor_api/mev_shield.py @@ -11,8 +11,6 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): # Storage queries self.get_mev_shield_current_key = subtensor.get_mev_shield_current_key self.get_mev_shield_next_key = subtensor.get_mev_shield_next_key - self.get_mev_shield_submission = subtensor.get_mev_shield_submission - self.get_mev_shield_submissions = subtensor.get_mev_shield_submissions # Extrinsics self.mev_submit_encrypted = subtensor.mev_submit_encrypted diff --git a/bittensor/extras/subtensor_api/staking.py b/bittensor/extras/subtensor_api/staking.py index 16d7b6cf5a..0ab2e5485b 100644 --- a/bittensor/extras/subtensor_api/staking.py +++ b/bittensor/extras/subtensor_api/staking.py @@ -8,6 +8,7 @@ class Staking: def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.add_stake = subtensor.add_stake + self.add_stake_burn = subtensor.add_stake_burn self.add_stake_multiple = subtensor.add_stake_multiple self.claim_root = subtensor.claim_root self.get_auto_stakes = subtensor.get_auto_stakes diff --git a/bittensor/extras/subtensor_api/wallets.py b/bittensor/extras/subtensor_api/wallets.py index b6822159ed..55e8477936 100644 --- a/bittensor/extras/subtensor_api/wallets.py +++ b/bittensor/extras/subtensor_api/wallets.py @@ -1,6 +1,7 @@ from typing import Union -from bittensor.core.subtensor import Subtensor as _Subtensor + from bittensor.core.async_subtensor import AsyncSubtensor as _AsyncSubtensor +from bittensor.core.subtensor import Subtensor as _Subtensor class Wallets: @@ -19,6 +20,17 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_balances = subtensor.get_balances self.get_children = subtensor.get_children self.get_children_pending = subtensor.get_children_pending + self.get_coldkey_swap_announcement_delay = ( + subtensor.get_coldkey_swap_announcement_delay + ) + self.get_coldkey_swap_announcement = subtensor.get_coldkey_swap_announcement + self.get_coldkey_swap_announcements = subtensor.get_coldkey_swap_announcements + self.get_coldkey_swap_constants = subtensor.get_coldkey_swap_constants + self.get_coldkey_swap_reannouncement_delay = ( + subtensor.get_coldkey_swap_reannouncement_delay + ) + self.get_coldkey_swap_dispute = subtensor.get_coldkey_swap_dispute + self.get_coldkey_swap_disputes = subtensor.get_coldkey_swap_disputes self.get_delegate_by_hotkey = subtensor.get_delegate_by_hotkey self.get_delegate_take = subtensor.get_delegate_take self.get_delegated = subtensor.get_delegated diff --git a/bittensor/py.typed b/bittensor/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bittensor/utils/__init__.py b/bittensor/utils/__init__.py index 1dd9fd205c..44889d4c40 100644 --- a/bittensor/utils/__init__.py +++ b/bittensor/utils/__init__.py @@ -4,7 +4,7 @@ import inspect import warnings from collections import namedtuple -from typing import Any, Literal, Union, Optional, TYPE_CHECKING +from typing import Any, Literal, Union, Optional, Type, TYPE_CHECKING from urllib.parse import urlparse import scalecodec @@ -42,6 +42,14 @@ UnlockStatus = namedtuple("UnlockStatus", ["success", "message"]) + +class ChainFeatureDisabledWarning(UserWarning): + """Warning indicating that a feature is currently disabled on the chain side. + + This warning is issued when SDK functionality depends on chain feats that are temporarily unavailable or disabled. + """ + + # redundant aliases logging = logging torch = torch @@ -480,10 +488,29 @@ def determine_chain_endpoint_and_network( return "unknown", network -def deprecated_message(message: str) -> None: - """Shows a deprecation warning message with the given message.""" - warnings.simplefilter("default", DeprecationWarning) - warnings.warn(message=message, category=DeprecationWarning, stacklevel=2) +def deprecated_message( + message: Optional[str] = None, + replacement_message: Optional[str] = None, + category: Type[Warning] = DeprecationWarning, + stacklevel: int = 2, +) -> None: + """Shows a warning message with the given message. + + Parameters: + message: The warning message to display. If None, a default deprecation message is generated. + replacement_message: An optional additional message suggesting a replacement. + category: The warning category to use. Defaults to DeprecationWarning. + stacklevel: The stack level for the warning. Defaults to 2 (points to the caller of deprecated_message). + Increase this value if deprecated_message is called from within another wrapper function. + """ + message = ( + message + if message + else f"The called object ({get_caller_name()}) is deprecated and will be removed in a future release." + ) + message = f"{message} {replacement_message}" if replacement_message else message + warnings.simplefilter("default", category) + warnings.warn(message=message, category=category, stacklevel=stacklevel) def get_function_name() -> str: diff --git a/bittensor/utils/balance.py b/bittensor/utils/balance.py index bd5e4e6049..670174f48e 100644 --- a/bittensor/utils/balance.py +++ b/bittensor/utils/balance.py @@ -1,6 +1,7 @@ from typing import Optional, TypedDict, Union from scalecodec import ScaleType +from async_substrate_interface.types import ScaleObj from bittensor.core import settings from bittensor.core.errors import BalanceTypeError, BalanceUnitMismatchError @@ -374,12 +375,14 @@ class FixedPoint(TypedDict): def fixed_to_float( - fixed: Union[FixedPoint, ScaleType], frac_bits: int = 64, total_bits: int = 128 + fixed: FixedPoint | ScaleType | ScaleObj, frac_bits: int = 64, total_bits: int = 128 ) -> float: """Converts a fixed-point value (e.g., U64F64) into a floating-point number.""" # By default, this is a U64F64 # which is 64 bits of integer and 64 bits of fractional - data: int = fb.value if isinstance((fb := fixed["bits"]), ScaleType) else fb + data: int = ( + fb.value if isinstance((fb := fixed["bits"]), (ScaleType, ScaleObj)) else fb + ) # Logical and to get the fractional part; remaining is the integer part fractional_part = data & (2**frac_bits - 1) diff --git a/bittensor/utils/easy_imports.py b/bittensor/utils/easy_imports.py index df846442c9..f31d968a79 100644 --- a/bittensor/utils/easy_imports.py +++ b/bittensor/utils/easy_imports.py @@ -36,6 +36,7 @@ from bittensor.core.chain_data import ( AxonInfo, ChainIdentity, + ColdkeySwapDisputeInfo, DelegateInfo, DelegateInfoLite, DynamicInfo, @@ -153,6 +154,7 @@ "Axon", "AxonInfo", "ChainIdentity", + "ColdkeySwapDisputeInfo", "DelegateInfo", "DelegateInfoLite", "DynamicInfo", diff --git a/bittensor/utils/liquidity.py b/bittensor/utils/liquidity.py index b32fab355c..16b778449e 100644 --- a/bittensor/utils/liquidity.py +++ b/bittensor/utils/liquidity.py @@ -8,6 +8,7 @@ from typing import Any from dataclasses import dataclass +from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message from bittensor.utils.balance import Balance, fixed_to_float # These three constants are unchangeable at the level of Uniswap math @@ -26,6 +27,14 @@ class LiquidityPosition: fees_alpha: Balance # RAO netuid: int + def __post_init__(self): + deprecated_message( + message="LiquidityPosition is deprecated. User liquidity functionality has been " + "disabled on the chain after migration from Uniswap V3 to PalSwap.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) + def to_token_amounts( self, current_subnet_price: Balance ) -> tuple[Balance, Balance]: @@ -63,6 +72,12 @@ def to_token_amounts( def price_to_tick(price: float) -> int: """Converts a float price to the nearest Uniswap V3 tick index.""" + deprecated_message( + message="price_to_tick() is deprecated. The chain has migrated from Uniswap V3 " + "to PalSwap which does not use tick-based pricing.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) if price <= 0: raise ValueError(f"Price must be positive, got `{price}`.") @@ -77,6 +92,12 @@ def price_to_tick(price: float) -> int: def tick_to_price(tick: int) -> float: """Convert an integer Uniswap V3 tick index to float price.""" + deprecated_message( + message="tick_to_price() is deprecated. The chain has migrated from Uniswap V3 " + "to PalSwap which does not use tick-based pricing.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) if not (MIN_TICK <= tick <= MAX_TICK): raise ValueError("Tick is out of allowed range") return PRICE_STEP**tick @@ -92,6 +113,12 @@ def get_fees( above: bool, ) -> float: """Returns the liquidity fee.""" + deprecated_message( + message="get_fees() is deprecated. The chain has migrated from Uniswap V3 " + "to PalSwap which uses a different fee calculation mechanism.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) tick_fee_key = "fees_out_tao" if quote else "fees_out_alpha" tick_fee_value = fixed_to_float(tick.get(tick_fee_key)) global_fee_value = global_fees_tao if quote else global_fees_alpha @@ -117,11 +144,16 @@ def get_fees_in_range( fees_above_high: float, ) -> float: """Returns the liquidity fee value in a range.""" + deprecated_message( + message="get_fees_in_range() is deprecated. The chain has migrated from Uniswap V3 " + "to PalSwap which uses a different fee calculation mechanism.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) global_fees = global_fees_tao if quote else global_fees_alpha return global_fees - fees_below_low - fees_above_high -# Calculate fees for a position def calculate_fees( position: dict[str, Any], global_fees_tao: float, @@ -132,6 +164,13 @@ def calculate_fees( alpha_fees_above_high: float, netuid: int, ) -> tuple[Balance, Balance]: + """Calculate fees for a position.""" + deprecated_message( + message="calculate_fees() is deprecated. The chain has migrated from Uniswap V3 " + "to PalSwap which uses a different fee calculation mechanism.", + category=ChainFeatureDisabledWarning, + stacklevel=3, + ) fee_tao_agg = get_fees_in_range( quote=True, global_fees_tao=global_fees_tao, diff --git a/contrib/CONTRIBUTING.md b/contrib/CONTRIBUTING.md index 7cb2f8a32f..67cdd09074 100644 --- a/contrib/CONTRIBUTING.md +++ b/contrib/CONTRIBUTING.md @@ -10,11 +10,12 @@ These are mostly guidelines, not rules. Use your best judgment, and feel free to 1. [Good First Issue Label](#good-first-issue-label) 1. [Beginner and Help-wanted Issues Label](#beginner-and-help-wanted-issues-label) 1. [How Can I Contribute?](#how-can-i-contribute) - 1. [Code Contribution General Guideline](#code-contribution-general-guidelines) + 1. [Code Contribution General Guidelines](#code-contribution-general-guidelines) 1. [Pull Request Philosophy](#pull-request-philosophy) 1. [Pull Request Process](#pull-request-process) 1. [Testing](#testing) 1. [Addressing Feedback](#addressing-feedback) + 1. [Signed Commits](#signed-commits) 1. [Squashing Commits](#squashing-commits) 1. [Refactoring](#refactoring) 1. [Peer Review](#peer-review) @@ -204,6 +205,26 @@ You are expected to reply to any review comments before your pull request is mer You may update the code or reject the feedback if you do not agree with it, but you should express so in a reply. If there is outstanding feedback and you are not actively working on it, your pull request will be closed. +#### Signed Commits + +All commits in pull requests must be signed. We require signed commits to verify the authenticity of contributions and ensure code integrity. + +To sign your commits, you must have GPG signing configured in Git: + +```bash +git commit -S -m "your commit message" +``` + +Or configure Git to sign all commits automatically: + +```bash +git config --global commit.gpgsign true +``` + +For instructions on setting up GPG key signing, see [GitHub's documentation on signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). + +> **Note:** Pull requests containing unsigned commits will not be merged. + #### Squashing Commits If your pull request contains fixup commits (commits that change the same line of code repeatedly) or too fine-grained commits, you may be asked to [squash](https://git-scm.com/docs/git-rebase#_interactive_mode) your commits before it will be reviewed. The basic squashing workflow is shown below. diff --git a/contrib/DEBUGGING.md b/contrib/DEBUGGING.md index 41992c20ca..2b9663d632 100644 --- a/contrib/DEBUGGING.md +++ b/contrib/DEBUGGING.md @@ -136,7 +136,7 @@ import pdb; pdb.set_trace() # breakpoint! This will stop execution at the breakpoint you set and can operate on the stack directly in the terminal. ## Searching for strings -Use `ag`. It's fast, convenient, and widely available on unix systems. Ag will highlight all occurrences of a given pattern. +Use `ag`. It's fast, convenient, and widely available on unix systems. Ag will highlight all occurrences of a given pattern. ```bash apt-get install silversearcher-ag diff --git a/contrib/DEVELOPMENT_WORKFLOW.md b/contrib/DEVELOPMENT_WORKFLOW.md index 8f9658d331..b637107ea0 100644 --- a/contrib/DEVELOPMENT_WORKFLOW.md +++ b/contrib/DEVELOPMENT_WORKFLOW.md @@ -136,7 +136,7 @@ Finishing a hotfix branch involves merging the bugfix into both `master` and `st 8. Push changes to origin/staging: `git push origin staging` 9. Delete hotfix branch: `git branch -d hotfix/3.3.4/optional-descriptive-message` -The one exception to the rule here is that, **when a release branch currently exists, the hotfix changes need to be merged into that release branch, instead of** `staging`. Back-merging the bugfix into the __release__ branch will eventually result in the bugfix being merged into `develop` too, when the release branch is finished. (If work in develop immediately requires this bugfix and cannot wait for the release branch to be finished, you may safely merge the bugfix into develop now already as well.) +The one exception to the rule here is that, **when a release branch currently exists, the hotfix changes need to be merged into that release branch, instead of** `staging`. Back-merging the bugfix into the __release__ branch will eventually result in the bugfix being merged into `staging` too, when the release branch is finished. (If work in staging immediately requires this bugfix and cannot wait for the release branch to be finished, you may safely merge the bugfix into staging now already as well.) Finally, we remove the temporary branch: diff --git a/contrib/STYLE.md b/contrib/STYLE.md index 9992bd404a..a5c11c15fb 100644 --- a/contrib/STYLE.md +++ b/contrib/STYLE.md @@ -1,6 +1,6 @@ # Style Guide -A project’s long-term success rests (among other things) on its maintainability, and a maintainer has few tools more powerful than his or her project’s log. It’s worth taking the time to learn how to care for one properly. What may be a hassle at first soon becomes habit, and eventually a source of pride and productivity for all involved. +A project's long-term success rests (among other things) on its maintainability, and a maintainer has few tools more powerful than his or her project's log. It's worth taking the time to learn how to care for one properly. What may be a hassle at first soon becomes habit, and eventually a source of pride and productivity for all involved. Most programming languages have well-established conventions as to what constitutes idiomatic style, i.e. naming, formatting and so on. There are variations on these conventions, of course, but most developers agree that picking one and sticking to it is far better than the chaos that ensues when everybody does their own thing. @@ -28,7 +28,7 @@ Python's official style guide is PEP 8, which provides conventions for writing c - `Indentation:` Use 4 spaces per indentation level. -- `Line Length:` Limit all lines to a maximum of 79 characters. This is not strict, however, and we follow ruff's default of 88 characters. +- `Line Length:` Limit all lines to a maximum of 79 characters. This is not strict, however, and we follow ruff's default of 88 characters. - `Blank Lines:` Surround top-level function and class definitions with two blank lines. Method definitions inside a class are surrounded by a single blank line. @@ -87,7 +87,7 @@ We have added a helper tool `make check` to run the ruff formatter, and all lint ### Git commit style -Here’s a model Git commit message when contributing: +Here's a model Git commit message when contributing: ``` Summarize changes in around 50 characters or less @@ -122,7 +122,7 @@ See also: #456, #789 ## The six rules of a great commit. #### 1. Atomic Commits -An “atomic” change revolves around one task or one fix. +An "atomic" change revolves around one task or one fix. Atomic Approach - Commit each fix or task as a separate change @@ -140,7 +140,7 @@ Benefits Commit messages like "fix", "fix2", or "fix3" don't provide any context or clear understanding of what changes the commit introduces. Here are some examples of good vs. bad commit messages: -**Bad Commit Message:** +**Bad Commit Message:** $ git commit -m "fix" @@ -148,11 +148,11 @@ Commit messages like "fix", "fix2", or "fix3" don't provide any context or clear $ git commit -m "Fix typo in README file" -> **Caveat**: When working with new features, an atomic commit will often consist of multiple files, since a layout file, code behind file, and additional resources may have been added/modified. You don’t want to commit all of these separately, because if you had to roll back the application to a state before the feature was added, it would involve multiple commit entries, and that can get confusing +> **Caveat**: When working with new features, an atomic commit will often consist of multiple files, since a layout file, code behind file, and additional resources may have been added/modified. You don't want to commit all of these separately, because if you had to roll back the application to a state before the feature was added, it would involve multiple commit entries, and that can get confusing #### 2. Separate subject from body with a blank line -Not every commit requires both a subject and a body. Sometimes a single line is fine, especially when the change is so simple that no further context is necessary. +Not every commit requires both a subject and a body. Sometimes a single line is fine, especially when the change is so simple that no further context is necessary. For example: @@ -160,7 +160,7 @@ For example: Nothing more needs to be said; if the reader wonders what the typo was, she can simply take a look at the change itself, i.e. use git show or git diff or git log -p. -If you’re committing something like this at the command line, it’s easy to use the -m option to git commit: +If you're committing something like this at the command line, it's easy to use the -m option to git commit: $ git commit -m "Fix typo in introduction to user guide" @@ -172,37 +172,37 @@ However, when a commit merits a bit of explanation and context, you need to writ This commit throws Tron's disc into MCP (causing its deresolution) and turns it back into a chess game. -Commit messages with bodies are not so easy to write with the -m option. You’re better off writing the message in a proper text editor. [See Pro Git](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration). +Commit messages with bodies are not so easy to write with the -m option. You're better off writing the message in a proper text editor. [See Pro Git](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration). -In any case, the separation of subject from body pays off when browsing the log. Here’s the full log entry: +In any case, the separation of subject from body pays off when browsing the log. Here's the full log entry: $ git log commit 42e769bdf4894310333942ffc5a15151222a87be Author: Kevin Flynn Date: Fri Jan 01 00:00:00 1982 -0200 - + Derezz the master control program - + MCP turned out to be evil and had become intent on world domination. This commit throws Tron's disc into MCP (causing its deresolution) and turns it back into a chess game. #### 3. Limit the subject line to 50 characters -50 characters is not a hard limit, just a rule of thumb. Keeping subject lines at this length ensures that they are readable, and forces the author to think for a moment about the most concise way to explain what’s going on. +50 characters is not a hard limit, just a rule of thumb. Keeping subject lines at this length ensures that they are readable, and forces the author to think for a moment about the most concise way to explain what's going on. -GitHub’s UI is fully aware of these conventions. It will warn you if you go past the 50 character limit. Git will truncate any subject line longer than 72 characters with an ellipsis, thus keeping it to 50 is best practice. +GitHub's UI is fully aware of these conventions. It will warn you if you go past the 50 character limit. Git will truncate any subject line longer than 72 characters with an ellipsis, thus keeping it to 50 is best practice. #### 4. Use the imperative mood in the subject line -Imperative mood just means “spoken or written as if giving a command or instruction”. A few examples: +Imperative mood just means "spoken or written as if giving a command or instruction". A few examples: Clean your room Close the door Take out the trash -Each of the seven rules you’re reading about right now is written in the imperative (“Wrap the body at 72 characters”, etc.). +Each of the six rules you're reading about right now is written in the imperative ("Wrap the body at 72 characters", etc.). -The imperative can sound a little rude; that’s why we don’t often use it. But it’s perfect for Git commit subject lines. One reason for this is that Git itself uses the imperative whenever it creates a commit on your behalf. +The imperative can sound a little rude; that's why we don't often use it. But it's perfect for Git commit subject lines. One reason for this is that Git itself uses the imperative whenever it creates a commit on your behalf. For example, the default message created when using git merge reads: @@ -214,18 +214,18 @@ And when using git revert: This reverts commit cc87791524aedd593cff5a74532befe7ab69ce9d. -Or when clicking the “Merge” button on a GitHub pull request: +Or when clicking the "Merge" button on a GitHub pull request: Merge pull request #123 from someuser/somebranch -So when you write your commit messages in the imperative, you’re following Git’s own built-in conventions. For example: +So when you write your commit messages in the imperative, you're following Git's own built-in conventions. For example: Refactor subsystem X for readability Update getting started documentation Remove deprecated methods Release version 1.0.0 -Writing this way can be a little awkward at first. We’re more used to speaking in the indicative mood, which is all about reporting facts. That’s why commit messages often end up reading like this: +Writing this way can be a little awkward at first. We're more used to speaking in the indicative mood, which is all about reporting facts. That's why commit messages often end up reading like this: Fixed bug with Y Changing behavior of X @@ -235,7 +235,7 @@ And sometimes commit messages get written as a description of their contents: More fixes for broken stuff Sweet new API methods -To remove any confusion, here’s a simple rule to get it right every time. +To remove any confusion, here's a simple rule to get it right every time. **A properly formed Git commit subject line should always be able to complete the following sentence:** @@ -254,7 +254,7 @@ Git never wraps text automatically. When you write the body of a commit message, The recommendation is to do this at 72 characters, so that Git has plenty of room to indent text while still keeping everything under 80 characters overall. -A good text editor can help here. It’s easy to configure Vim, for example, to wrap text at 72 characters when you’re writing a Git commit. +A good text editor can help here. It's easy to configure Vim, for example, to wrap text at 72 characters when you're writing a Git commit. #### 6. Use the body to explain what and why vs. how This [commit](https://github.com/bitcoin/bitcoin/commit/eb0b56b19017ab5c16c745e6da39c53126924ed6) from Bitcoin Core is a great example of explaining what changed and why: @@ -283,9 +283,9 @@ Date: Fri Aug 1 22:57:55 2014 +0200 them. ``` -Take a look at the [full diff](https://github.com/bitcoin/bitcoin/commit/eb0b56b19017ab5c16c745e6da39c53126924ed6) and just think how much time the author is saving fellow and future committers by taking the time to provide this context here and now. If he didn’t, it would probably be lost forever. +Take a look at the [full diff](https://github.com/bitcoin/bitcoin/commit/eb0b56b19017ab5c16c745e6da39c53126924ed6) and just think how much time the author is saving fellow and future committers by taking the time to provide this context here and now. If he didn't, it would probably be lost forever. -In most cases, you can leave out details about how a change has been made. Code is generally self-explanatory in this regard (and if the code is so complex that it needs to be explained in prose, that’s what source comments are for). Just focus on making clear the reasons why you made the change in the first place—the way things worked before the change (and what was wrong with that), the way they work now, and why you decided to solve it the way you did. +In most cases, you can leave out details about how a change has been made. Code is generally self-explanatory in this regard (and if the code is so complex that it needs to be explained in prose, that's what source comments are for). Just focus on making clear the reasons why you made the change in the first place-the way things worked before the change (and what was wrong with that), the way they work now, and why you decided to solve it the way you did. The future maintainer that thanks you may be yourself! @@ -295,7 +295,7 @@ The future maintainer that thanks you may be yourself! ##### Using `--fixup` -If you've made a commit and then realize you've missed something or made a minor mistake, you can use the `--fixup` option. +If you've made a commit and then realize you've missed something or made a minor mistake, you can use the `--fixup` option. For example, suppose you've made a commit with a hash `9fceb02`. Later, you realize you've left a debug statement in your code. Instead of making a new commit titled "remove debug statement" or "fix", you can do the following: @@ -337,10 +337,10 @@ In this context, an atomic commit message could look like: ``` Add feature X -This commit introduces feature X which does A, B, and C. It adds +This commit introduces feature X which does A, B, and C. It adds new files for layout, updates the code behind the file, and introduces -new resources. This change is important because it allows users to -perform task Y more efficiently. +new resources. This change is important because it allows users to +perform task Y more efficiently. It includes: - Creation of new layout file @@ -350,4 +350,4 @@ It includes: Resolves: #123 ``` -In your PRs, remember to detail what the PR is introducing or fixing. This will be helpful for reviewers to understand the context and the reason behind the changes. +In your PRs, remember to detail what the PR is introducing or fixing. This will be helpful for reviewers to understand the context and the reason behind the changes. diff --git a/pyproject.toml b/pyproject.toml index 4b4ae7073f..dd45115f98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.1.0" +version = "10.2.0" description = "Bittensor SDK" readme = "README.md" authors = [ @@ -32,9 +32,9 @@ dependencies = [ "pydantic>=2.3,<3", "scalecodec==1.2.12", "uvicorn", - "bittensor-drand>=1.2.0,<2.0.0", - "bittensor-wallet>=4.0.0,<5.0", - "async-substrate-interface>=1.5.15" + "bittensor-drand>=1.3.0,<2.0.0", + "bittensor-wallet==4.0.1", + "async-substrate-interface>=1.6.2" ] [project.optional-dependencies] @@ -52,6 +52,7 @@ dev = [ "flake8==7.0.0", "mypy==1.8.0", "types-retry==0.9.9.4", + "typing_extensions>= 4.0.0; python_version<'3.11'", "freezegun==1.5.0", "httpx==0.27.0", "ruff==0.11.5", @@ -72,6 +73,9 @@ cli = [ homepage = "https://github.com/opentensor/bittensor" Repository = "https://github.com/opentensor/bittensor" +[tool.setuptools.package-data] +bittensor = ["py.typed"] + [tool.flit.metadata] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/tests/e2e_tests/test_add_stake_burn.py b/tests/e2e_tests/test_add_stake_burn.py new file mode 100644 index 0000000000..3fc329236f --- /dev/null +++ b/tests/e2e_tests/test_add_stake_burn.py @@ -0,0 +1,231 @@ +import pytest + +from bittensor.utils.balance import Balance +from tests.e2e_tests.utils import ( + ACTIVATE_SUBNET, + REGISTER_NEURON, + REGISTER_SUBNET, + TestSubnet, +) + + +def test_add_stake_burn(subtensor, alice_wallet, bob_wallet): + """Tests subnet buyback without limit price. + + Steps: + - Create subnet and register neuron for the target hotkey + - Verify no stake before buyback + - Execute subnet buyback as subnet owner + - Confirm stake is burned and coldkey balance decreases + """ + alice_sn = TestSubnet(subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + ] + alice_sn.execute_steps(steps) + + # no stake before buyback + stake_before = subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_before == Balance(0).set_unit(alice_sn.netuid) + + # track coldkey balance before buyback + balance_before = subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + + response = subtensor.staking.add_stake_burn( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(10), + period=16, + ) + assert response.success, response.message + + # stake is burned immediately after buyback + stake_after = subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_after == Balance(0).set_unit(alice_sn.netuid) + + # buyback spends TAO from the subnet owner coldkey + balance_after = subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + assert balance_after < balance_before + + +@pytest.mark.asyncio +async def test_add_stake_burn_async(async_subtensor, alice_wallet, bob_wallet): + """Tests subnet buyback without limit price (async). + + Steps: + - Create subnet and register neuron for the target hotkey + - Verify no stake before buyback + - Execute subnet buyback as subnet owner + - Confirm stake is burned and coldkey balance decreases + """ + alice_sn = TestSubnet(async_subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + ] + await alice_sn.async_execute_steps(steps) + + # no stake before buyback + stake_before = await async_subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_before == Balance(0).set_unit(alice_sn.netuid) + + # track coldkey balance before buyback + balance_before = await async_subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + + response = await async_subtensor.staking.add_stake_burn( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(10), + period=16, + ) + assert response.success, response.message + + # stake is burned immediately after buyback + stake_after = await async_subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_after == Balance(0).set_unit(alice_sn.netuid) + + # buyback spends TAO from the subnet owner coldkey + balance_after = await async_subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + assert balance_after < balance_before + + +def test_add_stake_burn_with_limit_price(subtensor, alice_wallet, bob_wallet): + """Tests subnet buyback with limit price. + + Steps: + - Create subnet and register neuron for the target hotkey + - Verify no stake before buyback + - Execute subnet buyback with limit price as subnet owner + - Confirm stake is burned and coldkey balance decreases + """ + alice_sn = TestSubnet(subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + ] + alice_sn.execute_steps(steps) + + # no stake before buyback + stake_before = subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_before == Balance(0).set_unit(alice_sn.netuid) + + # track coldkey balance before buyback + balance_before = subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + + response = subtensor.staking.add_stake_burn( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(10), + limit_price=Balance.from_tao(2), + period=16, + ) + assert response.success, response.message + + # stake is burned immediately after buyback + stake_after = subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_after == Balance(0).set_unit(alice_sn.netuid) + + # buyback spends TAO from the subnet owner coldkey + balance_after = subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + assert balance_after < balance_before + + +@pytest.mark.asyncio +async def test_add_stake_burn_with_limit_price_async( + async_subtensor, alice_wallet, bob_wallet +): + """Tests subnet buyback with limit price (async). + + Steps: + - Create subnet and register neuron for the target hotkey + - Verify no stake before buyback + - Execute subnet buyback with limit price as subnet owner + - Confirm stake is burned and coldkey balance decreases + """ + alice_sn = TestSubnet(async_subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + ] + await alice_sn.async_execute_steps(steps) + + # no stake before buyback + stake_before = await async_subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_before == Balance(0).set_unit(alice_sn.netuid) + + # track coldkey balance before buyback + balance_before = await async_subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + + response = await async_subtensor.staking.add_stake_burn( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(10), + limit_price=Balance.from_tao(2), + period=16, + ) + assert response.success, response.message + + # stake is burned immediately after buyback + stake_after = await async_subtensor.staking.get_stake( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert stake_after == Balance(0).set_unit(alice_sn.netuid) + + # buyback spends TAO from the subnet owner coldkey + balance_after = await async_subtensor.wallets.get_balance( + address=alice_wallet.coldkeypub.ss58_address + ) + assert balance_after < balance_before diff --git a/tests/e2e_tests/test_coldkey_swap.py b/tests/e2e_tests/test_coldkey_swap.py new file mode 100644 index 0000000000..926c7f6ff0 --- /dev/null +++ b/tests/e2e_tests/test_coldkey_swap.py @@ -0,0 +1,1123 @@ +import pytest + +from bittensor import logging +from bittensor.core.extrinsics.asyncex.sudo import ( + reset_coldkey_swap_extrinsic as async_reset_coldkey_swap_extrinsic, + swap_coldkey_extrinsic as async_swap_coldkey_extrinsic, +) +from bittensor.core.extrinsics.sudo import ( + reset_coldkey_swap_extrinsic, + swap_coldkey_extrinsic, +) +from bittensor.utils.balance import Balance + + +def test_coldkey_swap(subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet): + """ + Sync test for coldkey swap extrinsics. + + This comprehensive test covers: + 1. Happy Path - Successful swap flow: + - Step 1: Announce coldkey swap from Alice to Bob + - Step 2: Verify announcement was created and contains correct data + - Step 3: Verify coldkey swap constants are accessible + - Step 4: Wait for execution block (50 blocks delay) + - Step 5: Execute the swap + - Step 6: Verify announcement was removed after successful swap + + 2. Error cases for swap_coldkey_announced: + - Error 1: Attempt to execute swap without prior announcement + - Error 2: Attempt to execute swap with incorrect coldkey hash (mismatch) + - Error 3: Attempt to execute swap too early (before execution block) + + 3. Error cases for announce_coldkey_swap: + - Error 4: Attempt to create duplicate announcement (reannouncement behavior) + + 4. Transaction blocking after announcement: + - Step 1: Create announcement + - Step 2: Attempt to execute other transaction (transfer) from announced coldkey + - Step 3: Verify transaction is blocked (except swap_coldkey_announced) + + 5. Dispute and root reset: + - Step 1: Dave announces swap, then disputes it (dispute_coldkey_swap) + - Step 2: Verify dispute is recorded (get_coldkey_swap_dispute) + - Step 3: Verify account is blocked (transfer fails) + - Step 4: Root resets coldkey swap (reset_coldkey_swap) + - Step 5: Verify dispute and announcement are cleared + - Step 6: Verify transfers are unblocked after reset + + 6. Root swap override: + - Step 1: Root swaps Dave to Charlie without announcement + - Step 2: Verify announcement and dispute are cleared + - Step 3: Verify old coldkey is reaped + + Notes: + - Uses fast blocks mode (50 blocks delay instead of 5 days) + - All operations use subtensor for sync execution + - Each test section cleans up after itself + """ + logging.console.info("Starting coldkey swap E2E test") + + # === 1. Happy Path - Successful swap === + logging.console.info("Testing Happy Path - successful swap") + + # Step 1: Alice announces swap to new coldkey (Bob) + logging.console.info("Step 1: Alice announces swap to Bob") + response = subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Step 2: Verify announcement was created + logging.console.info("Step 2: Verify announcement was created") + announcement = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Announcement should exist" + assert announcement.coldkey == alice_wallet.coldkeypub.ss58_address + assert announcement.execution_block > subtensor.chain.get_current_block() + + # Step 3: Verify constants and storage values + logging.console.info("Step 3: Verify constants and storage values") + constants = subtensor.wallets.get_coldkey_swap_constants() + assert constants.KeySwapCost is not None + + announcement_delay = subtensor.wallets.get_coldkey_swap_announcement_delay() + reannouncement_delay = subtensor.wallets.get_coldkey_swap_reannouncement_delay() + + assert announcement_delay is not None + assert reannouncement_delay is not None + swap_cost = Balance.from_rao(constants.KeySwapCost) + swap_cost_rao = int(constants.KeySwapCost) + existential_deposit = subtensor.chain.get_existential_deposit() + logging.console.info( + f"Constants: AnnouncementDelay={announcement_delay}, " + f"ReannouncementDelay={reannouncement_delay}, " + f"KeySwapCost={constants.KeySwapCost}" + ) + + def assert_coldkey_reaped(coldkey_ss58: str, label: str) -> None: + balance_after = subtensor.wallets.get_balance(coldkey_ss58) + assert balance_after <= existential_deposit, ( + f"{label} balance after swap ({balance_after}) should be <= " + f"ED ({existential_deposit})" + ) + + # Step 4: Wait for 50 blocks (execution_block) + logging.console.info("Step 4: Waiting for execution block") + current_block = subtensor.chain.get_current_block() + execution_block = announcement.execution_block + logging.console.info( + f"Current block: {current_block}, Execution block: {execution_block}" + ) + subtensor.wait_for_block(execution_block + 1) + + # Step 5: Execute swap + logging.console.info("Step 5: Executing swap") + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to execute swap: {response.message}" + + # Step 6: Verify announcement was removed after swap + logging.console.info("Step 6: Verify announcement was removed after swap") + announcement_after = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement_after is None, "Announcement should be removed after swap" + dispute_after_swap = subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert dispute_after_swap is None, "Dispute should not exist after swap" + assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + + logging.console.info("Happy Path completed successfully") + + # Refund Alice balance for further tests (Bob now has all Alice's funds after swap) + logging.console.info("Refunding Alice balance for further tests") + bob_balance = subtensor.wallets.get_balance(bob_wallet.coldkeypub.ss58_address) + refund_amount = Balance.from_tao(10) + assert bob_balance > refund_amount, ( + f"Bob balance ({bob_balance}) too low to refund Alice ({refund_amount})" + ) + response = subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, f"Failed to refund Alice: {response.message}" + logging.console.info("Alice balance refunded successfully") + + # === 2. Error cases for swap_coldkey_announced === + logging.console.info("Testing errors for swap_coldkey_announced") + + # Error 1: Attempt to execute swap without announcement + logging.console.info("Error 1: Attempting swap without announcement") + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail without announcement" + assert "No coldkey swap announcement found" in response.message + logging.console.info("Error 1 passed: No announcement error") + + # Error 2: Hash mismatch + logging.console.info("Error 2: Testing hash mismatch") + # Alice announces swap to Bob + response = subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Attempt to execute swap with incorrect coldkey (Charlie instead of Bob) + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail with hash mismatch" + assert "hash does not match" in response.message.lower() + logging.console.info("Error 2 passed: Hash mismatch error") + + # Cleanup: Remove announcement from Error 2 by waiting and executing swap + logging.console.info("Cleaning up announcement from Error 2") + announcement_from_error2 = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement_from_error2 is not None, ( + "Announcement from Error 2 should exist" + ) + # Wait for execution block (wait_for_block is safe even if block already passed) + subtensor.wait_for_block(announcement_from_error2.execution_block + 1) + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to cleanup announcement: {response.message}" + logging.console.info("Cleaned up announcement from Error 2") + assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + # Refund Alice balance after swap + bob_balance = subtensor.wallets.get_balance(bob_wallet.coldkeypub.ss58_address) + refund_amount = Balance.from_tao(10) + assert bob_balance > refund_amount, ( + f"Bob balance ({bob_balance}) too low to refund Alice ({refund_amount})" + ) + response = subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, f"Failed to refund Alice: {response.message}" + logging.console.info("Refunded Alice balance after cleanup") + + # Error 3: Too early (before execution block) + logging.console.info("Error 3: Testing too early error") + # Create new announcement for this test + response = subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + announcement = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Announcement should exist for Error 3 test" + + # Attempt to execute swap immediately (before execution_block) + current_block = subtensor.chain.get_current_block() + assert current_block < announcement.execution_block, ( + "Current block should be before execution block" + ) + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail with too early error" + assert "too early" in response.message.lower() + assert str(announcement.execution_block) in response.message + logging.console.info("Error 3 passed: Too early error") + + # Wait for execution_block and execute swap for cleanup + subtensor.wait_for_block(announcement.execution_block) + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, response.message + logging.console.info("Cleaned up announcement by executing swap") + assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + # Refund Alice balance after swap + + refund_amount = Balance.from_tao(10) + response = subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, response.message + + # === 3. Error cases for announce_coldkey_swap === + logging.console.info("Testing errors for announce_coldkey_swap") + + # Error 4: Duplicate announcement (reannouncement) + logging.console.info("Error 4: Testing duplicate announcement") + # Create first announcement + response = subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Attempt to create second announcement (to Charlie) + response = subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail with duplicate announcement" + + # Verify that there is an active announcement + announcement = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Should have an active announcement" + logging.console.info("Error 4: Duplicate announcement handled") + + # Cleanup: Remove announcement from Error 4 by waiting and executing swap + logging.console.info("Cleaning up announcement from Error 4") + from bittensor_wallet import Keypair + + from bittensor.core.extrinsics.utils import verify_coldkey_hash + + # Determine which coldkey matches the announcement hash + bob_keypair = Keypair(ss58_address=bob_wallet.coldkeypub.ss58_address) + charlie_keypair = Keypair(ss58_address=charlie_wallet.coldkeypub.ss58_address) + + assert verify_coldkey_hash( + bob_keypair, announcement.new_coldkey_hash + ) or verify_coldkey_hash(charlie_keypair, announcement.new_coldkey_hash), ( + "Announcement hash should match either Bob or Charlie" + ) + + # Use the matching coldkey + target_coldkey = ( + bob_wallet.coldkeypub.ss58_address + if verify_coldkey_hash(bob_keypair, announcement.new_coldkey_hash) + else charlie_wallet.coldkeypub.ss58_address + ) + refund_wallet = ( + bob_wallet + if verify_coldkey_hash(bob_keypair, announcement.new_coldkey_hash) + else charlie_wallet + ) + + # Wait for execution block + subtensor.wait_for_block(announcement.execution_block + 1) + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=target_coldkey, + ) + assert response.success, f"Failed to cleanup announcement: {response.message}" + logging.console.info("Cleaned up announcement from Error 4") + assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + + # Refund Alice balance after swap + refund_amount = Balance.from_tao(10) + refund_balance = subtensor.wallets.get_balance( + refund_wallet.coldkeypub.ss58_address + ) + assert refund_balance > refund_amount, ( + f"{refund_wallet.name} balance ({refund_balance}) too low to refund Alice ({refund_amount})" + ) + response = subtensor.extrinsics.transfer( + wallet=refund_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, f"Failed to refund Alice: {response.message}" + logging.console.info( + f"Refunded Alice balance from {refund_wallet.name} after cleanup" + ) + + # === 4. Transaction blocking after announcement === + logging.console.info("Testing transaction blocking after announcement") + + # Step 1: Alice announces swap to Bob + logging.console.info("Step 1: Alice announces swap to Bob") + # Verify no existing announcement + existing_announcement = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert existing_announcement is None, ( + "No announcement should exist before creating new one" + ) + response = subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Step 2: Attempt to execute other transaction from Alice (transfer) + logging.console.info("Step 2: Attempting transfer transaction (should be blocked)") + transfer_value = Balance.from_tao(1) + dest_coldkey = charlie_wallet.coldkeypub.ss58_address + + response = subtensor.extrinsics.transfer( + wallet=alice_wallet, + destination_ss58=dest_coldkey, + amount=transfer_value, + raise_error=False, + ) + + # Step 3: Verify transaction is blocked + assert not response.success, "Transfer should be blocked after announcement" + # Error code 0 corresponds to ColdkeySwapAnnounced (see CustomTransactionError enum) + # The message may contain "Custom error: 0" or specific text about swap + assert ( + "Custom error: 0" in response.message + or "ColdkeySwapAnnounced" in response.message + or "swap" in response.message.lower() + ), ( + f"Expected transaction to be blocked by ColdkeySwapAnnounced, got: {response.message}" + ) + logging.console.info("Transaction blocking test passed") + + # Cleanup: wait for execution_block and execute swap + announcement = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Announcement should exist for cleanup" + subtensor.wait_for_block(announcement.execution_block + 1) + response = subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to cleanup announcement: {response.message}" + logging.console.info("Cleaned up announcement by executing swap") + assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + + # Ensure Alice has enough balance to pay root transaction fees + root_min_balance = Balance.from_tao(5) + alice_balance = subtensor.wallets.get_balance(alice_wallet.coldkeypub.ss58_address) + if alice_balance < root_min_balance: + top_up_amount = Balance.from_tao(10) + logging.console.info( + f"Top up Alice for root operations by {top_up_amount} " + f"(current {alice_balance})" + ) + response = subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=top_up_amount, + ) + assert response.success, ( + f"Failed to fund Alice for root ops: {response.message}" + ) + + # Ensure Dave has enough balance for dispute flow + dave_min_balance = swap_cost + Balance.from_tao(10) + dave_balance = subtensor.wallets.get_balance(dave_wallet.coldkeypub.ss58_address) + if dave_balance < dave_min_balance: + top_up_amount = dave_min_balance - dave_balance + logging.console.info( + f"Top up Dave for dispute flow by {top_up_amount} (current {dave_balance})" + ) + response = subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=dave_wallet.coldkeypub.ss58_address, + amount=top_up_amount, + ) + assert response.success, f"Failed to fund Dave: {response.message}" + + # === 5. Dispute and root reset === + logging.console.info("Testing dispute and root reset") + + # Step 1: Dave announces swap to Charlie + logging.console.info("Step 1: Dave announces swap to Charlie") + existing_announcement = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + existing_dispute = subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + assert existing_announcement is None, ( + "No announcement should exist before dispute test" + ) + assert existing_dispute is None, "No dispute should exist before dispute test" + response = subtensor.extrinsics.announce_coldkey_swap( + wallet=dave_wallet, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Step 2: Dave disputes the swap + logging.console.info("Step 2: Dave disputes the swap") + response = subtensor.extrinsics.dispute_coldkey_swap(wallet=dave_wallet) + assert response.success, f"Failed to dispute swap: {response.message}" + + # Step 3: Verify dispute is recorded + logging.console.info("Step 3: Verify dispute is recorded") + dispute = subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + assert dispute is not None, "Dispute should exist" + assert dispute.coldkey == dave_wallet.coldkeypub.ss58_address + logging.console.info(f"Dispute recorded at block {dispute.disputed_block}") + + # Step 4: Verify account is blocked (transfer from Dave should fail) + logging.console.info("Step 4: Verify account is blocked") + response = subtensor.extrinsics.transfer( + wallet=dave_wallet, + destination_ss58=charlie_wallet.coldkeypub.ss58_address, + amount=Balance.from_tao(1), + raise_error=False, + ) + assert not response.success, "Transfer should be blocked while disputed" + logging.console.info("Account blocking verified") + + # Step 5: Root resets the coldkey swap (alice_wallet is //Alice, root) + logging.console.info("Step 5: Root resets the coldkey swap") + response = reset_coldkey_swap_extrinsic( + subtensor=subtensor.inner_subtensor, + wallet=alice_wallet, + coldkey_ss58=dave_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to reset coldkey swap: {response.message}" + + # Step 6: Verify dispute and announcement are cleared + logging.console.info("Step 6: Verify dispute and announcement are cleared") + dispute_after = subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + announcement_after_reset = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + assert dispute_after is None, "Dispute should be cleared after reset" + assert announcement_after_reset is None, ( + "Announcement should be cleared after reset" + ) + + # Step 7: Verify transfers work again after reset + logging.console.info("Step 7: Verify transfers are unblocked after reset") + response = subtensor.extrinsics.transfer( + wallet=dave_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=Balance.from_tao(1), + raise_error=False, + ) + assert response.success, "Transfer should be allowed after reset" + logging.console.info("Dispute scenario completed successfully") + + # === 6. Root swap override === + logging.console.info("Testing root swap override") + + # Ensure Dave has enough balance for root swap cost + dave_min_balance = Balance.from_rao(swap_cost_rao) + Balance.from_tao(1) + dave_balance = subtensor.wallets.get_balance(dave_wallet.coldkeypub.ss58_address) + if dave_balance < dave_min_balance: + top_up_amount = dave_min_balance - dave_balance + logging.console.info( + f"Top up Dave for root swap by {top_up_amount} (current {dave_balance})" + ) + response = subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=dave_wallet.coldkeypub.ss58_address, + amount=top_up_amount, + ) + assert response.success, f"Failed to fund Dave: {response.message}" + + response = swap_coldkey_extrinsic( + subtensor=subtensor.inner_subtensor, + wallet=alice_wallet, + old_coldkey_ss58=dave_wallet.coldkeypub.ss58_address, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + swap_cost=swap_cost_rao, + ) + assert response.success, f"Failed to swap coldkey via root: {response.message}" + + announcement_after_root_swap = subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + dispute_after_root_swap = subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + assert announcement_after_root_swap is None, ( + "Announcement should be cleared after root swap" + ) + assert dispute_after_root_swap is None, "Dispute should be cleared after root swap" + assert_coldkey_reaped(dave_wallet.coldkeypub.ss58_address, "Dave") + + logging.console.info("All coldkey swap E2E tests completed successfully") + + +@pytest.mark.asyncio +async def test_coldkey_swap_async( + async_subtensor, alice_wallet, bob_wallet, charlie_wallet, dave_wallet +): + """ + Async test for coldkey swap extrinsics. + + This comprehensive test covers: + 1. Happy Path - Successful swap flow: + - Step 1: Announce coldkey swap from Alice to Bob + - Step 2: Verify announcement was created and contains correct data + - Step 3: Verify coldkey swap constants are accessible + - Step 4: Wait for execution block (50 blocks delay) + - Step 5: Execute the swap + - Step 6: Verify announcement was removed after successful swap + + 2. Error cases for swap_coldkey_announced: + - Error 1: Attempt to execute swap without prior announcement + - Error 2: Attempt to execute swap with incorrect coldkey hash (mismatch) + - Error 3: Attempt to execute swap too early (before execution block) + + 3. Error cases for announce_coldkey_swap: + - Error 4: Attempt to create duplicate announcement (reannouncement behavior) + + 4. Transaction blocking after announcement: + - Step 1: Create announcement + - Step 2: Attempt to execute other transaction (transfer) from announced coldkey + - Step 3: Verify transaction is blocked (except swap_coldkey_announced) + + 5. Dispute and root reset: + - Step 1: Dave announces swap, then disputes it (dispute_coldkey_swap) + - Step 2: Verify dispute is recorded (get_coldkey_swap_dispute) + - Step 3: Verify account is blocked (transfer fails) + - Step 4: Root resets coldkey swap (reset_coldkey_swap) + - Step 5: Verify dispute and announcement are cleared + - Step 6: Verify transfers are unblocked after reset + + 6. Root swap override: + - Step 1: Root swaps Dave to Charlie without announcement + - Step 2: Verify announcement and dispute are cleared + - Step 3: Verify old coldkey is reaped + + Notes: + - Uses fast blocks mode (50 blocks delay instead of 5 days) + - All operations use async_subtensor for async execution + - Each test section cleans up after itself + """ + logging.console.info("Starting coldkey swap E2E test") + + # === 1. Happy Path - Successful swap === + logging.console.info("Testing Happy Path - successful swap") + + # Step 1: Alice announces swap to new coldkey (Bob) + logging.console.info("Step 1: Alice announces swap to Bob") + response = await async_subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Step 2: Verify announcement was created + logging.console.info("Step 2: Verify announcement was created") + announcement = await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Announcement should exist" + assert announcement.coldkey == alice_wallet.coldkeypub.ss58_address + assert ( + announcement.execution_block > await async_subtensor.chain.get_current_block() + ) + + # Step 3: Verify constants and storage values + logging.console.info("Step 3: Verify constants and storage values") + constants = await async_subtensor.wallets.get_coldkey_swap_constants() + assert constants.KeySwapCost is not None + + announcement_delay = ( + await async_subtensor.wallets.get_coldkey_swap_announcement_delay() + ) + reannouncement_delay = ( + await async_subtensor.wallets.get_coldkey_swap_reannouncement_delay() + ) + + assert announcement_delay is not None + assert reannouncement_delay is not None + swap_cost = Balance.from_rao(constants.KeySwapCost) + swap_cost_rao = int(constants.KeySwapCost) + existential_deposit = await async_subtensor.chain.get_existential_deposit() + logging.console.info( + f"Constants: AnnouncementDelay={announcement_delay}, " + f"ReannouncementDelay={reannouncement_delay}, " + f"KeySwapCost={constants.KeySwapCost}" + ) + + async def assert_coldkey_reaped(coldkey_ss58: str, label: str) -> None: + balance_after = await async_subtensor.wallets.get_balance(coldkey_ss58) + assert balance_after <= existential_deposit, ( + f"{label} balance after swap ({balance_after}) should be <= " + f"ED ({existential_deposit})" + ) + + # Step 4: Wait for 50 blocks (execution_block) + logging.console.info("Step 4: Waiting for execution block") + current_block = await async_subtensor.chain.get_current_block() + execution_block = announcement.execution_block + logging.console.info( + f"Current block: {current_block}, Execution block: {execution_block}" + ) + await async_subtensor.wait_for_block(execution_block + 1) + + # Step 5: Execute swap + logging.console.info("Step 5: Executing swap") + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to execute swap: {response.message}" + + # Step 6: Verify announcement was removed after swap + logging.console.info("Step 6: Verify announcement was removed after swap") + announcement_after = await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement_after is None, "Announcement should be removed after swap" + dispute_after_swap = await async_subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert dispute_after_swap is None, "Dispute should not exist after swap" + await assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + + logging.console.info("Happy Path completed successfully") + + # Refund Alice balance for further tests (Bob now has all Alice's funds after swap) + logging.console.info("Refunding Alice balance for further tests") + bob_balance = await async_subtensor.wallets.get_balance( + bob_wallet.coldkeypub.ss58_address + ) + refund_amount = Balance.from_tao(10) + assert bob_balance > refund_amount, ( + f"Bob balance ({bob_balance}) too low to refund Alice ({refund_amount})" + ) + response = await async_subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, f"Failed to refund Alice: {response.message}" + logging.console.info("Alice balance refunded successfully") + + # === 2. Error cases for swap_coldkey_announced === + logging.console.info("Testing errors for swap_coldkey_announced") + + # Error 1: Attempt to execute swap without announcement + logging.console.info("Error 1: Attempting swap without announcement") + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail without announcement" + assert "No coldkey swap announcement found" in response.message + logging.console.info("Error 1 passed: No announcement error") + + # Error 2: Hash mismatch + logging.console.info("Error 2: Testing hash mismatch") + # Alice announces swap to Bob + response = await async_subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Attempt to execute swap with incorrect coldkey (Charlie instead of Bob) + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail with hash mismatch" + assert "hash does not match" in response.message.lower() + logging.console.info("Error 2 passed: Hash mismatch error") + + # Cleanup: Remove announcement from Error 2 by waiting and executing swap + logging.console.info("Cleaning up announcement from Error 2") + announcement_from_error2 = ( + await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + ) + assert announcement_from_error2 is not None, ( + "Announcement from Error 2 should exist" + ) + # Wait for execution block (wait_for_block is safe even if block already passed) + await async_subtensor.wait_for_block(announcement_from_error2.execution_block + 1) + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to cleanup announcement: {response.message}" + logging.console.info("Cleaned up announcement from Error 2") + await assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + # Refund Alice balance after swap + bob_balance = await async_subtensor.wallets.get_balance( + bob_wallet.coldkeypub.ss58_address + ) + refund_amount = Balance.from_tao(10) + assert bob_balance > refund_amount, ( + f"Bob balance ({bob_balance}) too low to refund Alice ({refund_amount})" + ) + response = await async_subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, f"Failed to refund Alice: {response.message}" + logging.console.info("Refunded Alice balance after cleanup") + + # Error 3: Too early (before execution block) + logging.console.info("Error 3: Testing too early error") + # Create new announcement for this test + response = await async_subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + announcement = await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Announcement should exist for Error 3 test" + + # Attempt to execute swap immediately (before execution_block) + current_block = await async_subtensor.chain.get_current_block() + assert current_block < announcement.execution_block, ( + "Current block should be before execution block" + ) + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail with too early error" + assert "too early" in response.message.lower() + assert str(announcement.execution_block) in response.message + logging.console.info("Error 3 passed: Too early error") + + # Wait for execution_block and execute swap for cleanup + await async_subtensor.wait_for_block(announcement.execution_block) + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, response.message + logging.console.info("Cleaned up announcement by executing swap") + await assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + # Refund Alice balance after swap + + refund_amount = Balance.from_tao(10) + response = await async_subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, response.message + + # === 3. Error cases for announce_coldkey_swap === + logging.console.info("Testing errors for announce_coldkey_swap") + + # Error 4: Duplicate announcement (reannouncement) + logging.console.info("Error 4: Testing duplicate announcement") + # Create first announcement + response = await async_subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Attempt to create second announcement (to Charlie) + response = await async_subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + raise_error=False, + ) + assert not response.success, "Should fail with duplicate announcement" + + # Verify that there is an active announcement + announcement = await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Should have an active announcement" + logging.console.info("Error 4: Duplicate announcement handled") + + # Cleanup: Remove announcement from Error 4 by waiting and executing swap + logging.console.info("Cleaning up announcement from Error 4") + from bittensor_wallet import Keypair + + from bittensor.core.extrinsics.utils import verify_coldkey_hash + + # Determine which coldkey matches the announcement hash + bob_keypair = Keypair(ss58_address=bob_wallet.coldkeypub.ss58_address) + charlie_keypair = Keypair(ss58_address=charlie_wallet.coldkeypub.ss58_address) + + assert verify_coldkey_hash( + bob_keypair, announcement.new_coldkey_hash + ) or verify_coldkey_hash(charlie_keypair, announcement.new_coldkey_hash), ( + "Announcement hash should match either Bob or Charlie" + ) + + # Use the matching coldkey + target_coldkey = ( + bob_wallet.coldkeypub.ss58_address + if verify_coldkey_hash(bob_keypair, announcement.new_coldkey_hash) + else charlie_wallet.coldkeypub.ss58_address + ) + refund_wallet = ( + bob_wallet + if verify_coldkey_hash(bob_keypair, announcement.new_coldkey_hash) + else charlie_wallet + ) + + # Wait for execution block + await async_subtensor.wait_for_block(announcement.execution_block + 1) + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=target_coldkey, + ) + assert response.success, f"Failed to cleanup announcement: {response.message}" + logging.console.info("Cleaned up announcement from Error 4") + await assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + + # Refund Alice balance after swap + refund_amount = Balance.from_tao(10) + refund_balance = await async_subtensor.wallets.get_balance( + refund_wallet.coldkeypub.ss58_address + ) + assert refund_balance > refund_amount, ( + f"{refund_wallet.name} balance ({refund_balance}) too low to refund Alice ({refund_amount})" + ) + response = await async_subtensor.extrinsics.transfer( + wallet=refund_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=refund_amount, + ) + assert response.success, f"Failed to refund Alice: {response.message}" + logging.console.info( + f"Refunded Alice balance from {refund_wallet.name} after cleanup" + ) + + # === 4. Transaction blocking after announcement === + logging.console.info("Testing transaction blocking after announcement") + + # Step 1: Alice announces swap to Bob + logging.console.info("Step 1: Alice announces swap to Bob") + # Verify no existing announcement + existing_announcement = await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert existing_announcement is None, ( + "No announcement should exist before creating new one" + ) + response = await async_subtensor.extrinsics.announce_coldkey_swap( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Step 2: Attempt to execute other transaction from Alice (transfer) + logging.console.info("Step 2: Attempting transfer transaction (should be blocked)") + transfer_value = Balance.from_tao(1) + dest_coldkey = charlie_wallet.coldkeypub.ss58_address + + response = await async_subtensor.extrinsics.transfer( + wallet=alice_wallet, + destination_ss58=dest_coldkey, + amount=transfer_value, + raise_error=False, + ) + + # Step 3: Verify transaction is blocked + assert not response.success, "Transfer should be blocked after announcement" + # Error code 0 corresponds to ColdkeySwapAnnounced (see CustomTransactionError enum) + # The message may contain "Custom error: 0" or specific text about swap + assert ( + "Custom error: 0" in response.message + or "ColdkeySwapAnnounced" in response.message + or "swap" in response.message.lower() + ), ( + f"Expected transaction to be blocked by ColdkeySwapAnnounced, got: {response.message}" + ) + logging.console.info("Transaction blocking test passed") + + # Cleanup: wait for execution_block and execute swap + announcement = await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=alice_wallet.coldkeypub.ss58_address + ) + assert announcement is not None, "Announcement should exist for cleanup" + await async_subtensor.wait_for_block(announcement.execution_block + 1) + response = await async_subtensor.extrinsics.swap_coldkey_announced( + wallet=alice_wallet, + new_coldkey_ss58=bob_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to cleanup announcement: {response.message}" + logging.console.info("Cleaned up announcement by executing swap") + await assert_coldkey_reaped(alice_wallet.coldkeypub.ss58_address, "Alice") + + # Ensure Alice has enough balance to pay root transaction fees + root_min_balance = Balance.from_tao(5) + alice_balance = await async_subtensor.wallets.get_balance( + alice_wallet.coldkeypub.ss58_address + ) + if alice_balance < root_min_balance: + top_up_amount = Balance.from_tao(10) + logging.console.info( + f"Top up Alice for root operations by {top_up_amount} " + f"(current {alice_balance})" + ) + response = await async_subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=top_up_amount, + ) + assert response.success, ( + f"Failed to fund Alice for root ops: {response.message}" + ) + + # Ensure Dave has enough balance for dispute flow + dave_min_balance = swap_cost + Balance.from_tao(10) + dave_balance = await async_subtensor.wallets.get_balance( + dave_wallet.coldkeypub.ss58_address + ) + if dave_balance < dave_min_balance: + top_up_amount = dave_min_balance - dave_balance + logging.console.info( + f"Top up Dave for dispute flow by {top_up_amount} (current {dave_balance})" + ) + response = await async_subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=dave_wallet.coldkeypub.ss58_address, + amount=top_up_amount, + ) + assert response.success, f"Failed to fund Dave: {response.message}" + + # === 5. Dispute and root reset === + logging.console.info("Testing dispute and root reset") + + # Step 1: Dave announces swap to Charlie + logging.console.info("Step 1: Dave announces swap to Charlie") + existing_announcement = await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + existing_dispute = await async_subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + assert existing_announcement is None, ( + "No announcement should exist before dispute test" + ) + assert existing_dispute is None, "No dispute should exist before dispute test" + response = await async_subtensor.extrinsics.announce_coldkey_swap( + wallet=dave_wallet, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to announce swap: {response.message}" + + # Step 2: Dave disputes the swap + logging.console.info("Step 2: Dave disputes the swap") + response = await async_subtensor.extrinsics.dispute_coldkey_swap(wallet=dave_wallet) + assert response.success, f"Failed to dispute swap: {response.message}" + + # Step 3: Verify dispute is recorded + logging.console.info("Step 3: Verify dispute is recorded") + dispute = await async_subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + assert dispute is not None, "Dispute should exist" + assert dispute.coldkey == dave_wallet.coldkeypub.ss58_address + logging.console.info(f"Dispute recorded at block {dispute.disputed_block}") + + # Step 4: Verify account is blocked (transfer from Dave should fail) + logging.console.info("Step 4: Verify account is blocked") + response = await async_subtensor.extrinsics.transfer( + wallet=dave_wallet, + destination_ss58=charlie_wallet.coldkeypub.ss58_address, + amount=Balance.from_tao(1), + raise_error=False, + ) + assert not response.success, "Transfer should be blocked while disputed" + logging.console.info("Account blocking verified") + + # Step 5: Root resets the coldkey swap (alice_wallet is //Alice, root) + logging.console.info("Step 5: Root resets the coldkey swap") + response = await async_reset_coldkey_swap_extrinsic( + subtensor=async_subtensor.inner_subtensor, + wallet=alice_wallet, + coldkey_ss58=dave_wallet.coldkeypub.ss58_address, + ) + assert response.success, f"Failed to reset coldkey swap: {response.message}" + + # Step 6: Verify dispute and announcement are cleared + logging.console.info("Step 6: Verify dispute and announcement are cleared") + dispute_after = await async_subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + announcement_after_reset = ( + await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + ) + assert dispute_after is None, "Dispute should be cleared after reset" + assert announcement_after_reset is None, ( + "Announcement should be cleared after reset" + ) + + # Step 7: Verify transfers work again after reset + logging.console.info("Step 7: Verify transfers are unblocked after reset") + response = await async_subtensor.extrinsics.transfer( + wallet=dave_wallet, + destination_ss58=alice_wallet.coldkeypub.ss58_address, + amount=Balance.from_tao(1), + raise_error=False, + ) + assert response.success, "Transfer should be allowed after reset" + logging.console.info("Dispute scenario completed successfully") + + # === 6. Root swap override === + logging.console.info("Testing root swap override") + + # Ensure Dave has enough balance for root swap cost + dave_min_balance = Balance.from_rao(swap_cost_rao) + Balance.from_tao(1) + dave_balance = await async_subtensor.wallets.get_balance( + dave_wallet.coldkeypub.ss58_address + ) + if dave_balance < dave_min_balance: + top_up_amount = dave_min_balance - dave_balance + logging.console.info( + f"Top up Dave for root swap by {top_up_amount} (current {dave_balance})" + ) + response = await async_subtensor.extrinsics.transfer( + wallet=bob_wallet, + destination_ss58=dave_wallet.coldkeypub.ss58_address, + amount=top_up_amount, + ) + assert response.success, f"Failed to fund Dave: {response.message}" + + response = await async_swap_coldkey_extrinsic( + subtensor=async_subtensor.inner_subtensor, + wallet=alice_wallet, + old_coldkey_ss58=dave_wallet.coldkeypub.ss58_address, + new_coldkey_ss58=charlie_wallet.coldkeypub.ss58_address, + swap_cost=swap_cost_rao, + ) + assert response.success, f"Failed to swap coldkey via root: {response.message}" + + announcement_after_root_swap = ( + await async_subtensor.wallets.get_coldkey_swap_announcement( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + ) + dispute_after_root_swap = await async_subtensor.wallets.get_coldkey_swap_dispute( + coldkey_ss58=dave_wallet.coldkeypub.ss58_address + ) + assert announcement_after_root_swap is None, ( + "Announcement should be cleared after root swap" + ) + assert dispute_after_root_swap is None, "Dispute should be cleared after root swap" + await assert_coldkey_reaped(dave_wallet.coldkeypub.ss58_address, "Dave") + + logging.console.info("All coldkey swap E2E tests completed successfully") diff --git a/tests/e2e_tests/test_commit_reveal.py b/tests/e2e_tests/test_commit_reveal.py index 6d21c8d46f..ca79eb3a28 100644 --- a/tests/e2e_tests/test_commit_reveal.py +++ b/tests/e2e_tests/test_commit_reveal.py @@ -12,6 +12,7 @@ REGISTER_SUBNET, SUDO_SET_ADMIN_FREEZE_WINDOW, SUDO_SET_TEMPO, + SUDO_SET_MAX_ALLOWED_UIDS, SUDO_SET_MECHANISM_COUNT, SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED, SUDO_SET_WEIGHTS_SET_RATE_LIMIT, @@ -49,6 +50,9 @@ def test_commit_and_reveal_weights_cr4(subtensor, alice_wallet): steps = [ SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), REGISTER_SUBNET(alice_wallet), + SUDO_SET_MAX_ALLOWED_UIDS( + alice_wallet, AdminUtils, True, NETUID, int(256 / TESTED_MECHANISMS) + ), SUDO_SET_MECHANISM_COUNT( alice_wallet, AdminUtils, True, NETUID, TESTED_MECHANISMS ), @@ -121,6 +125,7 @@ def test_commit_and_reveal_weights_cr4(subtensor, alice_wallet): wait_for_finalization=True, block_time=BLOCK_TIME, period=16, + raise_error=True, ) # Assert committing was a success @@ -236,6 +241,9 @@ async def test_commit_and_reveal_weights_cr4_async(async_subtensor, alice_wallet steps = [ SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), REGISTER_SUBNET(alice_wallet), + SUDO_SET_MAX_ALLOWED_UIDS( + alice_wallet, AdminUtils, True, NETUID, int(256 / TESTED_MECHANISMS) + ), SUDO_SET_MECHANISM_COUNT( alice_wallet, AdminUtils, True, NETUID, TESTED_MECHANISMS ), diff --git a/tests/e2e_tests/test_commit_weights.py b/tests/e2e_tests/test_commit_weights.py index 0d488eedf0..536c8c86af 100644 --- a/tests/e2e_tests/test_commit_weights.py +++ b/tests/e2e_tests/test_commit_weights.py @@ -15,13 +15,14 @@ NETUID, SUDO_SET_ADMIN_FREEZE_WINDOW, SUDO_SET_TEMPO, + SUDO_SET_MAX_ALLOWED_UIDS, SUDO_SET_MECHANISM_COUNT, SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED, SUDO_SET_WEIGHTS_SET_RATE_LIMIT, AdminUtils, ) -TESTED_SUB_SUBNETS = 2 +TESTED_MECHANISMS = 2 def test_commit_and_reveal_weights_legacy(subtensor, alice_wallet): @@ -44,8 +45,11 @@ def test_commit_and_reveal_weights_legacy(subtensor, alice_wallet): steps = [ SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), REGISTER_SUBNET(alice_wallet), + SUDO_SET_MAX_ALLOWED_UIDS( + alice_wallet, AdminUtils, True, NETUID, int(256 / TESTED_MECHANISMS) + ), SUDO_SET_MECHANISM_COUNT( - alice_wallet, AdminUtils, True, NETUID, TESTED_SUB_SUBNETS + alice_wallet, AdminUtils, True, NETUID, TESTED_MECHANISMS ), ACTIVATE_SUBNET(alice_wallet), SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, TEMPO_TO_SET), @@ -77,7 +81,7 @@ def test_commit_and_reveal_weights_legacy(subtensor, alice_wallet): assert response.success, response.message assert subtensor.subnets.weights_rate_limit(netuid=alice_sn.netuid) == 0 - for mechid in range(TESTED_SUB_SUBNETS): + for mechid in range(TESTED_MECHANISMS): logging.console.info( f"[magenta]Testing subnet mechanism {alice_sn.netuid}.{mechid}[/magenta]" ) @@ -174,7 +178,7 @@ async def test_commit_and_reveal_weights_legacy_async(async_subtensor, alice_wal SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), REGISTER_SUBNET(alice_wallet), SUDO_SET_MECHANISM_COUNT( - alice_wallet, AdminUtils, True, NETUID, TESTED_SUB_SUBNETS + alice_wallet, AdminUtils, True, NETUID, TESTED_MECHANISMS ), ACTIVATE_SUBNET(alice_wallet), SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, TEMPO_TO_SET), @@ -429,6 +433,9 @@ async def test_commit_weights_uses_next_nonce_async(async_subtensor, alice_walle steps = [ SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), REGISTER_SUBNET(alice_wallet), + SUDO_SET_MAX_ALLOWED_UIDS( + alice_wallet, AdminUtils, True, NETUID, int(256 / TESTED_MECHANISMS) + ), ACTIVATE_SUBNET(alice_wallet), SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, TEMPO_TO_SET), SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( diff --git a/tests/e2e_tests/test_mev_shield.py b/tests/e2e_tests/test_mev_shield.py index 05889bbf61..97a8790900 100644 --- a/tests/e2e_tests/test_mev_shield.py +++ b/tests/e2e_tests/test_mev_shield.py @@ -169,3 +169,42 @@ async def test_mev_shield_happy_path_async( ) logging.console.info(f"Stake after: {stake_after}") assert stake_after > stake_before + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_mev_shield_invalid_ciphertext(subtensor, alice_wallet, local_chain): + """Submitting garbage bytes as ciphertext should be rejected by CheckShieldedTxValidity (Custom(23)).""" + extrinsic_call = pallets.MevShield(subtensor.inner_subtensor).submit_encrypted( + ciphertext=b"\x00", + ) + + logging.console.info(f"Extrinsic call: {extrinsic_call}") + + response = subtensor.sign_and_send_extrinsic( + wallet=alice_wallet, + call=extrinsic_call, + raise_error=False, + ) + assert not response.success + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +@pytest.mark.asyncio +async def test_mev_shield_invalid_ciphertext_async( + async_subtensor, alice_wallet, local_chain +): + """Async: submitting garbage ciphertext should be rejected by CheckShieldedTxValidity (Custom(23)).""" + extrinsic_call = await pallets.MevShield( + async_subtensor.inner_subtensor + ).submit_encrypted( + ciphertext=b"\x00", + ) + + logging.console.info(f"Extrinsic call: {extrinsic_call}") + + response = await async_subtensor.sign_and_send_extrinsic( + wallet=alice_wallet, + call=extrinsic_call, + raise_error=False, + ) + assert not response.success diff --git a/tests/e2e_tests/test_root_claim.py b/tests/e2e_tests/test_root_claim.py index 08a928ce02..012a0858ac 100644 --- a/tests/e2e_tests/test_root_claim.py +++ b/tests/e2e_tests/test_root_claim.py @@ -214,7 +214,8 @@ async def test_root_claim_swap_async( netuid=root_sn.netuid ) ) - await async_subtensor.wait_for_block(block=next_epoch_start_block) + await async_subtensor.wait_for_block(block=next_epoch_start_block + 4) + charlie_root_stake = await async_subtensor.staking.get_stake( coldkey_ss58=charlie_wallet.coldkey.ss58_address, hotkey_ss58=alice_wallet.hotkey.ss58_address, @@ -381,7 +382,7 @@ def test_root_claim_keep_with_zero_num_root_auto_claims( hotkey_ss58=alice_wallet.hotkey.ss58_address, netuid=sn2.netuid, ) - assert stake_after_charlie >= claimable_stake_before_charlie + assert stake_after_charlie >= claimable_stake_before_charlie - Balance.from_rao(1, sn2.netuid) logging.console.info(f"[blue]Charlie after:[/blue]") logging.console.info(f"RootClaimed: {claimed_after_charlie}") @@ -534,6 +535,8 @@ async def test_root_claim_keep_with_zero_num_root_auto_claims_async( ) assert response.success, response.message + await async_subtensor.wait_for_block(await async_subtensor.block + 4) + # === Check Charlie after manual claim === claimed_after_charlie = await async_subtensor.staking.get_root_claimed( coldkey_ss58=charlie_wallet.coldkey.ss58_address, @@ -556,7 +559,7 @@ async def test_root_claim_keep_with_zero_num_root_auto_claims_async( hotkey_ss58=alice_wallet.hotkey.ss58_address, netuid=sn2.netuid, ) - assert stake_after_charlie >= claimable_stake_before_charlie + assert stake_after_charlie >= claimable_stake_before_charlie - Balance.from_rao(1, sn2.netuid) logging.console.info(f"[blue]Charlie after:[/blue]") logging.console.info(f"RootClaimed: {claimed_after_charlie}") diff --git a/tests/e2e_tests/test_set_weights.py b/tests/e2e_tests/test_set_weights.py index 0f6aa9240d..8925f0e619 100644 --- a/tests/e2e_tests/test_set_weights.py +++ b/tests/e2e_tests/test_set_weights.py @@ -17,6 +17,7 @@ SUDO_SET_ADMIN_FREEZE_WINDOW, SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED, SUDO_SET_LOCK_REDUCTION_INTERVAL, + SUDO_SET_MAX_ALLOWED_UIDS, SUDO_SET_MECHANISM_COUNT, SUDO_SET_NETWORK_RATE_LIMIT, SUDO_SET_TEMPO, @@ -57,6 +58,9 @@ def test_set_weights_uses_next_nonce(subtensor, alice_wallet): sns_steps = [ REGISTER_SUBNET(alice_wallet), + SUDO_SET_MAX_ALLOWED_UIDS( + alice_wallet, AdminUtils, True, NETUID, int(256 / TESTED_MECHANISMS) + ), SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, subnet_tempo), SUDO_SET_MECHANISM_COUNT( alice_wallet, AdminUtils, True, NETUID, TESTED_MECHANISMS @@ -199,6 +203,9 @@ async def test_set_weights_uses_next_nonce_async(async_subtensor, alice_wallet): sns_steps = [ REGISTER_SUBNET(alice_wallet), + SUDO_SET_MAX_ALLOWED_UIDS( + alice_wallet, AdminUtils, True, NETUID, int(256 / TESTED_MECHANISMS) + ), SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, subnet_tempo), SUDO_SET_MECHANISM_COUNT( alice_wallet, AdminUtils, True, NETUID, TESTED_MECHANISMS diff --git a/tests/e2e_tests/test_staking.py b/tests/e2e_tests/test_staking.py index b87284899d..f0010f234f 100644 --- a/tests/e2e_tests/test_staking.py +++ b/tests/e2e_tests/test_staking.py @@ -443,9 +443,9 @@ def test_batch_operations(subtensor, alice_wallet, bob_wallet): bob_wallet.coldkey.ss58_address, ) - assert CloseInValue( # Make sure we are within 0.0001 TAO due to tx fees - balances[bob_wallet.coldkey.ss58_address], Balance.from_rao(100_000) - ) == Balance.from_tao(999_999.7994) + assert CloseInValue( # Make sure we are within 0.0002 TAO due to tx fees + balances[bob_wallet.coldkey.ss58_address], Balance.from_rao(5_000_000) + ) == Balance.from_tao(999_999.7979) assert balances[alice_wallet.coldkey.ss58_address] > alice_balance @@ -569,9 +569,9 @@ async def test_batch_operations_async(async_subtensor, alice_wallet, bob_wallet) bob_wallet.coldkey.ss58_address, ) - assert CloseInValue( # Make sure we are within 0.0001 TAO due to tx fees - balances[bob_wallet.coldkey.ss58_address], Balance.from_rao(100_000) - ) == Balance.from_tao(999_999.7994) + assert CloseInValue( # Make sure we are within 0.0002 TAO due to tx fees + balances[bob_wallet.coldkey.ss58_address], Balance.from_rao(5_000_000) + ) == Balance.from_tao(999_999.7979) assert balances[alice_wallet.coldkey.ss58_address] > alice_balance diff --git a/tests/integration_tests/test_subtensor_integration.py b/tests/integration_tests/test_subtensor_integration.py index 09bca006d9..527076f07b 100644 --- a/tests/integration_tests/test_subtensor_integration.py +++ b/tests/integration_tests/test_subtensor_integration.py @@ -29,15 +29,16 @@ async def prepare_test(mocker, seed, **subtensor_args): return subtensor -@pytest.mark.asyncio -async def test_get_all_subnets_info(mocker): - subtensor = await prepare_test(mocker, "get_all_subnets_info") - result = subtensor.get_all_subnets_info() - assert isinstance(result, list) - assert result[0].owner_ss58 == "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" - assert result[1].kappa == 32767 - assert result[1].max_weight_limit == 65535 - assert result[1].blocks_since_epoch == 30 +# TODO: update test after Runtime updated +# @pytest.mark.asyncio +# async def test_get_all_subnets_info(mocker): +# subtensor = await prepare_test(mocker, "get_all_subnets_info") +# result = subtensor.get_all_subnets_info() +# assert isinstance(result, list) +# assert result[0].owner_ss58 == "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" +# assert result[1].kappa == 32767 +# assert result[1].max_weight_limit == 65535 +# assert result[1].blocks_since_epoch == 30 @pytest.mark.asyncio diff --git a/tests/unit_tests/chain_data/test_coldkey_swap.py b/tests/unit_tests/chain_data/test_coldkey_swap.py new file mode 100644 index 0000000000..e13e245af6 --- /dev/null +++ b/tests/unit_tests/chain_data/test_coldkey_swap.py @@ -0,0 +1,83 @@ +from async_substrate_interface.types import ScaleObj + +from bittensor.core.chain_data.coldkey_swap import ( + ColdkeySwapAnnouncementInfo, + ColdkeySwapDisputeInfo, +) + + +def test_coldkey_swap_announcement_info_from_query_none(mocker): + """Test from_query returns None when query has no value.""" + # Prep + coldkey_ss58 = mocker.Mock(spec=str) + query = mocker.Mock(spec=ScaleObj) + + # Call + from_query = ColdkeySwapAnnouncementInfo.from_query(coldkey_ss58, query) + + # Asserts + assert from_query is None + + +def test_coldkey_swap_announcement_info_from_query_happy_path(mocker): + """Test from_query returns ColdkeySwapAnnouncementInfo when query has valid data.""" + # Prep + coldkey_ss58 = mocker.Mock(spec=str) + fake_block = mocker.Mock(spec=int) + fake_hash_data = mocker.Mock(spec=list) + query = mocker.Mock(value=(fake_block, (fake_hash_data,))) + + mocked_bytes = mocker.patch("bittensor.core.chain_data.coldkey_swap.bytes") + + # Call + from_query = ColdkeySwapAnnouncementInfo.from_query(coldkey_ss58, query) + + # Asserts + mocked_bytes.assert_called_once_with(fake_hash_data) + assert from_query is not None, "Should return ColdkeySwapAnnouncementInfo object" + assert from_query.coldkey == coldkey_ss58 + assert from_query.execution_block == fake_block + assert ( + from_query.new_coldkey_hash + == mocked_bytes.return_value.hex.return_value.__radd__.return_value + ) + + +def test_coldkey_swap_dispute_info_from_query_none(mocker): + """Test from_query returns None when query has no value.""" + coldkey_ss58 = mocker.Mock(spec=str) + query = mocker.Mock(spec=ScaleObj) + query.value = None + + from_query = ColdkeySwapDisputeInfo.from_query(coldkey_ss58, query) + + assert from_query is None + + +def test_coldkey_swap_dispute_info_from_query_happy_path(mocker): + """Test from_query returns ColdkeySwapDisputeInfo when query has valid data.""" + coldkey_ss58 = mocker.Mock(spec=str) + fake_block = 12345 + query = mocker.Mock(spec=ScaleObj, value=fake_block) + + from_query = ColdkeySwapDisputeInfo.from_query(coldkey_ss58, query) + + assert from_query is not None + assert from_query.coldkey == coldkey_ss58 + assert from_query.disputed_block == fake_block + + +def test_coldkey_swap_dispute_info_from_record(mocker): + """Test from_record returns ColdkeySwapDisputeInfo from query_map record.""" + decoded_coldkey = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + disputed_block = 999 + record = (mocker.Mock(), mocker.Mock(value=disputed_block)) + mocker.patch( + "bittensor.core.chain_data.coldkey_swap.decode_account_id", + return_value=decoded_coldkey, + ) + + from_record = ColdkeySwapDisputeInfo.from_record(record) + + assert from_record.coldkey == decoded_coldkey + assert from_record.disputed_block == disputed_block diff --git a/tests/unit_tests/extrinsics/asyncex/test_coldkey_swap.py b/tests/unit_tests/extrinsics/asyncex/test_coldkey_swap.py new file mode 100644 index 0000000000..7307f9a4bf --- /dev/null +++ b/tests/unit_tests/extrinsics/asyncex/test_coldkey_swap.py @@ -0,0 +1,352 @@ +import pytest +from bittensor_wallet import Wallet +from scalecodec.types import GenericCall + +from bittensor.core.extrinsics.asyncex import coldkey_swap +from bittensor.core.extrinsics.pallets import SubtensorModule +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse +from bittensor.core.chain_data.coldkey_swap import ColdkeySwapAnnouncementInfo + + +@pytest.mark.asyncio +async def test_announce_coldkey_swap_extrinsic(subtensor, mocker): + """Verify that async `announce_coldkey_swap_extrinsic` method calls proper methods.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_keypair = mocker.patch( + "bittensor.core.extrinsics.asyncex.coldkey_swap.Keypair" + ) + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_compute_hash = mocker.patch.object( + coldkey_swap, "compute_coldkey_hash", return_value="0x" + "00" * 32 + ) + mocked_subtensor_module = mocker.patch.object( + coldkey_swap, "SubtensorModule", return_value=mocker.MagicMock() + ) + mocked_pallet_instance = mocked_subtensor_module.return_value + mocked_pallet_instance.announce_coldkey_swap = mocker.AsyncMock( + return_value=mocker.MagicMock() + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = await coldkey_swap.announce_coldkey_swap_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_keypair.assert_called_once_with(ss58_address=new_coldkey_ss58) + mocked_compute_hash.assert_called_once_with(mocked_keypair_instance) + mocked_subtensor_module.assert_called_once_with(subtensor) + mocked_pallet_instance.announce_coldkey_swap.assert_awaited_once_with( + new_coldkey_hash="0x" + "00" * 32 + ) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_pallet_instance.announce_coldkey_swap.return_value, + wallet=wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + assert response == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_announce_coldkey_swap_extrinsic_with_mev_protection(subtensor, mocker): + """Verify that async `announce_coldkey_swap_extrinsic` uses MEV protection when enabled.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_keypair = mocker.patch( + "bittensor.core.extrinsics.asyncex.coldkey_swap.Keypair" + ) + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_compute_hash = mocker.patch.object( + coldkey_swap, "compute_coldkey_hash", return_value="0x" + "00" * 32 + ) + mocked_subtensor_module = mocker.patch.object( + coldkey_swap, "SubtensorModule", return_value=mocker.MagicMock() + ) + mocked_pallet_instance = mocked_subtensor_module.return_value + mocked_pallet_instance.announce_coldkey_swap = mocker.AsyncMock( + return_value=mocker.MagicMock() + ) + mocked_submit_encrypted = mocker.patch.object( + coldkey_swap, + "submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = await coldkey_swap.announce_coldkey_swap_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=True, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_subtensor_module.assert_called_once_with(subtensor) + mocked_pallet_instance.announce_coldkey_swap.assert_awaited_once_with( + new_coldkey_hash="0x" + "00" * 32 + ) + mocked_submit_encrypted.assert_awaited_once() + assert response == mocked_submit_encrypted.return_value + + +@pytest.mark.asyncio +async def test_swap_coldkey_announced_extrinsic_success(subtensor, mocker): + """Verify that async `swap_coldkey_announced_extrinsic` method calls proper methods.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + fake_hash = "0x" + "00" * 32 + + announcement = ColdkeySwapAnnouncementInfo( + coldkey=wallet.coldkeypub.ss58_address, + execution_block=1000, + new_coldkey_hash=fake_hash, + ) + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=announcement + ) + mocked_get_current_block = mocker.patch.object( + subtensor, "get_current_block", return_value=1001 + ) + mocked_keypair = mocker.patch( + "bittensor.core.extrinsics.asyncex.coldkey_swap.Keypair" + ) + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_verify_hash = mocker.patch.object( + coldkey_swap, "verify_coldkey_hash", return_value=True + ) + mocked_subtensor_module = mocker.patch.object( + coldkey_swap, "SubtensorModule", return_value=mocker.MagicMock() + ) + mocked_pallet_instance = mocked_subtensor_module.return_value + mocked_pallet_instance.swap_coldkey_announced = mocker.AsyncMock( + return_value=mocker.MagicMock() + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = await coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_awaited_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + mocked_get_current_block.assert_awaited_once() + mocked_verify_hash.assert_called_once_with(mocked_keypair_instance, fake_hash) + mocked_subtensor_module.assert_called_once_with(subtensor) + mocked_pallet_instance.swap_coldkey_announced.assert_awaited_once_with( + new_coldkey=new_coldkey_ss58 + ) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_pallet_instance.swap_coldkey_announced.return_value, + wallet=wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + assert response == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_swap_coldkey_announced_extrinsic_no_announcement(subtensor, mocker): + """Verify that async `swap_coldkey_announced_extrinsic` returns error when no announcement.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=None + ) + + # Call + response = await coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + raise_error=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_awaited_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + assert response.success is False + assert "No coldkey swap announcement found" in response.message + + +@pytest.mark.asyncio +async def test_swap_coldkey_announced_extrinsic_hash_mismatch(subtensor, mocker): + """Verify that async `swap_coldkey_announced_extrinsic` returns error when hash doesn't match.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + fake_hash = "0x" + "00" * 32 + + announcement = ColdkeySwapAnnouncementInfo( + coldkey=wallet.coldkeypub.ss58_address, + execution_block=1000, + new_coldkey_hash=fake_hash, + ) + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=announcement + ) + mocked_keypair = mocker.patch( + "bittensor.core.extrinsics.asyncex.coldkey_swap.Keypair" + ) + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_verify_hash = mocker.patch.object( + coldkey_swap, "verify_coldkey_hash", return_value=False + ) + mocked_compute_hash = mocker.patch.object( + coldkey_swap, "compute_coldkey_hash", return_value="0x" + "11" * 32 + ) + + # Call + response = await coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + raise_error=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_awaited_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + mocked_verify_hash.assert_called_once_with(mocked_keypair_instance, fake_hash) + assert response.success is False + assert "hash does not match" in response.message.lower() + + +@pytest.mark.asyncio +async def test_swap_coldkey_announced_extrinsic_too_early(subtensor, mocker): + """Verify that async `swap_coldkey_announced_extrinsic` returns error when too early.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + fake_hash = "0x" + "00" * 32 + + announcement = ColdkeySwapAnnouncementInfo( + coldkey=wallet.coldkeypub.ss58_address, + execution_block=1000, + new_coldkey_hash=fake_hash, + ) + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=announcement + ) + mocked_get_current_block = mocker.patch.object( + subtensor, "get_current_block", return_value=999 + ) + mocked_keypair = mocker.patch( + "bittensor.core.extrinsics.asyncex.coldkey_swap.Keypair" + ) + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_verify_hash = mocker.patch.object( + coldkey_swap, "verify_coldkey_hash", return_value=True + ) + + # Call + response = await coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + raise_error=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_awaited_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + mocked_get_current_block.assert_awaited_once() + mocked_verify_hash.assert_called_once_with(mocked_keypair_instance, fake_hash) + assert response.success is False + assert "too early" in response.message.lower() + assert "999" in response.message + assert "1000" in response.message diff --git a/tests/unit_tests/extrinsics/asyncex/test_mev_shield.py b/tests/unit_tests/extrinsics/asyncex/test_mev_shield.py index 4143252636..241203f80d 100644 --- a/tests/unit_tests/extrinsics/asyncex/test_mev_shield.py +++ b/tests/unit_tests/extrinsics/asyncex/test_mev_shield.py @@ -1,9 +1,10 @@ import pytest -from bittensor_wallet import Wallet -from scalecodec.types import GenericCall from async_substrate_interface import AsyncExtrinsicReceipt +from async_substrate_interface.errors import SubstrateRequestException +from scalecodec.types import GenericCall from bittensor.core.extrinsics.asyncex import mev_shield +from bittensor.core.settings import MAX_MEV_SHIELD_PERIOD from bittensor.core.types import ExtrinsicResponse @@ -12,7 +13,6 @@ async def test_wait_for_extrinsic_by_hash_success(subtensor, mocker): """Verify that wait_for_extrinsic_by_hash finds the extrinsic by hash.""" # Preps extrinsic_hash = "0x1234567890abcdef" - shield_id = "shield_id_123" submit_block_hash = "0xblockhash" starting_block = 100 current_block = 100 @@ -49,74 +49,6 @@ async def test_wait_for_extrinsic_by_hash_success(subtensor, mocker): result = await mev_shield.wait_for_extrinsic_by_hash( subtensor=subtensor, extrinsic_hash=extrinsic_hash, - shield_id=shield_id, - submit_block_hash=submit_block_hash, - timeout_blocks=3, - ) - - # Asserts - mocked_get_block_number.assert_awaited_once_with(submit_block_hash) - mocked_wait_for_block.assert_awaited_once() - mocked_get_block_hash.assert_awaited_once_with(current_block) - mocked_get_extrinsics.assert_awaited_once_with("0xblockhash101") - mocked_extrinsic_receipt.assert_called_once_with( - substrate=subtensor.substrate, - block_hash="0xblockhash101", - block_number=current_block, - extrinsic_idx=0, - ) - assert result == mock_receipt - - -@pytest.mark.asyncio -async def test_wait_for_extrinsic_by_hash_decryption_failed(subtensor, mocker): - """Verify that wait_for_extrinsic_by_hash finds mark_decryption_failed extrinsic.""" - # Preps - extrinsic_hash = "0x1234567890abcdef" - shield_id = "shield_id_123" - submit_block_hash = "0xblockhash" - starting_block = 100 - current_block = 100 - - mocked_get_block_number = mocker.patch.object( - subtensor.substrate, - "get_block_number", - new=mocker.AsyncMock(return_value=starting_block), - ) - mocked_wait_for_block = mocker.patch.object( - subtensor, "wait_for_block", new=mocker.AsyncMock() - ) - mocked_get_block_hash = mocker.patch.object( - subtensor.substrate, - "get_block_hash", - new=mocker.AsyncMock(return_value="0xblockhash101"), - ) - - mock_extrinsic = mocker.MagicMock() - mock_extrinsic.value = { - "call": { - "call_module": "MevShield", - "call_function": "mark_decryption_failed", - "call_args": [{"name": "id", "value": shield_id}], - } - } - mocked_get_extrinsics = mocker.patch.object( - subtensor.substrate, - "get_extrinsics", - new=mocker.AsyncMock(return_value=[mock_extrinsic]), - ) - - mock_receipt = mocker.MagicMock(spec=AsyncExtrinsicReceipt) - mocked_extrinsic_receipt = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.AsyncExtrinsicReceipt", - return_value=mock_receipt, - ) - - # Call - result = await mev_shield.wait_for_extrinsic_by_hash( - subtensor=subtensor, - extrinsic_hash=extrinsic_hash, - shield_id=shield_id, submit_block_hash=submit_block_hash, timeout_blocks=3, ) @@ -140,7 +72,6 @@ async def test_wait_for_extrinsic_by_hash_timeout(subtensor, mocker): """Verify that wait_for_extrinsic_by_hash returns None on timeout.""" # Preps extrinsic_hash = "0x1234567890abcdef" - shield_id = "shield_id_123" submit_block_hash = "0xblockhash" starting_block = 100 @@ -167,7 +98,6 @@ async def test_wait_for_extrinsic_by_hash_timeout(subtensor, mocker): result = await mev_shield.wait_for_extrinsic_by_hash( subtensor=subtensor, extrinsic_hash=extrinsic_hash, - shield_id=shield_id, submit_block_hash=submit_block_hash, timeout_blocks=3, ) @@ -192,14 +122,11 @@ async def test_submit_encrypted_extrinsic_success_with_revealed_execution( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 # 1184 bytes - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 next_nonce = 6 - shield_id = "shield_id_123" block_hash = "0xblockhash" mocked_unlock_wallet = mocker.patch.object( @@ -224,9 +151,9 @@ async def test_submit_encrypted_extrinsic_success_with_revealed_execution( "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -242,7 +169,7 @@ async def test_submit_encrypted_extrinsic_success_with_revealed_execution( { "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, } ] mock_response = mocker.MagicMock(spec=ExtrinsicResponse) @@ -264,7 +191,7 @@ async def test_submit_encrypted_extrinsic_success_with_revealed_execution( return_value={ "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, }, ) @@ -294,15 +221,14 @@ async def test_submit_encrypted_extrinsic_success_with_revealed_execution( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -310,7 +236,7 @@ async def test_submit_encrypted_extrinsic_success_with_revealed_execution( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -322,7 +248,6 @@ async def test_submit_encrypted_extrinsic_success_with_revealed_execution( mocked_wait_for_extrinsic.assert_awaited_once_with( subtensor=subtensor, extrinsic_hash=signed_extrinsic_hash, - shield_id=shield_id, submit_block_hash=block_hash, timeout_blocks=3, ) @@ -342,9 +267,7 @@ async def test_submit_encrypted_extrinsic_success_without_revealed_execution( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 @@ -372,9 +295,9 @@ async def test_submit_encrypted_extrinsic_success_without_revealed_execution( "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -415,15 +338,14 @@ async def test_submit_encrypted_extrinsic_success_without_revealed_execution( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -431,16 +353,14 @@ async def test_submit_encrypted_extrinsic_success_without_revealed_execution( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, ) assert result == mock_response - assert result.data["commitment"] == mev_commitment assert result.data["ciphertext"] == mev_ciphertext assert result.data["ml_kem_768_public_key"] == ml_kem_768_public_key - assert result.data["payload_core"] == payload_core assert result.data["signed_extrinsic_hash"] == signed_extrinsic_hash @@ -561,9 +481,7 @@ async def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 @@ -589,9 +507,9 @@ async def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -640,15 +558,14 @@ async def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -656,7 +573,7 @@ async def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -682,14 +599,11 @@ async def test_submit_encrypted_extrinsic_failed_to_find_outcome( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 next_nonce = 6 - shield_id = "shield_id_123" block_hash = "0xblockhash" mocked_unlock_wallet = mocker.patch.object( @@ -714,9 +628,9 @@ async def test_submit_encrypted_extrinsic_failed_to_find_outcome( "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -732,7 +646,7 @@ async def test_submit_encrypted_extrinsic_failed_to_find_outcome( { "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, } ] mock_response = mocker.MagicMock(spec=ExtrinsicResponse) @@ -748,7 +662,7 @@ async def test_submit_encrypted_extrinsic_failed_to_find_outcome( return_value={ "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, }, ) mocked_wait_for_extrinsic = mocker.patch( @@ -781,15 +695,14 @@ async def test_submit_encrypted_extrinsic_failed_to_find_outcome( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -797,7 +710,7 @@ async def test_submit_encrypted_extrinsic_failed_to_find_outcome( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -809,7 +722,6 @@ async def test_submit_encrypted_extrinsic_failed_to_find_outcome( mocked_wait_for_extrinsic.assert_awaited_once_with( subtensor=subtensor, extrinsic_hash=signed_extrinsic_hash, - shield_id=shield_id, submit_block_hash=block_hash, timeout_blocks=3, ) @@ -830,14 +742,11 @@ async def test_submit_encrypted_extrinsic_execution_failure( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 next_nonce = 6 - shield_id = "shield_id_123" block_hash = "0xblockhash" error_message = "Execution failed" formatted_error = "Formatted error: Execution failed" @@ -864,9 +773,9 @@ async def test_submit_encrypted_extrinsic_execution_failure( "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -882,7 +791,7 @@ async def test_submit_encrypted_extrinsic_execution_failure( { "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, } ] mock_response = mocker.MagicMock(spec=ExtrinsicResponse) @@ -905,7 +814,7 @@ async def test_submit_encrypted_extrinsic_execution_failure( return_value={ "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, }, ) mocked_format_error_message = mocker.patch( @@ -938,15 +847,14 @@ async def test_submit_encrypted_extrinsic_execution_failure( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -954,7 +862,7 @@ async def test_submit_encrypted_extrinsic_execution_failure( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -966,13 +874,12 @@ async def test_submit_encrypted_extrinsic_execution_failure( mocked_wait_for_extrinsic.assert_awaited_once_with( subtensor=subtensor, extrinsic_hash=signed_extrinsic_hash, - shield_id=shield_id, submit_block_hash=block_hash, timeout_blocks=3, ) mocked_format_error_message.assert_called_once_with(error_message) assert result.success is False - assert isinstance(result.error, RuntimeError) + assert isinstance(result.error, SubstrateRequestException) assert result.message == formatted_error @@ -988,9 +895,7 @@ async def test_submit_encrypted_extrinsic_sign_and_send_failure( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 @@ -1016,9 +921,9 @@ async def test_submit_encrypted_extrinsic_sign_and_send_failure( "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -1058,15 +963,14 @@ async def test_submit_encrypted_extrinsic_sign_and_send_failure( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -1074,7 +978,7 @@ async def test_submit_encrypted_extrinsic_sign_and_send_failure( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -1092,9 +996,7 @@ async def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mo fake_wallet.hotkey.ss58_address = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 @@ -1120,9 +1022,9 @@ async def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mo "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -1163,15 +1065,14 @@ async def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mo call=call, keypair=fake_wallet.hotkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -1179,7 +1080,7 @@ async def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mo sign_with="hotkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -1197,9 +1098,7 @@ async def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mo ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 period = 64 @@ -1226,9 +1125,9 @@ async def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mo "create_signed_extrinsic", new=mocker.AsyncMock(return_value=mock_signed_extrinsic), ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.asyncex.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch( "bittensor.core.extrinsics.asyncex.mev_shield.MevShield" @@ -1269,15 +1168,14 @@ async def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mo call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era={"period": period}, + era={"period": MAX_MEV_SHIELD_PERIOD}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_awaited_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_awaited_once_with( @@ -1285,7 +1183,7 @@ async def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mo sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=period, + period=MAX_MEV_SHIELD_PERIOD, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, diff --git a/tests/unit_tests/extrinsics/asyncex/test_staking.py b/tests/unit_tests/extrinsics/asyncex/test_staking.py index 6d1571fcf7..a68e8a6362 100644 --- a/tests/unit_tests/extrinsics/asyncex/test_staking.py +++ b/tests/unit_tests/extrinsics/asyncex/test_staking.py @@ -1,7 +1,9 @@ import pytest from bittensor.core.extrinsics.asyncex import staking +from bittensor.core.settings import DEFAULT_MEV_PROTECTION from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance @pytest.mark.parametrize( @@ -53,3 +55,117 @@ async def test_set_auto_stake_extrinsic( assert success is res_success assert message == res_message + + +@pytest.mark.asyncio +async def test_subnet_buyback_extrinsic(fake_wallet, mocker): + """Verify that async `subnet_buyback_extrinsic` method calls proper methods.""" + # Preps + fake_substrate = mocker.AsyncMock(**{"get_chain_head.return_value": "0xhead"}) + fake_subtensor = mocker.AsyncMock( + **{ + "get_balance.return_value": Balance.from_tao(10), + "get_existential_deposit.return_value": Balance.from_tao(1), + "get_stake.return_value": Balance.from_tao(0), + "get_block_hash.return_value": "0xblock", + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + "substrate": fake_substrate, + } + ) + fake_wallet.coldkeypub.ss58_address = "coldkey" + hotkey_ss58 = "hotkey" + netuid = 1 + amount = Balance.from_tao(2) + + mocked_pallet_compose_call = mocker.AsyncMock() + mocker.patch.object( + staking.SubtensorModule, "add_stake_burn", new=mocked_pallet_compose_call + ) + fake_subtensor.sim_swap = mocker.AsyncMock( + return_value=mocker.Mock(tao_fee=Balance.from_rao(1), alpha_fee=mocker.Mock()) + ) + + # Call + result = await staking.add_stake_burn_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + mev_protection=DEFAULT_MEV_PROTECTION, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert result.success is True + mocked_pallet_compose_call.assert_awaited_once_with( + netuid=netuid, + hotkey=hotkey_ss58, + amount=amount.rao, + limit=None, + ) + fake_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_pallet_compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + nonce_key="coldkeypub", + use_nonce=True, + period=None, + raise_error=False, + ) + + +@pytest.mark.asyncio +async def test_subnet_buyback_extrinsic_with_limit(fake_wallet, mocker): + """Verify that async `subnet_buyback_extrinsic` passes limit price.""" + # Preps + fake_substrate = mocker.AsyncMock(**{"get_chain_head.return_value": "0xhead"}) + fake_subtensor = mocker.AsyncMock( + **{ + "get_balance.return_value": Balance.from_tao(10), + "get_existential_deposit.return_value": Balance.from_tao(1), + "get_stake.return_value": Balance.from_tao(0), + "get_block_hash.return_value": "0xblock", + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + "substrate": fake_substrate, + } + ) + fake_wallet.coldkeypub.ss58_address = "coldkey" + hotkey_ss58 = "hotkey" + netuid = 1 + amount = Balance.from_tao(2) + limit_price = Balance.from_tao(2) + + mocked_pallet_compose_call = mocker.AsyncMock() + mocker.patch.object( + staking.SubtensorModule, "add_stake_burn", new=mocked_pallet_compose_call + ) + fake_subtensor.sim_swap = mocker.AsyncMock( + return_value=mocker.Mock(tao_fee=Balance.from_rao(1), alpha_fee=mocker.Mock()) + ) + + # Call + result = await staking.add_stake_burn_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + limit_price=limit_price, + mev_protection=DEFAULT_MEV_PROTECTION, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert result.success is True + mocked_pallet_compose_call.assert_awaited_once_with( + netuid=netuid, + hotkey=hotkey_ss58, + amount=amount.rao, + limit=limit_price.rao, + ) diff --git a/tests/unit_tests/extrinsics/test_coldkey_swap.py b/tests/unit_tests/extrinsics/test_coldkey_swap.py new file mode 100644 index 0000000000..9ef59d549d --- /dev/null +++ b/tests/unit_tests/extrinsics/test_coldkey_swap.py @@ -0,0 +1,484 @@ +from bittensor_wallet import Wallet +from scalecodec.types import GenericCall + +from bittensor.core.chain_data.coldkey_swap import ColdkeySwapAnnouncementInfo +from bittensor.core.extrinsics import coldkey_swap +from bittensor.core.extrinsics import sudo as sudo_extrinsics +from bittensor.core.extrinsics.pallets import SubtensorModule +from bittensor.core.types import ExtrinsicResponse + + +def test_announce_coldkey_swap_extrinsic(subtensor, mocker): + """Verify that sync `announce_coldkey_swap_extrinsic` method calls proper methods.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_keypair = mocker.patch("bittensor.core.extrinsics.coldkey_swap.Keypair") + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_compute_hash = mocker.patch.object( + coldkey_swap, "compute_coldkey_hash", return_value="0x" + "00" * 32 + ) + mocked_subtensor_module = mocker.patch.object( + coldkey_swap, "SubtensorModule", return_value=mocker.MagicMock() + ) + mocked_pallet_instance = mocked_subtensor_module.return_value + mocked_pallet_instance.announce_coldkey_swap.return_value = mocker.MagicMock() + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = coldkey_swap.announce_coldkey_swap_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_keypair.assert_called_once_with(ss58_address=new_coldkey_ss58) + mocked_compute_hash.assert_called_once_with(mocked_keypair_instance) + mocked_subtensor_module.assert_called_once_with(subtensor) + mocked_pallet_instance.announce_coldkey_swap.assert_called_once_with( + new_coldkey_hash="0x" + "00" * 32 + ) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_pallet_instance.announce_coldkey_swap.return_value, + wallet=wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + assert response == mocked_sign_and_send_extrinsic.return_value + + +def test_announce_coldkey_swap_extrinsic_with_mev_protection(subtensor, mocker): + """Verify that sync `announce_coldkey_swap_extrinsic` uses MEV protection when enabled.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_keypair = mocker.patch("bittensor.core.extrinsics.coldkey_swap.Keypair") + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_compute_hash = mocker.patch.object( + coldkey_swap, "compute_coldkey_hash", return_value="0x" + "00" * 32 + ) + mocked_subtensor_module = mocker.patch.object( + coldkey_swap, "SubtensorModule", return_value=mocker.MagicMock() + ) + mocked_pallet_instance = mocked_subtensor_module.return_value + mocked_pallet_instance.announce_coldkey_swap.return_value = mocker.MagicMock() + mocked_submit_encrypted = mocker.patch.object( + coldkey_swap, + "submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = coldkey_swap.announce_coldkey_swap_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=True, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_subtensor_module.assert_called_once_with(subtensor) + mocked_pallet_instance.announce_coldkey_swap.assert_called_once_with( + new_coldkey_hash="0x" + "00" * 32 + ) + mocked_submit_encrypted.assert_called_once() + assert response == mocked_submit_encrypted.return_value + + +def test_swap_coldkey_announced_extrinsic_success(subtensor, mocker): + """Verify that sync `swap_coldkey_announced_extrinsic` method calls proper methods.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + fake_hash = "0x" + "00" * 32 + + announcement = ColdkeySwapAnnouncementInfo( + coldkey=wallet.coldkeypub.ss58_address, + execution_block=1000, + new_coldkey_hash=fake_hash, + ) + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=announcement + ) + mocked_get_current_block = mocker.patch.object( + subtensor, "get_current_block", return_value=1001 + ) + mocked_keypair = mocker.patch("bittensor.core.extrinsics.coldkey_swap.Keypair") + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_verify_hash = mocker.patch.object( + coldkey_swap, "verify_coldkey_hash", return_value=True + ) + mocked_subtensor_module = mocker.patch.object( + coldkey_swap, "SubtensorModule", return_value=mocker.MagicMock() + ) + mocked_pallet_instance = mocked_subtensor_module.return_value + mocked_pallet_instance.swap_coldkey_announced.return_value = mocker.MagicMock() + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_called_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + mocked_get_current_block.assert_called_once() + mocked_verify_hash.assert_called_once_with(mocked_keypair_instance, fake_hash) + mocked_subtensor_module.assert_called_once_with(subtensor) + mocked_pallet_instance.swap_coldkey_announced.assert_called_once_with( + new_coldkey=new_coldkey_ss58 + ) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_pallet_instance.swap_coldkey_announced.return_value, + wallet=wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + assert response == mocked_sign_and_send_extrinsic.return_value + + +def test_swap_coldkey_announced_extrinsic_no_announcement(subtensor, mocker): + """Verify that sync `swap_coldkey_announced_extrinsic` returns error when no announcement.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=None + ) + + # Call + response = coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + raise_error=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_called_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + assert response.success is False + assert "No coldkey swap announcement found" in response.message + + +def test_swap_coldkey_announced_extrinsic_hash_mismatch(subtensor, mocker): + """Verify that sync `swap_coldkey_announced_extrinsic` returns error when hash doesn't match.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + fake_hash = "0x" + "00" * 32 + + announcement = ColdkeySwapAnnouncementInfo( + coldkey=wallet.coldkeypub.ss58_address, + execution_block=1000, + new_coldkey_hash=fake_hash, + ) + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=announcement + ) + mocked_keypair = mocker.patch("bittensor.core.extrinsics.coldkey_swap.Keypair") + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_verify_hash = mocker.patch.object( + coldkey_swap, "verify_coldkey_hash", return_value=False + ) + mocked_compute_hash = mocker.patch.object( + coldkey_swap, "compute_coldkey_hash", return_value="0x" + "11" * 32 + ) + + # Call + response = coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + raise_error=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_called_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + mocked_verify_hash.assert_called_once_with(mocked_keypair_instance, fake_hash) + assert response.success is False + assert "hash does not match" in response.message.lower() + + +def test_swap_coldkey_announced_extrinsic_too_early(subtensor, mocker): + """Verify that sync `swap_coldkey_announced_extrinsic` returns error when too early.""" + # Preps + wallet = mocker.MagicMock(spec=Wallet) + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + fake_hash = "0x" + "00" * 32 + + announcement = ColdkeySwapAnnouncementInfo( + coldkey=wallet.coldkeypub.ss58_address, + execution_block=1000, + new_coldkey_hash=fake_hash, + ) + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_get_announcement = mocker.patch.object( + subtensor, "get_coldkey_swap_announcement", return_value=announcement + ) + mocked_get_current_block = mocker.patch.object( + subtensor, "get_current_block", return_value=999 + ) + mocked_keypair = mocker.patch("bittensor.core.extrinsics.coldkey_swap.Keypair") + mocked_keypair_instance = mocker.MagicMock() + mocked_keypair_instance.public_key = b"\x00" * 32 + mocked_keypair.return_value = mocked_keypair_instance + + mocked_verify_hash = mocker.patch.object( + coldkey_swap, "verify_coldkey_hash", return_value=True + ) + + # Call + response = coldkey_swap.swap_coldkey_announced_extrinsic( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + raise_error=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_get_announcement.assert_called_once_with( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + mocked_get_current_block.assert_called_once() + mocked_verify_hash.assert_called_once_with(mocked_keypair_instance, fake_hash) + assert response.success is False + assert "too early" in response.message.lower() + assert "999" in response.message + assert "1000" in response.message + + +def test_dispute_coldkey_swap_extrinsic(subtensor, mocker): + """Verify that sync dispute_coldkey_swap_extrinsic calls pallet and sign_and_send_extrinsic.""" + wallet = mocker.MagicMock(spec=Wallet) + + mocked_unlock_wallet = mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(success=True, message="Unlocked"), + ) + mocked_subtensor_module = mocker.patch.object( + coldkey_swap, "SubtensorModule", return_value=mocker.MagicMock() + ) + mocked_pallet_instance = mocked_subtensor_module.return_value + mocked_pallet_instance.dispute_coldkey_swap.return_value = mocker.MagicMock() + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = coldkey_swap.dispute_coldkey_swap_extrinsic( + subtensor=subtensor, + wallet=wallet, + mev_protection=False, + ) + + # Asserts + mocked_unlock_wallet.assert_called_once_with(wallet, False) + mocked_subtensor_module.assert_called_once_with(subtensor) + mocked_pallet_instance.dispute_coldkey_swap.assert_called_once_with() + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_pallet_instance.dispute_coldkey_swap.return_value, + wallet=wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + assert response == mocked_sign_and_send_extrinsic.return_value + + +def test_reset_coldkey_swap_extrinsic(subtensor, mocker): + """Verify that sync reset_coldkey_swap_extrinsic uses sudo_call_extrinsic.""" + wallet = mocker.MagicMock(spec=Wallet) + coldkey_ss58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + mocked_sudo_call_extrinsic = mocker.patch.object( + sudo_extrinsics, + "sudo_call_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = sudo_extrinsics.reset_coldkey_swap_extrinsic( + subtensor=subtensor, + wallet=wallet, + coldkey_ss58=coldkey_ss58, + ) + + # Asserts + mocked_sudo_call_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + call_module="SubtensorModule", + call_function="reset_coldkey_swap", + call_params={"coldkey": coldkey_ss58}, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_sudo_call_extrinsic.return_value + + +def test_swap_coldkey_extrinsic(subtensor, mocker): + """Verify that sync swap_coldkey_extrinsic uses sudo_call_extrinsic.""" + wallet = mocker.MagicMock(spec=Wallet) + old_coldkey_ss58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + new_coldkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + swap_cost = 1000 + mocked_sudo_call_extrinsic = mocker.patch.object( + sudo_extrinsics, + "sudo_call_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + response = sudo_extrinsics.swap_coldkey_extrinsic( + subtensor=subtensor, + wallet=wallet, + old_coldkey_ss58=old_coldkey_ss58, + new_coldkey_ss58=new_coldkey_ss58, + swap_cost=swap_cost, + ) + + # Asserts + mocked_sudo_call_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + call_module="SubtensorModule", + call_function="swap_coldkey", + call_params={ + "old_coldkey": old_coldkey_ss58, + "new_coldkey": new_coldkey_ss58, + "swap_cost": swap_cost, + }, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_sudo_call_extrinsic.return_value + + +def test_subtensor_module_dispute_reset_swap_coldkey_call_names(subtensor, mocker): + """Verify pallet call builders use correct runtime call names.""" + mocked_compose_call = mocker.patch.object( + subtensor, "compose_call", return_value=mocker.MagicMock(spec=GenericCall) + ) + pallet = SubtensorModule(subtensor) + + pallet.dispute_coldkey_swap() + mocked_compose_call.assert_called_with( + call_module="SubtensorModule", + call_function="dispute_coldkey_swap", + call_params={}, + ) + + mocked_compose_call.reset_mock() + pallet.reset_coldkey_swap( + coldkey="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + ) + mocked_compose_call.assert_called_with( + call_module="SubtensorModule", + call_function="reset_coldkey_swap", + call_params={"coldkey": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}, + ) + + mocked_compose_call.reset_mock() + pallet.swap_coldkey( + old_coldkey="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + new_coldkey="5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + swap_cost=0, + ) + mocked_compose_call.assert_called_with( + call_module="SubtensorModule", + call_function="swap_coldkey", + call_params={ + "old_coldkey": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "new_coldkey": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "swap_cost": 0, + }, + ) diff --git a/tests/unit_tests/extrinsics/test_mev_shield.py b/tests/unit_tests/extrinsics/test_mev_shield.py index 44b7cec7a9..2c0fba851b 100644 --- a/tests/unit_tests/extrinsics/test_mev_shield.py +++ b/tests/unit_tests/extrinsics/test_mev_shield.py @@ -1,8 +1,10 @@ from bittensor_wallet import Wallet from scalecodec.types import GenericCall from async_substrate_interface import ExtrinsicReceipt +from async_substrate_interface.errors import SubstrateRequestException from bittensor.core.extrinsics import mev_shield +from bittensor.core.settings import MAX_MEV_SHIELD_PERIOD from bittensor.core.types import ExtrinsicResponse @@ -10,7 +12,6 @@ def test_wait_for_extrinsic_by_hash_success(subtensor, mocker): """Verify that wait_for_extrinsic_by_hash finds the extrinsic by hash.""" # Preps extrinsic_hash = "0x1234567890abcdef" - shield_id = "shield_id_123" submit_block_hash = "0xblockhash" starting_block = 100 current_block = 100 @@ -41,67 +42,6 @@ def test_wait_for_extrinsic_by_hash_success(subtensor, mocker): result = mev_shield.wait_for_extrinsic_by_hash( subtensor=subtensor, extrinsic_hash=extrinsic_hash, - shield_id=shield_id, - submit_block_hash=submit_block_hash, - timeout_blocks=3, - ) - - # Asserts - mocked_get_block_number.assert_called_once_with(submit_block_hash) - mocked_wait_for_block.assert_called_once() - mocked_get_block_hash.assert_called_once_with(current_block) - mocked_get_extrinsics.assert_called_once_with("0xblockhash101") - mocked_extrinsic_receipt.assert_called_once_with( - substrate=subtensor.substrate, - block_hash="0xblockhash101", - block_number=current_block, - extrinsic_idx=0, - ) - assert result == mock_receipt - - -def test_wait_for_extrinsic_by_hash_decryption_failed(subtensor, mocker): - """Verify that wait_for_extrinsic_by_hash finds mark_decryption_failed extrinsic.""" - # Preps - extrinsic_hash = "0x1234567890abcdef" - shield_id = "shield_id_123" - submit_block_hash = "0xblockhash" - starting_block = 100 - current_block = 100 - - mocked_get_block_number = mocker.patch.object( - subtensor.substrate, "get_block_number", return_value=starting_block - ) - mocked_wait_for_block = mocker.patch.object(subtensor, "wait_for_block") - mocked_get_block_hash = mocker.patch.object( - subtensor.substrate, "get_block_hash", return_value="0xblockhash101" - ) - - mock_extrinsic = mocker.MagicMock() - mock_extrinsic.value = { - "call": { - "call_module": "MevShield", - "call_function": "mark_decryption_failed", - "call_args": [{"name": "id", "value": shield_id}], - } - } - mocked_get_extrinsics = mocker.patch.object( - subtensor.substrate, - "get_extrinsics", - return_value=[mock_extrinsic], - ) - - mock_receipt = mocker.MagicMock(spec=ExtrinsicReceipt) - mocked_extrinsic_receipt = mocker.patch( - "bittensor.core.extrinsics.mev_shield.ExtrinsicReceipt", - return_value=mock_receipt, - ) - - # Call - result = mev_shield.wait_for_extrinsic_by_hash( - subtensor=subtensor, - extrinsic_hash=extrinsic_hash, - shield_id=shield_id, submit_block_hash=submit_block_hash, timeout_blocks=3, ) @@ -124,7 +64,6 @@ def test_wait_for_extrinsic_by_hash_timeout(subtensor, mocker): """Verify that wait_for_extrinsic_by_hash returns None on timeout.""" # Preps extrinsic_hash = "0x1234567890abcdef" - shield_id = "shield_id_123" submit_block_hash = "0xblockhash" starting_block = 100 @@ -145,7 +84,6 @@ def test_wait_for_extrinsic_by_hash_timeout(subtensor, mocker): result = mev_shield.wait_for_extrinsic_by_hash( subtensor=subtensor, extrinsic_hash=extrinsic_hash, - shield_id=shield_id, submit_block_hash=submit_block_hash, timeout_blocks=3, ) @@ -169,14 +107,11 @@ def test_submit_encrypted_extrinsic_success_with_revealed_execution( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 # 1184 bytes - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 next_nonce = 6 - shield_id = "shield_id_123" block_hash = "0xblockhash" mocked_unlock_wallet = mocker.patch.object( @@ -199,9 +134,9 @@ def test_submit_encrypted_extrinsic_success_with_revealed_execution( "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -217,7 +152,7 @@ def test_submit_encrypted_extrinsic_success_with_revealed_execution( { "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, } ] @@ -232,7 +167,7 @@ def test_submit_encrypted_extrinsic_success_with_revealed_execution( return_value={ "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, }, ) @@ -262,15 +197,14 @@ def test_submit_encrypted_extrinsic_success_with_revealed_execution( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -278,7 +212,7 @@ def test_submit_encrypted_extrinsic_success_with_revealed_execution( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -290,16 +224,13 @@ def test_submit_encrypted_extrinsic_success_with_revealed_execution( mocked_wait_for_extrinsic.assert_called_once_with( subtensor=subtensor, extrinsic_hash=signed_extrinsic_hash, - shield_id=shield_id, submit_block_hash=block_hash, timeout_blocks=3, ) assert result == mock_response assert result.mev_extrinsic == mock_mev_extrinsic - assert result.data["commitment"] == mev_commitment assert result.data["ciphertext"] == mev_ciphertext assert result.data["ml_kem_768_public_key"] == ml_kem_768_public_key - assert result.data["payload_core"] == payload_core assert result.data["signed_extrinsic_hash"] == signed_extrinsic_hash @@ -314,9 +245,7 @@ def test_submit_encrypted_extrinsic_success_without_revealed_execution( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 @@ -342,9 +271,9 @@ def test_submit_encrypted_extrinsic_success_without_revealed_execution( "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -380,15 +309,14 @@ def test_submit_encrypted_extrinsic_success_without_revealed_execution( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -396,16 +324,14 @@ def test_submit_encrypted_extrinsic_success_without_revealed_execution( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, ) assert result == mock_response - assert result.data["commitment"] == mev_commitment assert result.data["ciphertext"] == mev_ciphertext assert result.data["ml_kem_768_public_key"] == ml_kem_768_public_key - assert result.data["payload_core"] == payload_core assert result.data["signed_extrinsic_hash"] == signed_extrinsic_hash @@ -517,9 +443,7 @@ def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 @@ -543,9 +467,9 @@ def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -587,15 +511,14 @@ def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -603,7 +526,7 @@ def test_submit_encrypted_extrinsic_encrypted_submitted_event_not_found( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -628,14 +551,11 @@ def test_submit_encrypted_extrinsic_failed_to_find_outcome( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 next_nonce = 6 - shield_id = "shield_id_123" block_hash = "0xblockhash" mocked_unlock_wallet = mocker.patch.object( @@ -658,9 +578,9 @@ def test_submit_encrypted_extrinsic_failed_to_find_outcome( "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -676,7 +596,7 @@ def test_submit_encrypted_extrinsic_failed_to_find_outcome( { "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, } ] @@ -685,7 +605,7 @@ def test_submit_encrypted_extrinsic_failed_to_find_outcome( return_value={ "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, }, ) mocked_wait_for_extrinsic = mocker.patch( @@ -717,15 +637,14 @@ def test_submit_encrypted_extrinsic_failed_to_find_outcome( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -733,7 +652,7 @@ def test_submit_encrypted_extrinsic_failed_to_find_outcome( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -745,7 +664,6 @@ def test_submit_encrypted_extrinsic_failed_to_find_outcome( mocked_wait_for_extrinsic.assert_called_once_with( subtensor=subtensor, extrinsic_hash=signed_extrinsic_hash, - shield_id=shield_id, submit_block_hash=block_hash, timeout_blocks=3, ) @@ -763,14 +681,11 @@ def test_submit_encrypted_extrinsic_execution_failure(subtensor, fake_wallet, mo ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" signed_extrinsic_hash_hex = "abcdef123456" signed_extrinsic_hash = f"0x{signed_extrinsic_hash_hex}" current_nonce = 5 next_nonce = 6 - shield_id = "shield_id_123" block_hash = "0xblockhash" error_message = "Execution failed" formatted_error = "Formatted error: Execution failed" @@ -795,9 +710,9 @@ def test_submit_encrypted_extrinsic_execution_failure(subtensor, fake_wallet, mo "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -813,7 +728,7 @@ def test_submit_encrypted_extrinsic_execution_failure(subtensor, fake_wallet, mo { "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, } ] @@ -829,7 +744,7 @@ def test_submit_encrypted_extrinsic_execution_failure(subtensor, fake_wallet, mo return_value={ "module_id": "mevShield", "event_id": "EncryptedSubmitted", - "attributes": {"id": shield_id}, + "attributes": {"id": "shield_id_123"}, }, ) mocked_format_error_message = mocker.patch( @@ -861,15 +776,14 @@ def test_submit_encrypted_extrinsic_execution_failure(subtensor, fake_wallet, mo call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -877,7 +791,7 @@ def test_submit_encrypted_extrinsic_execution_failure(subtensor, fake_wallet, mo sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -889,13 +803,12 @@ def test_submit_encrypted_extrinsic_execution_failure(subtensor, fake_wallet, mo mocked_wait_for_extrinsic.assert_called_once_with( subtensor=subtensor, extrinsic_hash=signed_extrinsic_hash, - shield_id=shield_id, submit_block_hash=block_hash, timeout_blocks=3, ) mocked_format_error_message.assert_called_once_with(error_message) assert result.success is False - assert isinstance(result.error, RuntimeError) + assert isinstance(result.error, SubstrateRequestException) assert result.message == formatted_error @@ -910,9 +823,7 @@ def test_submit_encrypted_extrinsic_sign_and_send_failure( ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 @@ -936,9 +847,9 @@ def test_submit_encrypted_extrinsic_sign_and_send_failure( "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -973,15 +884,14 @@ def test_submit_encrypted_extrinsic_sign_and_send_failure( call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -989,7 +899,7 @@ def test_submit_encrypted_extrinsic_sign_and_send_failure( sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -1006,9 +916,7 @@ def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mocker): fake_wallet.hotkey.ss58_address = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 @@ -1032,9 +940,9 @@ def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mocker): "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -1070,15 +978,14 @@ def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mocker): call=call, keypair=fake_wallet.hotkey, nonce=next_nonce, - era="00", + era={"period": 8}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -1086,7 +993,7 @@ def test_submit_encrypted_extrinsic_with_hotkey(subtensor, fake_wallet, mocker): sign_with="hotkey", call=mock_extrinsic_call, nonce=current_nonce, - period=None, + period=8, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, @@ -1103,9 +1010,7 @@ def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mocker): ) ml_kem_768_public_key = b"fake_ml_kem_key" * 74 - mev_commitment = "0xcommitment" mev_ciphertext = b"fake_ciphertext" - payload_core = b"fake_payload" current_nonce = 5 next_nonce = 6 period = 64 @@ -1130,9 +1035,9 @@ def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mocker): "create_signed_extrinsic", return_value=mock_signed_extrinsic, ) - mocked_get_mev_commitment = mocker.patch( - "bittensor.core.extrinsics.mev_shield.get_mev_commitment_and_ciphertext", - return_value=(mev_commitment, mev_ciphertext, payload_core), + mocked_get_mev_shielded_ciphertext = mocker.patch( + "bittensor.core.extrinsics.mev_shield.get_mev_shielded_ciphertext", + return_value=mev_ciphertext, ) mocked_mev_shield = mocker.patch("bittensor.core.extrinsics.mev_shield.MevShield") mock_mev_shield_instance = mocker.MagicMock() @@ -1168,15 +1073,14 @@ def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mocker): call=call, keypair=fake_wallet.coldkey, nonce=next_nonce, - era={"period": period}, + era={"period": MAX_MEV_SHIELD_PERIOD}, ) - mocked_get_mev_commitment.assert_called_once_with( + mocked_get_mev_shielded_ciphertext.assert_called_once_with( signed_ext=mock_signed_extrinsic, ml_kem_768_public_key=ml_kem_768_public_key, ) mocked_mev_shield.assert_called_once_with(subtensor) mock_mev_shield_instance.submit_encrypted.assert_called_once_with( - commitment=mev_commitment, ciphertext=mev_ciphertext, ) mocked_sign_and_send_extrinsic.assert_called_once_with( @@ -1184,7 +1088,7 @@ def test_submit_encrypted_extrinsic_with_period(subtensor, fake_wallet, mocker): sign_with="coldkey", call=mock_extrinsic_call, nonce=current_nonce, - period=period, + period=MAX_MEV_SHIELD_PERIOD, raise_error=False, wait_for_inclusion=True, wait_for_finalization=False, diff --git a/tests/unit_tests/extrinsics/test_staking.py b/tests/unit_tests/extrinsics/test_staking.py index 77ced51faf..6ac282605b 100644 --- a/tests/unit_tests/extrinsics/test_staking.py +++ b/tests/unit_tests/extrinsics/test_staking.py @@ -2,8 +2,8 @@ from bittensor.core.extrinsics import staking from bittensor.core.settings import DEFAULT_MEV_PROTECTION -from bittensor.utils.balance import Balance from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance def test_add_stake_extrinsic(mocker): @@ -65,6 +65,114 @@ def test_add_stake_extrinsic(mocker): ) +def test_add_stake_burn_extrinsic(mocker): + """Verify that sync `add_stake_burn_extrinsic` method calls proper methods.""" + # Preps + fake_subtensor = mocker.Mock( + **{ + "get_balance.return_value": Balance.from_tao(10), + "get_existential_deposit.return_value": Balance.from_tao(1), + "get_current_block.return_value": 123, + "get_stake.return_value": Balance.from_tao(0), + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock( + **{ + "coldkeypub.ss58_address": "coldkey", + } + ) + hotkey_ss58 = "hotkey" + netuid = 1 + amount = Balance.from_tao(2) + + # Call + result = staking.add_stake_burn_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + mev_protection=DEFAULT_MEV_PROTECTION, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert result.success is True + fake_subtensor.compose_call.assert_called_once_with( + call_module="SubtensorModule", + call_function="add_stake_burn", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount": amount.rao, + "limit": None, + }, + ) + fake_subtensor.sign_and_send_extrinsic.assert_called_once_with( + call=fake_subtensor.compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + nonce_key="coldkeypub", + use_nonce=True, + period=None, + raise_error=False, + ) + + +def test_add_stake_burn_extrinsic_with_limit(mocker): + """Verify that sync `add_stake_burn_extrinsic` passes limit price.""" + # Preps + fake_subtensor = mocker.Mock( + **{ + "get_balance.return_value": Balance.from_tao(10), + "get_existential_deposit.return_value": Balance.from_tao(1), + "get_current_block.return_value": 123, + "get_stake.return_value": Balance.from_tao(0), + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock( + **{ + "coldkeypub.ss58_address": "coldkey", + } + ) + hotkey_ss58 = "hotkey" + netuid = 1 + amount = Balance.from_tao(2) + limit_price = Balance.from_tao(2) + + # Call + result = staking.add_stake_burn_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + limit_price=limit_price, + mev_protection=DEFAULT_MEV_PROTECTION, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert result.success is True + fake_subtensor.compose_call.assert_called_once_with( + call_module="SubtensorModule", + call_function="add_stake_burn", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount": amount.rao, + "limit": limit_price.rao, + }, + ) + + def test_add_stake_multiple_extrinsic(subtensor, mocker, fake_wallet): """Verify that sync `add_stake_multiple_extrinsic` method calls proper async method.""" # Preps @@ -73,9 +181,8 @@ def test_add_stake_multiple_extrinsic(subtensor, mocker, fake_wallet): "get_stake_info_for_coldkey", return_value=[Balance(1.1), Balance(0.3)], ) - mocked_get_balance = mocker.patch.object( - subtensor, "get_balance", return_value=Balance.from_tao(10) - ) + mocker.patch.object(subtensor, "get_stake", return_value=Balance.from_tao(2.2)) + mocker.patch.object(subtensor, "get_balance", return_value=Balance.from_tao(10)) mocker.patch.object( staking, "get_old_stakes", return_value=[Balance(1.1), Balance(0.3)] ) diff --git a/tests/unit_tests/extrinsics/test_unstaking.py b/tests/unit_tests/extrinsics/test_unstaking.py index f5c6f578a9..710d052062 100644 --- a/tests/unit_tests/extrinsics/test_unstaking.py +++ b/tests/unit_tests/extrinsics/test_unstaking.py @@ -119,7 +119,8 @@ def test_unstake_multiple_extrinsic(subtensor, fake_wallet, mocker): mocked_balance = mocker.patch.object( subtensor, "get_balance", return_value=Balance.from_tao(1.0) ) - mocked_get_stake_for_coldkey_and_hotkey = mocker.patch.object( + mocker.patch.object(subtensor, "get_stake", return_value=Balance.from_tao(8.9)) + mocker.patch.object( subtensor, "get_stake_for_coldkey_and_hotkey", return_value=[Balance(10.0)] ) mocked_unstake_extrinsic = mocker.patch.object( diff --git a/tests/unit_tests/extrinsics/test_utils.py b/tests/unit_tests/extrinsics/test_utils.py index d77c3cc5e7..6b9d30900c 100644 --- a/tests/unit_tests/extrinsics/test_utils.py +++ b/tests/unit_tests/extrinsics/test_utils.py @@ -1,6 +1,7 @@ from bittensor.core.chain_data import StakeInfo from bittensor.core.extrinsics import utils from bittensor.utils.balance import Balance +from bittensor_wallet import Keypair def test_old_stake(subtensor, mocker): @@ -29,3 +30,61 @@ def test_old_stake(subtensor, mocker): result = utils.get_old_stakes(wallet, hotkey_ss58s, netuids, all_stakes) assert result == [expected_stake, Balance.from_tao(0)] + + +def test_compute_coldkey_hash(): + """Test compute_coldkey_hash computes correct BlakeTwo256 hash.""" + # Prep + keypair = Keypair(ss58_address="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + expected_hash_length = 66 # 0x + 64 hex chars + + # Call + result = utils.compute_coldkey_hash(keypair) + + # Asserts + assert result.startswith("0x") + assert len(result) == expected_hash_length + assert all(c in "0123456789abcdef" for c in result[2:].lower()) + + +def test_verify_coldkey_hash_match(): + """Test verify_coldkey_hash returns True when hash matches.""" + # Prep + keypair = Keypair(ss58_address="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + expected_hash = utils.compute_coldkey_hash(keypair) + + # Call + result = utils.verify_coldkey_hash(keypair, expected_hash) + + # Asserts + assert result is True + + +def test_verify_coldkey_hash_mismatch(): + """Test verify_coldkey_hash returns False when hash doesn't match.""" + # Prep + keypair = Keypair(ss58_address="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + wrong_hash = "0x" + "00" * 32 + + # Call + result = utils.verify_coldkey_hash(keypair, wrong_hash) + + # Asserts + assert result is False + + +def test_verify_coldkey_hash_case_insensitive(): + """Test verify_coldkey_hash is case insensitive.""" + # Prep + keypair = Keypair(ss58_address="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + expected_hash = utils.compute_coldkey_hash(keypair) + upper_hash = expected_hash.upper() + lower_hash = expected_hash.lower() + + # Call + result_upper = utils.verify_coldkey_hash(keypair, upper_hash) + result_lower = utils.verify_coldkey_hash(keypair, lower_hash) + + # Asserts + assert result_upper is True + assert result_lower is True diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index a4b3f18c16..072be4b650 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -2920,6 +2920,71 @@ async def test_start_call(subtensor, mocker): assert result == mocked_extrinsic.return_value +@pytest.mark.asyncio +async def test_add_stake_burn(subtensor, mocker): + """Test add_stake_burn extrinsic calls properly.""" + # Preps + wallet_name = mocker.Mock(spec=Wallet) + netuid = 123 + hotkey_ss58 = "hotkey" + amount = Balance.from_tao(1.0) + mocked_extrinsic = mocker.patch.object(async_subtensor, "add_stake_burn_extrinsic") + + # Call + result = await subtensor.add_stake_burn(wallet_name, netuid, hotkey_ss58, amount) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet_name, + netuid=netuid, + hotkey_ss58=hotkey_ss58, + amount=amount, + limit_price=None, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_add_stake_burn_with_limit_price(subtensor, mocker): + """Test add_stake_burn extrinsic passes limit price.""" + # Preps + wallet_name = mocker.Mock(spec=Wallet) + netuid = 123 + hotkey_ss58 = "hotkey" + amount = Balance.from_tao(1.0) + limit_price = Balance.from_tao(2.0) + mocked_extrinsic = mocker.patch.object(async_subtensor, "add_stake_burn_extrinsic") + + # Call + result = await subtensor.add_stake_burn( + wallet_name, netuid, hotkey_ss58, amount, limit_price=limit_price + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet_name, + netuid=netuid, + hotkey_ss58=hotkey_ss58, + amount=amount, + limit_price=limit_price, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + @pytest.mark.asyncio async def test_get_metagraph_info_all_fields(subtensor, mocker): """Test get_metagraph_info with all fields (default behavior).""" @@ -3805,6 +3870,7 @@ async def fake_current_sqrt_prices(): block_hash=mocked_determine_block_hash.return_value, page_size=129, # total number of subnets ) + assert result == expected_prices @@ -6017,231 +6083,6 @@ async def test_get_mev_shield_next_key_invalid_size(subtensor, mocker): ) -@pytest.mark.asyncio -async def test_get_mev_shield_submission_success(subtensor, mocker): - """Test get_mev_shield_submission returns correct submission when found.""" - # Prep - fake_submission_id = "0x1234567890abcdef" - fake_block = 123 - fake_block_hash = "0x123abc" - fake_author = b"\x01" * 32 - fake_commitment = b"\x02" * 32 - fake_ciphertext = b"\x03" * 100 - fake_submitted_in = 100 - - fake_query_result = { - "author": [fake_author], - "commitment": [fake_commitment], - "ciphertext": [fake_ciphertext], - "submitted_in": fake_submitted_in, - } - - mocked_determine_block_hash = mocker.AsyncMock(return_value=fake_block_hash) - mocker.patch.object(subtensor, "determine_block_hash", mocked_determine_block_hash) - mocked_query = mocker.AsyncMock(return_value=fake_query_result) - subtensor.substrate.query = mocked_query - mocked_decode_account_id = mocker.patch.object( - async_subtensor, - "decode_account_id", - return_value="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - ) - - # Call - result = await subtensor.get_mev_shield_submission( - submission_id=fake_submission_id, block=fake_block - ) - - # Asserts - mocked_determine_block_hash.assert_awaited_once_with(fake_block, None, False) - mocked_query.assert_awaited_once_with( - module="MevShield", - storage_function="Submissions", - params=[bytes.fromhex("1234567890abcdef")], - block_hash=fake_block_hash, - ) - mocked_decode_account_id.assert_called_once_with([fake_author]) - assert result == { - "author": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - "commitment": fake_commitment, - "ciphertext": fake_ciphertext, - "submitted_in": fake_submitted_in, - } - - -@pytest.mark.asyncio -async def test_get_mev_shield_submission_without_0x_prefix(subtensor, mocker): - """Test get_mev_shield_submission handles submission_id without 0x prefix.""" - # Prep - fake_submission_id = "1234567890abcdef" - fake_block = 123 - fake_block_hash = "0x123abc" - fake_query_result = { - "author": [b"\x01" * 32], - "commitment": [b"\x02" * 32], - "ciphertext": [b"\x03" * 100], - "submitted_in": 100, - } - - mocked_determine_block_hash = mocker.AsyncMock(return_value=fake_block_hash) - mocker.patch.object(subtensor, "determine_block_hash", mocked_determine_block_hash) - mocked_query = mocker.AsyncMock(return_value=fake_query_result) - subtensor.substrate.query = mocked_query - mocked_decode_account_id = mocker.patch.object( - async_subtensor, - "decode_account_id", - return_value="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - ) - - # Call - result = await subtensor.get_mev_shield_submission( - submission_id=fake_submission_id, block=fake_block - ) - - # Asserts - mocked_determine_block_hash.assert_awaited_once_with(fake_block, None, False) - mocked_query.assert_awaited_once_with( - module="MevShield", - storage_function="Submissions", - params=[bytes.fromhex("1234567890abcdef")], - block_hash=fake_block_hash, - ) - mocked_decode_account_id.assert_called_once_with([b"\x01" * 32]) - assert result is not None - - -@pytest.mark.asyncio -async def test_get_mev_shield_submission_none(subtensor, mocker): - """Test get_mev_shield_submission returns None when submission not found.""" - # Prep - fake_submission_id = "0x1234567890abcdef" - fake_block = 123 - fake_block_hash = "0x123abc" - - mocked_determine_block_hash = mocker.AsyncMock(return_value=fake_block_hash) - mocker.patch.object(subtensor, "determine_block_hash", mocked_determine_block_hash) - mocked_query = mocker.AsyncMock(return_value=None) - subtensor.substrate.query = mocked_query - - # Call - result = await subtensor.get_mev_shield_submission( - submission_id=fake_submission_id, block=fake_block - ) - - # Asserts - mocked_determine_block_hash.assert_awaited_once_with(fake_block, None, False) - mocked_query.assert_awaited_once_with( - module="MevShield", - storage_function="Submissions", - params=[bytes.fromhex("1234567890abcdef")], - block_hash=fake_block_hash, - ) - assert result is None - - -@pytest.mark.asyncio -async def test_get_mev_shield_submissions_success(subtensor, mocker): - """Test get_mev_shield_submissions returns all submissions when found.""" - # Prep - fake_block = 123 - fake_block_hash = "0x123abc" - fake_submission_id_1 = b"\x01" * 32 - fake_submission_id_2 = b"\x02" * 32 - fake_author_1 = b"\x03" * 32 - fake_author_2 = b"\x04" * 32 - fake_commitment_1 = b"\x05" * 32 - fake_commitment_2 = b"\x06" * 32 - fake_ciphertext_1 = b"\x07" * 100 - fake_ciphertext_2 = b"\x08" * 100 - - fake_query_result = mocker.AsyncMock() - fake_query_result.__aiter__.return_value = iter( - [ - ( - [fake_submission_id_1], - mocker.MagicMock( - value={ - "author": [fake_author_1], - "commitment": [fake_commitment_1], - "ciphertext": [fake_ciphertext_1], - "submitted_in": 100, - } - ), - ), - ( - [fake_submission_id_2], - mocker.MagicMock( - value={ - "author": [fake_author_2], - "commitment": [fake_commitment_2], - "ciphertext": [fake_ciphertext_2], - "submitted_in": 101, - } - ), - ), - ] - ) - - mocked_determine_block_hash = mocker.AsyncMock(return_value=fake_block_hash) - mocker.patch.object(subtensor, "determine_block_hash", mocked_determine_block_hash) - mocked_query_map = mocker.AsyncMock(return_value=fake_query_result) - subtensor.substrate.query_map = mocked_query_map - mocked_decode_account_id = mocker.patch.object( - async_subtensor, - "decode_account_id", - side_effect=[ - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", - ], - ) - - # Call - result = await subtensor.get_mev_shield_submissions(block=fake_block) - - # Asserts - mocked_determine_block_hash.assert_awaited_once_with(fake_block, None, False) - mocked_query_map.assert_awaited_once_with( - module="MevShield", - storage_function="Submissions", - block_hash=fake_block_hash, - ) - assert result is not None - assert len(result) == 2 - assert "0x" + fake_submission_id_1.hex() in result - assert "0x" + fake_submission_id_2.hex() in result - assert result["0x" + fake_submission_id_1.hex()]["submitted_in"] == 100 - assert result["0x" + fake_submission_id_2.hex()]["submitted_in"] == 101 - # Verify decode_account_id was called for both submissions - assert mocked_decode_account_id.call_count == 2 - - -@pytest.mark.asyncio -async def test_get_mev_shield_submissions_none(subtensor, mocker): - """Test get_mev_shield_submissions returns None when no submissions found.""" - # Prep - fake_block = 123 - fake_block_hash = "0x123abc" - - fake_query_result = mocker.AsyncMock() - fake_query_result.__aiter__.return_value = iter([]) - - mocked_determine_block_hash = mocker.AsyncMock(return_value=fake_block_hash) - mocker.patch.object(subtensor, "determine_block_hash", mocked_determine_block_hash) - mocked_query_map = mocker.AsyncMock(return_value=fake_query_result) - subtensor.substrate.query_map = mocked_query_map - - # Call - result = await subtensor.get_mev_shield_submissions(block=fake_block) - - # Asserts - mocked_determine_block_hash.assert_awaited_once_with(fake_block, None, False) - mocked_query_map.assert_awaited_once_with( - module="MevShield", - storage_function="Submissions", - block_hash=fake_block_hash, - ) - assert result is None - - @pytest.mark.asyncio async def test_mev_submit_encrypted_success(subtensor, fake_wallet, mocker): """Test mev_submit_encrypted calls submit_encrypted_extrinsic correctly.""" @@ -6358,3 +6199,291 @@ async def test_get_start_call_delay(subtensor, mocker): reuse_block=False, ) assert result == mocked_query_subtensor.return_value + + +@pytest.mark.asyncio +async def test_get_coldkey_swap_announcement(subtensor, mocker): + """Test get_coldkey_swap_announcement returns correct data when announcement information is found.""" + # Prep + fake_coldkey_ss58 = mocker.Mock(spec=str) + + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + mocked_from_query = mocker.patch.object( + async_subtensor.ColdkeySwapAnnouncementInfo, "from_query" + ) + + # Call + result = await subtensor.get_coldkey_swap_announcement( + coldkey_ss58=fake_coldkey_ss58 + ) + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query.assert_awaited_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + params=[fake_coldkey_ss58], + block_hash=mocked_determine_block_hash.return_value, + reuse_block_hash=False, + ) + mocked_from_query.assert_called_once_with( + coldkey_ss58=fake_coldkey_ss58, query=mocked_query.return_value + ) + assert result == mocked_from_query.return_value + + +@pytest.mark.asyncio +async def test_get_coldkey_swap_announcements(subtensor, mocker): + """Test get_coldkey_swap_announcements returns correct data when announcement information is found.""" + # Prep + fake_record = mocker.Mock() + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + fake_query_result = mocker.AsyncMock() + fake_query_result.__aiter__.return_value = iter((fake_record,)) + mocked_query_map = mocker.patch.object( + subtensor.substrate, "query_map", return_value=fake_query_result + ) + mocked_from_record = mocker.patch.object( + async_subtensor.ColdkeySwapAnnouncementInfo, "from_record" + ) + + # Call + result = await subtensor.get_coldkey_swap_announcements() + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query_map.assert_awaited_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + block_hash=mocked_determine_block_hash.return_value, + reuse_block_hash=False, + ) + mocked_from_record.assert_called_once_with(fake_record) + assert result == [mocked_from_record.return_value] + + +@pytest.mark.asyncio +async def test_get_coldkey_swap_announcement_delay(subtensor, mocker): + """Test get_coldkey_swap_announcement_delay returns correct value when found.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = await subtensor.get_coldkey_swap_announcement_delay() + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query.assert_awaited_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncementDelay", + block_hash=mocked_determine_block_hash.return_value, + reuse_block_hash=False, + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_coldkey_swap_reannouncement_delay(subtensor, mocker): + """Test get_coldkey_swap_reannouncement_delay returns correct value when found.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = await subtensor.get_coldkey_swap_reannouncement_delay() + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query.assert_awaited_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapReannouncementDelay", + block_hash=mocked_determine_block_hash.return_value, + reuse_block_hash=False, + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_coldkey_swap_constants(subtensor, mocker): + """Test get_coldkey_swap_constants returns correct data when constants are found.""" + # Prep + fake_name = mocker.Mock(spec=str) + fake_value = mocker.Mock(value=mocker.Mock(spec=int)) + mocked_constants_names = mocker.patch.object( + async_subtensor.ColdkeySwapConstants, + "constants_names", + return_value=[fake_name], + ) + mocked_query_constant = mocker.patch.object( + subtensor, + "query_constant", + side_effect=[fake_value], + ) + mocked_from_dict = mocker.patch.object( + async_subtensor.ColdkeySwapConstants, "from_dict" + ) + + # Call + result = await subtensor.get_coldkey_swap_constants() + + # Asserts + mocked_constants_names.assert_called_once_with() + mocked_query_constant.assert_awaited_once_with( + module_name="SubtensorModule", + constant_name=fake_name, + block=None, + block_hash=None, + reuse_block=False, + ) + mocked_from_dict.assert_called_once_with({fake_name: fake_value.value}) + assert result == mocked_from_dict.return_value + + +@pytest.mark.asyncio +async def test_announce_coldkey_swap(mocker, subtensor): + """Tests `announce_coldkey_swap` extrinsic call method.""" + # preps + wallet = mocker.Mock(spec=Wallet) + new_coldkey_ss58 = mocker.Mock(spec=str) + mocked_announce_coldkey_swap_extrinsic = mocker.patch.object( + async_subtensor, "announce_coldkey_swap_extrinsic" + ) + + # call + response = await subtensor.announce_coldkey_swap( + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + ) + + # asserts + mocked_announce_coldkey_swap_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_announce_coldkey_swap_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_swap_coldkey_announced(mocker, subtensor): + """Tests `swap_coldkey_announced` extrinsic call method.""" + # preps + wallet = mocker.Mock(spec=Wallet) + new_coldkey_ss58 = mocker.Mock(spec=str) + mocked_swap_coldkey_announced_extrinsic = mocker.patch.object( + async_subtensor, "swap_coldkey_announced_extrinsic" + ) + + # call + response = await subtensor.swap_coldkey_announced( + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + ) + + # asserts + mocked_swap_coldkey_announced_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_swap_coldkey_announced_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_get_coldkey_swap_dispute(subtensor, mocker): + """Test get_coldkey_swap_dispute returns correct data when dispute information is found.""" + # prep + fake_coldkey_ss58 = mocker.Mock(spec=str) + + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + mocked_from_query = mocker.patch.object( + async_subtensor.ColdkeySwapDisputeInfo, "from_query" + ) + + # call + result = await subtensor.get_coldkey_swap_dispute(coldkey_ss58=fake_coldkey_ss58) + + # asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query.assert_awaited_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + params=[fake_coldkey_ss58], + block_hash=mocked_determine_block_hash.return_value, + reuse_block_hash=False, + ) + mocked_from_query.assert_called_once_with( + coldkey_ss58=fake_coldkey_ss58, query=mocked_query.return_value + ) + assert result == mocked_from_query.return_value + + +@pytest.mark.asyncio +async def test_get_coldkey_swap_disputes(subtensor, mocker): + """Test get_coldkey_swap_disputes returns correct data when dispute information is found.""" + # prep + fake_record = mocker.Mock() + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + fake_query_result = mocker.AsyncMock() + fake_query_result.__aiter__.return_value = iter((fake_record,)) + mocked_query_map = mocker.patch.object( + subtensor.substrate, "query_map", return_value=fake_query_result + ) + mocked_from_record = mocker.patch.object( + async_subtensor.ColdkeySwapDisputeInfo, "from_record" + ) + + # call + result = await subtensor.get_coldkey_swap_disputes() + + # asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_query_map.assert_awaited_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + block_hash=mocked_determine_block_hash.return_value, + reuse_block_hash=False, + ) + mocked_from_record.assert_called_once_with(fake_record) + assert result == [mocked_from_record.return_value] + + +@pytest.mark.asyncio +async def test_dispute_coldkey_swap(mocker, subtensor): + """Tests `dispute_coldkey_swap` extrinsic call method.""" + # prep + wallet = mocker.Mock(spec=Wallet) + mocked_dispute_coldkey_swap_extrinsic = mocker.patch.object( + async_subtensor, "dispute_coldkey_swap_extrinsic" + ) + + # call + response = await subtensor.dispute_coldkey_swap(wallet=wallet) + + # asserts + mocked_dispute_coldkey_swap_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_dispute_coldkey_swap_extrinsic.return_value diff --git a/tests/unit_tests/test_errors.py b/tests/unit_tests/test_errors.py index 8a556b22e4..8b09b892e0 100644 --- a/tests/unit_tests/test_errors.py +++ b/tests/unit_tests/test_errors.py @@ -1,6 +1,7 @@ from bittensor.core.errors import ( ChainError, HotKeyAccountNotExists, + map_shield_error, ) @@ -47,3 +48,29 @@ class NewException(ChainError): exception = ChainError.from_error(error) assert isinstance(exception, NewException) + + +class TestMapShieldError: + def test_custom_error_23_maps_to_parsing_failure(self): + msg = "Subtensor returned `SubstrateRequestException(Verification Error)` error. This means: `Custom error: 23 | Please consult docs`." + result = map_shield_error(msg) + assert ( + result + == "Failed to parse shielded transaction: the ciphertext has an invalid format." + ) + + def test_custom_error_24_maps_to_invalid_key_hash(self): + msg = "Subtensor returned `SubstrateRequestException(Verification Error)` error. This means: `Custom error: 24 | Please consult docs`." + result = map_shield_error(msg) + assert "key_hash" in result + assert "does not match any known key" in result + + def test_generic_invalid_status_maps_to_catchall(self): + msg = "Subtensor returned: Subscription abc123 invalid: {'jsonrpc': '2.0', 'params': {'result': 'invalid'}}" + result = map_shield_error(msg) + assert "MEV Shield extrinsic rejected as invalid" in result + + def test_unrelated_error_returned_unchanged(self): + msg = "Something completely unrelated went wrong" + result = map_shield_error(msg) + assert result == msg diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 0c5584c7f2..0a55814fd7 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -3182,6 +3182,76 @@ def test_start_call(subtensor, mocker): assert result == mocked_extrinsic.return_value +def test_add_stake_burn(subtensor, fake_wallet, mocker): + """Test add_stake_burn extrinsic calls properly.""" + # Preps + netuid = 123 + hotkey_ss58 = "hotkey" + amount = Balance.from_tao(1.0) + mocked_extrinsic = mocker.patch.object(subtensor_module, "add_stake_burn_extrinsic") + + # Call + result = subtensor.add_stake_burn( + wallet=fake_wallet, + netuid=netuid, + hotkey_ss58=hotkey_ss58, + amount=amount, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + hotkey_ss58=hotkey_ss58, + amount=amount, + limit_price=None, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +def test_add_stake_burn_with_limit_price(subtensor, fake_wallet, mocker): + """Test add_stake_burn extrinsic passes limit price.""" + # Preps + netuid = 123 + hotkey_ss58 = "hotkey" + amount = Balance.from_tao(1.0) + limit_price = Balance.from_tao(2.0) + mocked_extrinsic = mocker.patch.object(subtensor_module, "add_stake_burn_extrinsic") + + # Call + result = subtensor.add_stake_burn( + wallet=fake_wallet, + netuid=netuid, + hotkey_ss58=hotkey_ss58, + amount=amount, + limit_price=limit_price, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + hotkey_ss58=hotkey_ss58, + amount=amount, + limit_price=limit_price, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + def test_get_metagraph_info_all_fields(subtensor, mocker): """Test get_metagraph_info with all fields (default behavior).""" # Preps @@ -6133,234 +6203,6 @@ def test_get_mev_shield_next_key_invalid_size(subtensor, mocker): ) -def test_get_mev_shield_submission_success(subtensor, mocker): - """Test get_mev_shield_submission returns correct submission when found.""" - # Prep - fake_submission_id = "0x1234567890abcdef" - fake_block = 123 - fake_block_hash = "0x123abc" - fake_author = b"\x01" * 32 - fake_commitment = b"\x02" * 32 - fake_ciphertext = b"\x03" * 100 - fake_submitted_in = 100 - - fake_query_result = { - "author": [fake_author], - "commitment": [fake_commitment], - "ciphertext": [fake_ciphertext], - "submitted_in": fake_submitted_in, - } - - mocked_determine_block_hash = mocker.patch.object( - subtensor, "determine_block_hash", return_value=fake_block_hash - ) - mocked_query = mocker.patch.object( - subtensor.substrate, "query", return_value=fake_query_result - ) - mocked_decode_account_id = mocker.patch.object( - subtensor_module, - "decode_account_id", - return_value="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - ) - - # Call - result = subtensor.get_mev_shield_submission( - submission_id=fake_submission_id, block=fake_block - ) - - # Asserts - mocked_determine_block_hash.assert_called_once_with(block=fake_block) - mocked_query.assert_called_once_with( - module="MevShield", - storage_function="Submissions", - params=[bytes.fromhex("1234567890abcdef")], - block_hash=fake_block_hash, - ) - mocked_decode_account_id.assert_called_once_with([fake_author]) - assert result == { - "author": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - "commitment": fake_commitment, - "ciphertext": fake_ciphertext, - "submitted_in": fake_submitted_in, - } - - -def test_get_mev_shield_submission_without_0x_prefix(subtensor, mocker): - """Test get_mev_shield_submission handles submission_id without 0x prefix.""" - # Prep - fake_submission_id = "1234567890abcdef" - fake_block = 123 - fake_block_hash = "0x123abc" - fake_query_result = { - "author": [b"\x01" * 32], - "commitment": [b"\x02" * 32], - "ciphertext": [b"\x03" * 100], - "submitted_in": 100, - } - - mocked_determine_block_hash = mocker.patch.object( - subtensor, "determine_block_hash", return_value=fake_block_hash - ) - mocked_query = mocker.patch.object( - subtensor.substrate, "query", return_value=fake_query_result - ) - mocked_decode_account_id = mocker.patch.object( - subtensor_module, - "decode_account_id", - return_value="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - ) - - # Call - result = subtensor.get_mev_shield_submission( - submission_id=fake_submission_id, block=fake_block - ) - - # Asserts - mocked_determine_block_hash.assert_called_once_with(block=fake_block) - mocked_query.assert_called_once_with( - module="MevShield", - storage_function="Submissions", - params=[bytes.fromhex("1234567890abcdef")], - block_hash=fake_block_hash, - ) - mocked_decode_account_id.assert_called_once_with([b"\x01" * 32]) - assert result is not None - - -def test_get_mev_shield_submission_none(subtensor, mocker): - """Test get_mev_shield_submission returns None when submission not found.""" - # Prep - fake_submission_id = "0x1234567890abcdef" - fake_block = 123 - fake_block_hash = "0x123abc" - - mocked_determine_block_hash = mocker.patch.object( - subtensor, "determine_block_hash", return_value=fake_block_hash - ) - mocked_query = mocker.patch.object(subtensor.substrate, "query", return_value=None) - - # Call - result = subtensor.get_mev_shield_submission( - submission_id=fake_submission_id, block=fake_block - ) - - # Asserts - mocked_determine_block_hash.assert_called_once_with(block=fake_block) - mocked_query.assert_called_once_with( - module="MevShield", - storage_function="Submissions", - params=[bytes.fromhex("1234567890abcdef")], - block_hash=fake_block_hash, - ) - assert result is None - - -def test_get_mev_shield_submissions_success(subtensor, mocker): - """Test get_mev_shield_submissions returns all submissions when found.""" - # Prep - fake_block = 123 - fake_block_hash = "0x123abc" - fake_submission_id_1 = b"\x01" * 32 - fake_submission_id_2 = b"\x02" * 32 - fake_author_1 = b"\x03" * 32 - fake_author_2 = b"\x04" * 32 - fake_commitment_1 = b"\x05" * 32 - fake_commitment_2 = b"\x06" * 32 - fake_ciphertext_1 = b"\x07" * 100 - fake_ciphertext_2 = b"\x08" * 100 - - fake_query_result = mocker.MagicMock() - fake_query_result.__iter__.return_value = iter( - [ - ( - [fake_submission_id_1], - mocker.MagicMock( - value={ - "author": [fake_author_1], - "commitment": [fake_commitment_1], - "ciphertext": [fake_ciphertext_1], - "submitted_in": 100, - } - ), - ), - ( - [fake_submission_id_2], - mocker.MagicMock( - value={ - "author": [fake_author_2], - "commitment": [fake_commitment_2], - "ciphertext": [fake_ciphertext_2], - "submitted_in": 101, - } - ), - ), - ] - ) - - mocked_determine_block_hash = mocker.patch.object( - subtensor, "determine_block_hash", return_value=fake_block_hash - ) - mocked_query_map = mocker.patch.object( - subtensor.substrate, "query_map", return_value=fake_query_result - ) - mocked_decode_account_id = mocker.patch.object( - subtensor_module, - "decode_account_id", - side_effect=[ - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", - "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", - ], - ) - - # Call - result = subtensor.get_mev_shield_submissions(block=fake_block) - - # Asserts - mocked_determine_block_hash.assert_called_once_with(block=fake_block) - mocked_query_map.assert_called_once_with( - module="MevShield", - storage_function="Submissions", - block_hash=fake_block_hash, - ) - assert result is not None - assert len(result) == 2 - assert "0x" + fake_submission_id_1.hex() in result - assert "0x" + fake_submission_id_2.hex() in result - assert result["0x" + fake_submission_id_1.hex()]["submitted_in"] == 100 - assert result["0x" + fake_submission_id_2.hex()]["submitted_in"] == 101 - # Verify decode_account_id was called for both submissions - assert mocked_decode_account_id.call_count == 2 - - -def test_get_mev_shield_submissions_none(subtensor, mocker): - """Test get_mev_shield_submissions returns None when no submissions found.""" - # Prep - fake_block = 123 - fake_block_hash = "0x123abc" - - fake_query_result = mocker.MagicMock() - fake_query_result.__iter__.return_value = iter([]) - - mocked_determine_block_hash = mocker.patch.object( - subtensor, "determine_block_hash", return_value=fake_block_hash - ) - mocked_query_map = mocker.patch.object( - subtensor.substrate, "query_map", return_value=fake_query_result - ) - - # Call - result = subtensor.get_mev_shield_submissions(block=fake_block) - - # Asserts - mocked_determine_block_hash.assert_called_once_with(block=fake_block) - mocked_query_map.assert_called_once_with( - module="MevShield", - storage_function="Submissions", - block_hash=fake_block_hash, - ) - assert result is None - - def test_mev_submit_encrypted_success(subtensor, fake_wallet, mocker): """Test mev_submit_encrypted calls submit_encrypted_extrinsic correctly.""" # Prep @@ -6466,3 +6308,257 @@ def test_get_start_call_delay(subtensor, mocker): # Asserts mocked_query_subtensor.assert_called_once_with(name="StartCallDelay", block=None) assert result == mocked_query_subtensor.return_value + + +def test_get_coldkey_swap_announcement(subtensor, mocker): + """Test get_coldkey_swap_announcement returns correct data when announcement information is found.""" + # Prep + fake_coldkey_ss58 = mocker.Mock(spec=str) + + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + mocked_from_query = mocker.patch.object( + subtensor_module.ColdkeySwapAnnouncementInfo, "from_query" + ) + + # Call + result = subtensor.get_coldkey_swap_announcement(coldkey_ss58=fake_coldkey_ss58) + + # Asserts + mocked_determine_block_hash.assert_called_once_with(None) + mocked_query.assert_called_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + params=[fake_coldkey_ss58], + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_query.assert_called_once_with( + coldkey_ss58=fake_coldkey_ss58, query=mocked_query.return_value + ) + assert result == mocked_from_query.return_value + + +def test_get_coldkey_swap_announcements(subtensor, mocker): + """Test get_coldkey_swap_announcements returns correct data when announcement information is found.""" + # Prep + fake_record = mocker.Mock() + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query_map = mocker.patch.object( + subtensor.substrate, "query_map", return_value=(fake_record,) + ) + mocked_from_record = mocker.patch.object( + subtensor_module.ColdkeySwapAnnouncementInfo, "from_record" + ) + + # Call + result = subtensor.get_coldkey_swap_announcements() + + # Asserts + mocked_determine_block_hash.assert_called_once_with(None) + mocked_query_map.assert_called_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncements", + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_record.assert_called_once_with(fake_record) + assert result == [mocked_from_record.return_value] + + +def test_get_coldkey_swap_announcement_delay(subtensor, mocker): + """Test get_coldkey_swap_announcement_delay returns correct value when found.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = subtensor.get_coldkey_swap_announcement_delay() + + # Asserts + mocked_determine_block_hash.assert_called_once_with(None) + mocked_query.assert_called_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapAnnouncementDelay", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == mocked_query.return_value.value + + +def test_get_coldkey_swap_reannouncement_delay(subtensor, mocker): + """Test get_coldkey_swap_reannouncement_delay returns correct value when found.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = subtensor.get_coldkey_swap_reannouncement_delay() + + # Asserts + mocked_determine_block_hash.assert_called_once_with(None) + mocked_query.assert_called_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapReannouncementDelay", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == mocked_query.return_value.value + + +def test_get_coldkey_swap_constants(subtensor, mocker): + """Test get_coldkey_swap_constants returns correct data when constants are found.""" + # Prep + fake_name = mocker.Mock(spec=str) + fake_value = mocker.Mock(value=mocker.Mock(spec=int)) + mocked_constants_names = mocker.patch.object( + subtensor_module.ColdkeySwapConstants, + "constants_names", + return_value=[fake_name], + ) + mocked_query_constant = mocker.patch.object( + subtensor, + "query_constant", + side_effect=[fake_value], + ) + mocked_from_dict = mocker.patch.object( + subtensor_module.ColdkeySwapConstants, "from_dict" + ) + + # Call + result = subtensor.get_coldkey_swap_constants() + + # Asserts + mocked_constants_names.assert_called_once() + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name=fake_name, + block=None, + ) + mocked_from_dict.assert_called_once_with({fake_name: fake_value.value}) + assert result == mocked_from_dict.return_value + + +def test_announce_coldkey_swap(mocker, subtensor): + """Tests `announce_coldkey_swap` extrinsic call method.""" + # preps + wallet = mocker.Mock(spec=Wallet) + new_coldkey_ss58 = mocker.Mock(spec=str) + mocked_announce_coldkey_swap_extrinsic = mocker.patch.object( + subtensor_module, "announce_coldkey_swap_extrinsic" + ) + + # call + response = subtensor.announce_coldkey_swap( + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + ) + + # asserts + mocked_announce_coldkey_swap_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_announce_coldkey_swap_extrinsic.return_value + + +def test_swap_coldkey_announced(mocker, subtensor): + """Tests `swap_coldkey_announced` extrinsic call method.""" + # preps + wallet = mocker.Mock(spec=Wallet) + new_coldkey_ss58 = mocker.Mock(spec=str) + mocked_swap_coldkey_announced_extrinsic = mocker.patch.object( + subtensor_module, "swap_coldkey_announced_extrinsic" + ) + + # call + response = subtensor.swap_coldkey_announced( + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + ) + + # asserts + mocked_swap_coldkey_announced_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + new_coldkey_ss58=new_coldkey_ss58, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_swap_coldkey_announced_extrinsic.return_value + + +def test_get_coldkey_swap_dispute(subtensor, mocker): + """Test get_coldkey_swap_dispute returns correct data when dispute information is found.""" + fake_coldkey_ss58 = mocker.Mock(spec=str) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + mocked_from_query = mocker.patch.object( + subtensor_module.ColdkeySwapDisputeInfo, "from_query" + ) + + result = subtensor.get_coldkey_swap_dispute(coldkey_ss58=fake_coldkey_ss58) + + mocked_determine_block_hash.assert_called_once_with(None) + mocked_query.assert_called_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + params=[fake_coldkey_ss58], + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_query.assert_called_once_with( + coldkey_ss58=fake_coldkey_ss58, query=mocked_query.return_value + ) + assert result == mocked_from_query.return_value + + +def test_get_coldkey_swap_disputes(subtensor, mocker): + """Test get_coldkey_swap_disputes returns correct data when dispute information is found.""" + fake_record = mocker.Mock() + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query_map = mocker.patch.object( + subtensor.substrate, "query_map", return_value=(fake_record,) + ) + mocked_from_record = mocker.patch.object( + subtensor_module.ColdkeySwapDisputeInfo, "from_record" + ) + + result = subtensor.get_coldkey_swap_disputes() + + mocked_determine_block_hash.assert_called_once_with(None) + mocked_query_map.assert_called_once_with( + module="SubtensorModule", + storage_function="ColdkeySwapDisputes", + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_record.assert_called_once_with(fake_record) + assert result == [mocked_from_record.return_value] + + +def test_dispute_coldkey_swap(mocker, subtensor): + """Tests `dispute_coldkey_swap` extrinsic call method.""" + wallet = mocker.Mock(spec=Wallet) + mocked_dispute_coldkey_swap_extrinsic = mocker.patch.object( + subtensor_module, "dispute_coldkey_swap_extrinsic" + ) + + response = subtensor.dispute_coldkey_swap(wallet=wallet) + + mocked_dispute_coldkey_swap_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=wallet, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_dispute_coldkey_swap_extrinsic.return_value