diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index cfb02af0aa..ac5a45a91f 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -11,7 +11,7 @@ from async_substrate_interface.utils.storage import StorageKey from bittensor_drand import get_encrypted_commitment from bittensor_wallet.utils import SS58_FORMAT -from scalecodec import GenericCall +from scalecodec import GenericCall, GenericExtrinsic from bittensor.core.chain_data import ( CrowdloanConstants, @@ -5677,6 +5677,7 @@ async def sign_and_send_extrinsic( sign_with: str = "coldkey", use_nonce: bool = False, nonce_key: str = "hotkey", + nonce: Optional[int] = None, period: Optional[int] = DEFAULT_PERIOD, raise_error: bool = False, wait_for_inclusion: bool = True, @@ -5692,6 +5693,7 @@ async def sign_and_send_extrinsic( sign_with: the wallet's keypair to use for the signing. Options are "coldkey", "hotkey", "coldkeypub" use_nonce: unique identifier for the transaction related with hot/coldkey. nonce_key: the type on nonce to use. Options are "hotkey" or "coldkey". + nonce: the nonce to use for the transaction, will be used if provided. 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. @@ -5718,7 +5720,10 @@ async def sign_and_send_extrinsic( ) signing_keypair = getattr(wallet, sign_with) extrinsic_data = {"call": call, "keypair": signing_keypair} - if use_nonce: + if nonce is not None: + # if nonce is provided, use it + extrinsic_data["nonce"] = nonce + elif use_nonce: if nonce_key not in possible_keys: raise AttributeError( f"'nonce_key' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{nonce_key}'" @@ -5780,6 +5785,60 @@ async def sign_and_send_extrinsic( extrinsic_response.error = error return extrinsic_response + async def create_signed_extrinsic( + self, + call: "GenericCall", + wallet: "Wallet", + sign_with: str = "coldkey", + use_nonce: bool = False, + nonce_key: str = "hotkey", + nonce: Optional[int] = None, + period: Optional[int] = DEFAULT_PERIOD, + ) -> "GenericExtrinsic": + """ + Helper method to sign and submit an extrinsic call to chain. + + Parameters: + call: a prepared Call object + wallet: the wallet whose coldkey will be used to sign the extrinsic + sign_with: the wallet's keypair to use for the signing. Options are "coldkey", "hotkey", "coldkeypub" + use_nonce: unique identifier for the transaction related with hot/coldkey. + nonce_key: the type on nonce to use. Options are "hotkey" or "coldkey". + nonce: the nonce to use for the transaction, will be used if provided. + 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. + + Returns: + GenericExtrinsic: The signed extrinsic. + """ + possible_keys = ("coldkey", "hotkey", "coldkeypub") + if sign_with not in possible_keys: + raise AttributeError( + f"'sign_with' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{sign_with}'" + ) + signing_keypair = getattr(wallet, sign_with) + extrinsic_data = {"call": call, "keypair": signing_keypair} + if nonce is not None: + # if nonce is provided, use it + extrinsic_data["nonce"] = nonce + elif use_nonce: + if nonce_key not in possible_keys: + raise AttributeError( + f"'nonce_key' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{nonce_key}'" + ) + next_nonce = await self.substrate.get_account_next_index( + getattr(wallet, nonce_key).ss58_address + ) + extrinsic_data["nonce"] = next_nonce + + if period is not None: + extrinsic_data["era"] = {"period": period} + + signed_ext = await self.substrate.create_signed_extrinsic(**extrinsic_data) + + return signed_ext + async def get_extrinsic_fee( self, call: "GenericCall", diff --git a/bittensor/core/extrinsics/asyncex/mev_shield.py b/bittensor/core/extrinsics/asyncex/mev_shield.py index 40feffeb23..99b813a71f 100644 --- a/bittensor/core/extrinsics/asyncex/mev_shield.py +++ b/bittensor/core/extrinsics/asyncex/mev_shield.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from bittensor.core.async_subtensor import AsyncSubtensor from bittensor_wallet import Wallet, Keypair - from scalecodec.types import GenericCall + from scalecodec.types import GenericCall, GenericExtrinsic async def find_revealed_extrinsic( @@ -84,9 +84,10 @@ async def find_revealed_extrinsic( async def submit_encrypted_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", - call: "GenericCall", + signed_ext: "GenericExtrinsic", signer_keypair: Optional["Keypair"] = None, period: Optional[int] = None, + nonce: Optional[int] = None, raise_error: bool = False, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, @@ -102,7 +103,7 @@ async def submit_encrypted_extrinsic( Parameters: subtensor: The Subtensor client instance used for blockchain interaction. wallet: The wallet used to sign the extrinsic (must be unlocked, coldkey will be used for signing). - call: The GenericCall object to encrypt and submit. + signed_ext: The signed GenericExtrinsic object to encrypt and submit. signer_keypair: The Keypair object used for signing the inner call. 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 @@ -147,10 +148,6 @@ async def submit_encrypted_extrinsic( ), ) - # Use wallet.coldkey as default signer if signer_keypair is not provided - if signer_keypair is None: - signer_keypair = wallet.coldkey - ml_kem_768_public_key = await subtensor.get_mev_shield_next_key() if ml_kem_768_public_key is None: return ExtrinsicResponse.from_exception( @@ -158,13 +155,9 @@ async def submit_encrypted_extrinsic( error=ValueError("MEV Shield NextKey not available in storage."), ) - genesis_hash = await subtensor.get_block_hash(block=0) - - mev_commitment, mev_ciphertext, payload_core, signature = ( + mev_commitment, mev_ciphertext, payload_core = ( get_mev_commitment_and_ciphertext( - call=call, - signer_keypair=signer_keypair, - genesis_hash=genesis_hash, + signed_ext=signed_ext, ml_kem_768_public_key=ml_kem_768_public_key, ) ) @@ -178,6 +171,7 @@ async def submit_encrypted_extrinsic( wallet=wallet, call=extrinsic_call, period=period, + nonce=nonce, raise_error=raise_error, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, @@ -189,7 +183,6 @@ async def submit_encrypted_extrinsic( "ciphertext": mev_ciphertext, "ml_kem_768_public_key": ml_kem_768_public_key, "payload_core": payload_core, - "signature": signature, "submitting_id": extrinsic_call.call_hash, } if wait_for_revealed_execution: diff --git a/bittensor/core/extrinsics/asyncex/staking.py b/bittensor/core/extrinsics/asyncex/staking.py index 69d6236199..49efd3db18 100644 --- a/bittensor/core/extrinsics/asyncex/staking.py +++ b/bittensor/core/extrinsics/asyncex/staking.py @@ -142,11 +142,22 @@ async def add_stake_extrinsic( block_hash_before = await subtensor.get_block_hash() if mev_protection: + # get current nonce for the coldkey, accounting for the current mempool. + current_nonce = await subtensor.substrate.get_account_next_index( + wallet.coldkeypub.ss58_address + ) + # sign the extrinsic with the nonce, *one higher than the current nonce* as it will execute after. + signed_ext = await subtensor.create_signed_extrinsic( + call=call, wallet=wallet, period=period, nonce=current_nonce + 1 + ) + + # submit the encrypted extrinsic, with the *current* nonce. response = await submit_encrypted_extrinsic( subtensor=subtensor, wallet=wallet, - call=call, + signed_ext=signed_ext, period=period, + nonce=current_nonce, 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 db17f9c359..545128e1ec 100644 --- a/bittensor/core/extrinsics/utils.py +++ b/bittensor/core/extrinsics/utils.py @@ -15,7 +15,7 @@ from bittensor_wallet import Wallet from bittensor.core.chain_data import StakeInfo from bittensor.core.subtensor import Subtensor - from scalecodec.types import GenericCall + from scalecodec.types import GenericCall, GenericExtrinsic from bittensor_wallet.keypair import Keypair from async_substrate_interface import AsyncExtrinsicReceipt, ExtrinsicReceipt @@ -228,11 +228,9 @@ def apply_pure_proxy_data( def get_mev_commitment_and_ciphertext( - call: "GenericCall", - signer_keypair: "Keypair", - genesis_hash: str, + signed_ext: "GenericExtrinsic", ml_kem_768_public_key: bytes, -) -> tuple[str, bytes, bytes, bytes]: +) -> tuple[str, bytes, bytes]: """ Builds MEV Shield payload and encrypts it using ML-KEM-768 + XChaCha20Poly1305. @@ -241,11 +239,7 @@ def get_mev_commitment_and_ciphertext( replay protection. Parameters: - call: The GenericCall object representing the inner call to be encrypted and executed. - signer_keypair: The Keypair used for signing the inner call payload. The signer's AccountId32 (32 bytes) is - embedded in the payload_core. - genesis_hash: The genesis block hash as a hex string (with or without "0x" prefix). Used for chain-bound - signature domain separation. + 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. @@ -254,41 +248,10 @@ def get_mev_commitment_and_ciphertext( - commitment_hex (str): Hex string of the Blake2-256 hash of payload_core (32 bytes). - ciphertext (bytes): Encrypted blob containing plaintext. - payload_core (bytes): Raw payload bytes before encryption. - - signature (bytes): MultiSignature (64 bytes for sr25519). """ - # Create payload_core: signer (32B) + key_hash (32B Blake2-256 hash) + SCALE(call) - decoded_ss58 = ss58_decode(signer_keypair.ss58_address) - decoded_ss58_cut = ( - decoded_ss58[2:] if decoded_ss58.startswith("0x") else decoded_ss58 - ) - signer_bytes = bytes.fromhex(decoded_ss58_cut) # 32 bytes - - # Compute key_hash = Blake2-256(NextKey_bytes) - # This binds the transaction to the key epoch at submission time - key_hash_bytes = hashlib.blake2b( - ml_kem_768_public_key, digest_size=32 - ).digest() # 32 bytes - - scale_call_bytes = bytes(call.data.data) # SCALE encoded call - mev_shield_version = mlkem_kdf_id() - - # Fix genesis_hash processing - genesis_hash_clean = ( - genesis_hash[2:] if genesis_hash.startswith("0x") else genesis_hash - ) - genesis_hash_bytes = bytes.fromhex(genesis_hash_clean) - - payload_core = signer_bytes + key_hash_bytes + scale_call_bytes - - # Sign payload: coldkey.sign(b"mev-shield:v1" + genesis_hash + payload_core) - message_to_sign = ( - b"mev-shield:" + mev_shield_version + genesis_hash_bytes + payload_core - ) - - signature = signer_keypair.sign(message_to_sign) + payload_core = signed_ext.data.data - # Create plaintext: payload_core + b"\x01" + signature - plaintext = payload_core + b"\x01" + signature + plaintext = bytes(payload_core) # Getting ciphertext (encrypting plaintext using ML-KEM-768) ciphertext = encrypt_mlkem768(ml_kem_768_public_key, plaintext) @@ -297,7 +260,7 @@ def get_mev_commitment_and_ciphertext( commitment_hash = hashlib.blake2b(payload_core, digest_size=32).digest() commitment_hex = "0x" + commitment_hash.hex() - return commitment_hex, ciphertext, payload_core, signature + return commitment_hex, ciphertext, payload_core def get_event_attributes_by_event_name(events: list, event_name: str) -> Optional[dict]: diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 506038e17d..e21f61a9ca 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -166,7 +166,7 @@ if TYPE_CHECKING: from async_substrate_interface.sync_substrate import QueryMapResult from bittensor_wallet import Keypair, Wallet - from scalecodec.types import GenericCall + from scalecodec.types import GenericCall, GenericExtrinsic class Subtensor(SubtensorMixin): @@ -4502,6 +4502,60 @@ def sign_and_send_extrinsic( extrinsic_response.error = error return extrinsic_response + def create_signed_extrinsic( + self, + call: "GenericCall", + wallet: "Wallet", + sign_with: str = "coldkey", + use_nonce: bool = False, + nonce_key: str = "hotkey", + nonce: Optional[int] = None, + period: Optional[int] = DEFAULT_PERIOD, + ) -> "GenericExtrinsic": + """ + Helper method to sign and submit an extrinsic call to chain. + + Parameters: + call: a prepared Call object + wallet: the wallet whose coldkey will be used to sign the extrinsic + sign_with: the wallet's keypair to use for the signing. Options are "coldkey", "hotkey", "coldkeypub" + use_nonce: unique identifier for the transaction related with hot/coldkey. + nonce_key: the type on nonce to use. Options are "hotkey" or "coldkey". + nonce: the nonce to use for the transaction, will be used if provided. + 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. + + Returns: + GenericExtrinsic: The signed extrinsic. + """ + possible_keys = ("coldkey", "hotkey", "coldkeypub") + if sign_with not in possible_keys: + raise AttributeError( + f"'sign_with' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{sign_with}'" + ) + signing_keypair = getattr(wallet, sign_with) + extrinsic_data = {"call": call, "keypair": signing_keypair} + if nonce is not None: + # if nonce is provided, use it + extrinsic_data["nonce"] = nonce + elif use_nonce: + if nonce_key not in possible_keys: + raise AttributeError( + f"'nonce_key' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{nonce_key}'" + ) + next_nonce = self.substrate.get_account_next_index( + getattr(wallet, nonce_key).ss58_address + ) + extrinsic_data["nonce"] = next_nonce + + if period is not None: + extrinsic_data["era"] = {"period": period} + + signed_ext = self.substrate.create_signed_extrinsic(**extrinsic_data) + + return signed_ext + def get_extrinsic_fee( self, call: "GenericCall",