diff --git a/bittensor/core/extrinsics/async_weights.py b/bittensor/core/extrinsics/async_weights.py index 572266c3f6..82934edfc5 100644 --- a/bittensor/core/extrinsics/async_weights.py +++ b/bittensor/core/extrinsics/async_weights.py @@ -58,11 +58,17 @@ async def _do_set_weights( "version_key": version_key, }, ) + + next_nonce = await subtensor.substrate.get_account_next_index( + wallet.hotkey.ss58_address + ) + # Period dictates how long the extrinsic will stay as part of waiting pool extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.hotkey, era={"period": 5}, + nonce=next_nonce, ) response = await subtensor.substrate.submit_extrinsic( extrinsic, @@ -180,9 +186,15 @@ async def _do_commit_weights( "commit_hash": commit_hash, }, ) + + next_nonce = await subtensor.substrate.get_account_next_index( + wallet.hotkey.ss58_address + ) + extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.hotkey, + nonce=next_nonce, ) response = await subtensor.substrate.submit_extrinsic( substrate=subtensor.substrate, diff --git a/bittensor/core/extrinsics/commit_weights.py b/bittensor/core/extrinsics/commit_weights.py index 0ad6ad5add..4136e0c348 100644 --- a/bittensor/core/extrinsics/commit_weights.py +++ b/bittensor/core/extrinsics/commit_weights.py @@ -66,9 +66,11 @@ def do_commit_weights( "commit_hash": commit_hash, }, ) + next_nonce = self.get_account_next_index(wallet.hotkey.ss58_address) extrinsic = self.substrate.create_signed_extrinsic( call=call, keypair=wallet.hotkey, + nonce=next_nonce, ) response = submit_extrinsic( subtensor=self, diff --git a/bittensor/core/extrinsics/set_weights.py b/bittensor/core/extrinsics/set_weights.py index 64f318f8d6..9cb291a299 100644 --- a/bittensor/core/extrinsics/set_weights.py +++ b/bittensor/core/extrinsics/set_weights.py @@ -76,11 +76,13 @@ def do_set_weights( "version_key": version_key, }, ) + next_nonce = self.get_account_next_index(wallet.hotkey.ss58_address) # Period dictates how long the extrinsic will stay as part of waiting pool extrinsic = self.substrate.create_signed_extrinsic( call=call, keypair=wallet.hotkey, era={"period": period}, + nonce=next_nonce, ) response = submit_extrinsic( self, diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index ff17c8e896..6ae0f027c1 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -661,6 +661,16 @@ def query_module( ), ) + @networking.ensure_connected + def get_account_next_index(self, address: str) -> int: + """ + Returns the next nonce for an account, taking into account the transaction pool. + """ + if not self.substrate.supports_rpc_method("account_nextIndex"): + raise Exception("account_nextIndex not supported") + + return self.substrate.rpc_request("account_nextIndex", [address])["result"] + # Common subtensor methods ========================================================================================= def metagraph( self, netuid: int, lite: bool = True, block: Optional[int] = None diff --git a/bittensor/utils/async_substrate_interface.py b/bittensor/utils/async_substrate_interface.py index 05fd963212..eeb5eb1068 100644 --- a/bittensor/utils/async_substrate_interface.py +++ b/bittensor/utils/async_substrate_interface.py @@ -13,6 +13,7 @@ from hashlib import blake2b from typing import Optional, Any, Union, Callable, Awaitable, cast, TYPE_CHECKING +import asyncstdlib as a from async_property import async_property from bittensor_wallet import Keypair from bt_decode import PortableRegistry, decode as decode_by_type_string, MetadataV15 @@ -1704,6 +1705,24 @@ def make_payload(id_: str, method: str, params: list) -> dict: "payload": {"jsonrpc": "2.0", "method": method, "params": params}, } + @a.lru_cache(maxsize=512) # RPC methods are unlikely to change often + async def supports_rpc_method(self, name: str) -> bool: + """ + Check if substrate RPC supports given method + Parameters + ---------- + name: name of method to check + + Returns + ------- + bool + """ + result = await self.rpc_request("rpc_methods", []).get("result") + if result: + self.config["rpc_methods"] = result.get("methods", []) + + return name in self.config["rpc_methods"] + async def rpc_request( self, method: str, @@ -2296,10 +2315,33 @@ async def get_account_nonce(self, account_address: str) -> int: Returns: Nonce for given account address """ - nonce_obj = await self.runtime_call( - "AccountNonceApi", "account_nonce", [account_address] - ) - return nonce_obj.value + if await self.supports_rpc_method("state_call"): + nonce_obj = await self.runtime_call( + "AccountNonceApi", "account_nonce", [account_address] + ) + return nonce_obj + else: + response = await self.query( + module="System", storage_function="Account", params=[account_address] + ) + return response["nonce"] + + async def get_account_next_index(self, account_address: str) -> int: + """ + Returns next index for the given account address, taking into account the transaction pool. + + Args: + account_address: SS58 formatted address + + Returns: + Next index for the given account address + """ + if not await self.supports_rpc_method("account_nextIndex"): + # Unlikely to happen, this is a common RPC method + raise Exception("account_nextIndex not supported") + + nonce_obj = await self.rpc_request("account_nextIndex", [account_address]) + return nonce_obj["result"] async def get_metadata_constant(self, module_name, constant_name, block_hash=None): """ diff --git a/requirements/prod.txt b/requirements/prod.txt index c57ce611f9..12525a2305 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,6 +1,7 @@ wheel setuptools~=70.0.0 aiohttp~=3.9 +asyncstdlib~=3.13.0 async-property==0.2.2 bittensor-cli bt-decode==0.4.0 diff --git a/tests/e2e_tests/test_commit_weights.py b/tests/e2e_tests/test_commit_weights.py index c6737e01ae..8a5371283e 100644 --- a/tests/e2e_tests/test_commit_weights.py +++ b/tests/e2e_tests/test_commit_weights.py @@ -2,6 +2,9 @@ import numpy as np import pytest + +import asyncio + from bittensor.core.subtensor import Subtensor from bittensor.utils.balance import Balance from bittensor.utils.weight_utils import convert_weights_and_uids_for_emit @@ -171,3 +174,158 @@ async def test_commit_and_reveal_weights_legacy(local_chain): weight_vals[0] == revealed_weights.value[0][1] ), f"Incorrect revealed weights. Expected: {weights[0]}, Actual: {revealed_weights.value[0][1]}" print("✅ Passed test_commit_and_reveal_weights") + + +@pytest.mark.asyncio +async def test_commit_weights_uses_next_nonce(local_chain): + """ + Tests that commiting weights doesn't re-use a nonce in the transaction pool. + + Steps: + 1. Register a subnet through Alice + 2. Register Alice's neuron and add stake + 3. Enable commit-reveal mechanism on the subnet + 4. Lower the commit_reveal interval and rate limit + 5. Commit weights three times + 6. Assert that all commits succeeded + Raises: + AssertionError: If any of the checks or verifications fail + """ + netuid = 1 + utils.EXTRINSIC_SUBMISSION_TIMEOUT = 12 # handle fast blocks + print("Testing test_commit_and_reveal_weights") + # Register root as Alice + keypair, alice_wallet = setup_wallet("//Alice") + assert register_subnet(local_chain, alice_wallet), "Unable to register the subnet" + + # Verify subnet 1 created successfully + assert local_chain.query( + "SubtensorModule", "NetworksAdded", [1] + ).serialize(), "Subnet wasn't created successfully" + + subtensor = Subtensor(network="ws://localhost:9945") + + # Register Alice to the subnet + assert subtensor.burned_register( + alice_wallet, netuid + ), "Unable to register Alice as a neuron" + + # Stake to become to top neuron after the first epoch + add_stake(local_chain, alice_wallet, Balance.from_tao(100_000)) + + # Enable commit_reveal on the subnet + assert sudo_set_hyperparameter_bool( + local_chain, + alice_wallet, + "sudo_set_commit_reveal_weights_enabled", + True, + netuid, + ), "Unable to enable commit reveal on the subnet" + + assert subtensor.get_subnet_hyperparameters( + netuid=netuid, + ).commit_reveal_weights_enabled, "Failed to enable commit/reveal" + + # Lower the commit_reveal interval + assert sudo_set_hyperparameter_values( + local_chain, + alice_wallet, + call_function="sudo_set_commit_reveal_weights_interval", + call_params={"netuid": netuid, "interval": "1"}, + return_error_message=True, + ) + + assert ( + subtensor.get_subnet_hyperparameters( + netuid=netuid + ).commit_reveal_weights_interval + == 1 + ), "Failed to set commit/reveal periods" + + assert ( + subtensor.weights_rate_limit(netuid=netuid) > 0 + ), "Weights rate limit is below 0" + # Lower the rate limit + assert sudo_set_hyperparameter_values( + local_chain, + alice_wallet, + call_function="sudo_set_weights_set_rate_limit", + call_params={"netuid": netuid, "weights_set_rate_limit": "0"}, + return_error_message=True, + ) + + assert ( + subtensor.get_subnet_hyperparameters(netuid=netuid).weights_rate_limit == 0 + ), "Failed to set weights_rate_limit" + assert subtensor.weights_rate_limit(netuid=netuid) == 0 + + # Commit-reveal values + uids = np.array([0], dtype=np.int64) + weights = np.array([0.1], dtype=np.float32) + salt = [18, 179, 107, 0, 165, 211, 141, 197] + weight_uids, weight_vals = convert_weights_and_uids_for_emit( + uids=uids, weights=weights + ) + + # Make a second salt + salt2 = salt.copy() + salt2[0] += 1 # Increment the first byte to produce a different commit hash + + # Make a third salt + salt3 = salt.copy() + salt3[0] += 2 # Increment the first byte to produce a different commit hash + + # Commit all three salts + success, message = subtensor.commit_weights( + alice_wallet, + netuid, + salt=salt, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=False, # Don't wait for inclusion, we are testing the nonce when there is a tx in the pool + wait_for_finalization=False, + ) + + assert success is True + + success, message = subtensor.commit_weights( + alice_wallet, + netuid, + salt=salt2, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + + assert success is True + + # Commit the third salt + success, message = subtensor.commit_weights( + alice_wallet, + netuid, + salt=salt3, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + + assert success is True + + # Wait a few blocks + await asyncio.sleep(2) # Wait for the txs to be included in the chain + + # Query the WeightCommits storage map for all three salts + weight_commits = subtensor.query_module( + module="SubtensorModule", + name="WeightCommits", + params=[netuid, alice_wallet.hotkey.ss58_address], + ) + # Assert that the committed weights are set correctly + assert weight_commits.value is not None, "Weight commit not found in storage" + commit_hash, commit_block, reveal_block, expire_block = weight_commits.value[0] + assert commit_block > 0, f"Invalid block number: {commit_block}" + + # Check for three commits in the WeightCommits storage map + assert len(weight_commits.value) == 3, "Expected 3 weight commits" diff --git a/tests/e2e_tests/test_incentive.py b/tests/e2e_tests/test_incentive.py index ab557a56fd..cfafef42b5 100644 --- a/tests/e2e_tests/test_incentive.py +++ b/tests/e2e_tests/test_incentive.py @@ -15,6 +15,7 @@ templates_repo, ) from bittensor.utils.balance import Balance +from bittensor.core.extrinsics import utils from bittensor.core.extrinsics.set_weights import do_set_weights from bittensor.core.metagraph import Metagraph @@ -40,6 +41,8 @@ async def test_incentive(local_chain): print("Testing test_incentive") netuid = 1 + utils.EXTRINSIC_SUBMISSION_TIMEOUT = 12 # handle fast blocks + # Register root as Alice - the subnet owner and validator alice_keypair, alice_wallet = setup_wallet("//Alice") register_subnet(local_chain, alice_wallet) diff --git a/tests/e2e_tests/test_set_weights.py b/tests/e2e_tests/test_set_weights.py new file mode 100644 index 0000000000..edf208ba8d --- /dev/null +++ b/tests/e2e_tests/test_set_weights.py @@ -0,0 +1,157 @@ +import numpy as np +import pytest + +import asyncio + +from bittensor.core.subtensor import Subtensor +from bittensor.utils.balance import Balance +from bittensor.utils.weight_utils import convert_weights_and_uids_for_emit +from bittensor.core.extrinsics import utils +from tests.e2e_tests.utils.chain_interactions import ( + add_stake, + register_subnet, + sudo_set_hyperparameter_bool, + sudo_set_hyperparameter_values, + sudo_set_admin_utils, +) +from tests.e2e_tests.utils.e2e_test_utils import setup_wallet + + +@pytest.mark.asyncio +async def test_set_weights_uses_next_nonce(local_chain): + """ + Tests that setting weights doesn't re-use a nonce in the transaction pool. + + Steps: + 1. Register three subnets through Alice + 2. Register Alice's neuron on each subnet and add stake + 3. Verify Alice has a vpermit on each subnet + 4. Lower the set weights rate limit on each subnet + 5. Set weights on each subnet + 6. Assert that all the set weights succeeded + Raises: + AssertionError: If any of the checks or verifications fail + """ + netuids = [1, 2] + utils.EXTRINSIC_SUBMISSION_TIMEOUT = 12 # handle fast blocks + print("Testing test_set_weights_uses_next_nonce") + # Register root as Alice + keypair, alice_wallet = setup_wallet("//Alice") + + # Lower the network registration rate limit and cost + sudo_set_admin_utils( + local_chain, + alice_wallet, + call_function="sudo_set_network_rate_limit", + call_params={"rate_limit": "0"}, # No limit + return_error_message=True, + ) + # Set lock reduction interval + sudo_set_admin_utils( + local_chain, + alice_wallet, + call_function="sudo_set_lock_reduction_interval", + call_params={"interval": "1"}, # 1 block # reduce lock every block + return_error_message=True, + ) + # Try to register the subnets + for _ in netuids: + assert register_subnet( + local_chain, alice_wallet + ), "Unable to register the subnet" + + # Verify all subnets created successfully + assert local_chain.query( + "SubtensorModule", "NetworksAdded", [3] + ).serialize(), "Subnet wasn't created successfully" + + subtensor = Subtensor(network="ws://localhost:9945") + + for netuid in netuids: + # Allow registration on the subnet + assert sudo_set_hyperparameter_values( + local_chain, + alice_wallet, + "sudo_set_network_registration_allowed", + {"netuid": netuid, "registration_allowed": True}, + return_error_message=True, + ) + + # This should give a gap for the calls above to be included in the chain + await asyncio.sleep(2) + + for netuid in netuids: + # Register Alice to the subnet + assert subtensor.burned_register( + alice_wallet, netuid + ), f"Unable to register Alice as a neuron on SN{netuid}" + + # Stake to become to top neuron after the first epoch + add_stake(local_chain, alice_wallet, Balance.from_tao(100_000)) + + # Set weight hyperparameters per subnet + for netuid in netuids: + assert sudo_set_hyperparameter_bool( + local_chain, + alice_wallet, + "sudo_set_commit_reveal_weights_enabled", + False, + netuid, + ), "Unable to enable commit reveal on the subnet" + + assert not subtensor.get_subnet_hyperparameters( + netuid=netuid, + ).commit_reveal_weights_enabled, "Failed to enable commit/reveal" + + assert ( + subtensor.weights_rate_limit(netuid=netuid) > 0 + ), "Weights rate limit is below 0" + # Lower the rate limit + assert sudo_set_hyperparameter_values( + local_chain, + alice_wallet, + call_function="sudo_set_weights_set_rate_limit", + call_params={"netuid": netuid, "weights_set_rate_limit": "0"}, + return_error_message=True, + ) + + assert ( + subtensor.get_subnet_hyperparameters(netuid=netuid).weights_rate_limit == 0 + ), "Failed to set weights_rate_limit" + assert subtensor.weights_rate_limit(netuid=netuid) == 0 + + # Weights values + uids = np.array([0], dtype=np.int64) + weights = np.array([0.1], dtype=np.float32) + weight_uids, weight_vals = convert_weights_and_uids_for_emit( + uids=uids, weights=weights + ) + + # Set weights for each subnet + for netuid in netuids: + success, message = subtensor.set_weights( + alice_wallet, + netuid, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=False, # Don't wait for inclusion, we are testing the nonce when there is a tx in the pool + wait_for_finalization=False, + ) + + assert success is True, f"Failed to set weights for subnet {netuid}" + + # Wait for the txs to be included in the chain + await asyncio.sleep(4) + + for netuid in netuids: + # Query the Weights storage map for all three subnets + weights = subtensor.query_module( + module="SubtensorModule", + name="Weights", + params=[netuid, 0], # Alice should be the only UID + ) + + assert weights is not None, f"Weights not found for subnet {netuid}" + assert weights == list( + zip(weight_uids, weight_vals) + ), f"Weights do not match for subnet {netuid}" diff --git a/tests/e2e_tests/utils/chain_interactions.py b/tests/e2e_tests/utils/chain_interactions.py index bae60c5443..bd5829e219 100644 --- a/tests/e2e_tests/utils/chain_interactions.py +++ b/tests/e2e_tests/utils/chain_interactions.py @@ -215,6 +215,35 @@ def sudo_set_admin_utils( extrinsic = substrate.create_signed_extrinsic( call=sudo_call, keypair=wallet.coldkey ) + response = substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + response.process_events() + + if return_error_message: + return response.is_success, response.error_message + + return response.is_success + + +async def root_set_subtensor_hyperparameter_values( + substrate: "SubstrateInterface", + wallet: "Wallet", + call_function: str, + call_params: dict, + return_error_message: bool = False, +) -> Union[bool, tuple[bool, Optional[str]]]: + """ + Sets liquid alpha values using AdminUtils. Mimics setting hyperparams + """ + call = substrate.compose_call( + call_module="SubtensorModule", + call_function=call_function, + call_params=call_params, + ) + extrinsic = substrate.create_signed_extrinsic(call=call, keypair=wallet.coldkey) response = substrate.submit_extrinsic( extrinsic, diff --git a/tests/unit_tests/extrinsics/test_commit_weights.py b/tests/unit_tests/extrinsics/test_commit_weights.py index 35a1d4d426..57d78a8013 100644 --- a/tests/unit_tests/extrinsics/test_commit_weights.py +++ b/tests/unit_tests/extrinsics/test_commit_weights.py @@ -53,9 +53,10 @@ def test_do_commit_weights(subtensor, mocker): }, ) - subtensor.substrate.create_signed_extrinsic.assert_called_once_with( - call=subtensor.substrate.compose_call.return_value, keypair=fake_wallet.hotkey - ) + subtensor.substrate.create_signed_extrinsic.assert_called_once() + _, kwargs = subtensor.substrate.create_signed_extrinsic.call_args + assert kwargs["call"] == subtensor.substrate.compose_call.return_value + assert kwargs["keypair"] == fake_wallet.hotkey subtensor.substrate.submit_extrinsic.assert_called_once_with( subtensor.substrate.create_signed_extrinsic.return_value, diff --git a/tests/unit_tests/extrinsics/test_set_weights.py b/tests/unit_tests/extrinsics/test_set_weights.py index f447915d2f..6c070bf5c4 100644 --- a/tests/unit_tests/extrinsics/test_set_weights.py +++ b/tests/unit_tests/extrinsics/test_set_weights.py @@ -135,17 +135,11 @@ def test_do_set_weights_is_success(mock_subtensor, mocker): }, ) - mock_subtensor.substrate.create_signed_extrinsic.assert_called_once_with( - call=mock_subtensor.substrate.compose_call.return_value, - keypair=fake_wallet.hotkey, - era={"period": 5}, - ) - - mock_subtensor.substrate.submit_extrinsic.assert_called_once_with( - mock_subtensor.substrate.create_signed_extrinsic.return_value, - wait_for_inclusion=fake_wait_for_inclusion, - wait_for_finalization=fake_wait_for_finalization, - ) + mock_subtensor.substrate.create_signed_extrinsic.assert_called_once() + _, kwargs = mock_subtensor.substrate.create_signed_extrinsic.call_args + assert kwargs["call"] == mock_subtensor.substrate.compose_call.return_value + assert kwargs["keypair"] == fake_wallet.hotkey + assert kwargs["era"] == {"period": 5} mock_subtensor.substrate.submit_extrinsic.return_value.process_events.assert_called_once() assert result == (True, "Successfully set weights.") @@ -189,11 +183,11 @@ def test_do_set_weights_is_not_success(mock_subtensor, mocker): }, ) - mock_subtensor.substrate.create_signed_extrinsic.assert_called_once_with( - call=mock_subtensor.substrate.compose_call.return_value, - keypair=fake_wallet.hotkey, - era={"period": 5}, - ) + mock_subtensor.substrate.create_signed_extrinsic.assert_called_once() + _, kwargs = mock_subtensor.substrate.create_signed_extrinsic.call_args + assert kwargs["call"] == mock_subtensor.substrate.compose_call.return_value + assert kwargs["keypair"] == fake_wallet.hotkey + assert kwargs["era"] == {"period": 5} mock_subtensor.substrate.submit_extrinsic.assert_called_once_with( mock_subtensor.substrate.create_signed_extrinsic.return_value, @@ -242,11 +236,11 @@ def test_do_set_weights_no_waits(mock_subtensor, mocker): }, ) - mock_subtensor.substrate.create_signed_extrinsic.assert_called_once_with( - call=mock_subtensor.substrate.compose_call.return_value, - keypair=fake_wallet.hotkey, - era={"period": 5}, - ) + mock_subtensor.substrate.create_signed_extrinsic.assert_called_once() + _, kwargs = mock_subtensor.substrate.create_signed_extrinsic.call_args + assert kwargs["call"] == mock_subtensor.substrate.compose_call.return_value + assert kwargs["keypair"] == fake_wallet.hotkey + assert kwargs["era"] == {"period": 5} mock_subtensor.substrate.submit_extrinsic.assert_called_once_with( mock_subtensor.substrate.create_signed_extrinsic.return_value,