diff --git a/.github/workflows/e2e-subtensor-tests.yaml b/.github/workflows/e2e-subtensor-tests.yaml index a95bece4bc..97df3354be 100644 --- a/.github/workflows/e2e-subtensor-tests.yaml +++ b/.github/workflows/e2e-subtensor-tests.yaml @@ -25,7 +25,7 @@ env: # job to run tests in parallel jobs: - # Job to find all test files + find-tests: runs-on: ubuntu-latest if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} @@ -42,68 +42,55 @@ jobs: echo "::set-output name=test-files::$test_files" shell: bash + pull-docker-image: + runs-on: ubuntu-latest + steps: + - name: Log in to GitHub Container Registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin + + - name: Pull Docker Image + run: docker pull ghcr.io/opentensor/subtensor-localnet:latest + + - name: Save Docker Image to Cache + run: docker save -o subtensor-localnet.tar ghcr.io/opentensor/subtensor-localnet:latest + + - name: Upload Docker Image as Artifact + uses: actions/upload-artifact@v4 + with: + name: subtensor-localnet + path: subtensor-localnet.tar + # Job to run tests in parallel run: - needs: find-tests - runs-on: SubtensorCI + needs: + - find-tests + - pull-docker-image + runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false # Allow other matrix jobs to run even if this job fails - max-parallel: 8 # Set the maximum number of parallel jobs + max-parallel: 32 # Set the maximum number of parallel jobs (same as we have cores in SubtensorCI runner) matrix: - rust-branch: - - stable - rust-target: - - x86_64-unknown-linux-gnu os: - ubuntu-latest test-file: ${{ fromJson(needs.find-tests.outputs.test-files) }} - env: - RELEASE_NAME: development - RUSTV: ${{ matrix.rust-branch }} - RUST_BACKTRACE: full - RUST_BIN_DIR: target/${{ matrix.rust-target }} - TARGET: ${{ matrix.rust-target }} steps: - - name: Check-out repository under $GITHUB_WORKSPACE + - name: Check-out repository uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt-get update && - sudo apt-get install -y clang curl libssl-dev llvm libudev-dev protobuf-compiler - - - name: Install Rust ${{ matrix.rust-branch }} - uses: actions-rs/toolchain@v1.0.6 - with: - toolchain: ${{ matrix.rust-branch }} - components: rustfmt - profile: minimal - - - name: Add wasm32-unknown-unknown target - run: | - rustup target add wasm32-unknown-unknown --toolchain stable-x86_64-unknown-linux-gnu - rustup component add rust-src --toolchain stable-x86_64-unknown-linux-gnu - - - name: Clone subtensor repo - run: git clone https://github.com/opentensor/subtensor.git - - - name: Setup subtensor repo - working-directory: ${{ github.workspace }}/subtensor - run: git checkout devnet-ready - - name: Install uv uses: astral-sh/setup-uv@v4 - name: install dependencies run: uv sync --all-extras --dev - - name: Run tests - run: | - LOCALNET_SH_PATH="${{ github.workspace }}/subtensor/scripts/localnet.sh" uv run pytest ${{ matrix.test-file }} -s + - name: Download Cached Docker Image + uses: actions/download-artifact@v4 + with: + name: subtensor-localnet - - name: Retry failed tests - if: failure() - run: | - sleep 10 - LOCALNET_SH_PATH="${{ github.workspace }}/subtensor/scripts/localnet.sh" uv run pytest ${{ matrix.test-file }} -s + - name: Load Docker Image + run: docker load -i subtensor-localnet.tar + + - name: Run tests + run: uv run pytest ${{ matrix.test-file }} -s diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c88fa7c8..15214fcf88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 9.2.0 /2025-03-18 + +## What's Changed +* Fix E2E test_incentive by waiting till start of the very next epoch by @zyzniewski-reef in https://github.com/opentensor/bittensor/pull/2746 +* New era of e2e Tests Bittensor by @roman-opentensor in https://github.com/opentensor/bittensor/pull/2743 +* Allow installation on Py 3.13 by @thewhaleking in https://github.com/opentensor/bittensor/pull/2756 +* Feat/dynamic stake prices by @ibraheem-opentensor in https://github.com/opentensor/bittensor/pull/2755 + +**Full Changelog**: https://github.com/opentensor/bittensor/compare/v9.1.0...v9.2.0 + ## 9.1.0 /2025-03-12 ## What's Changed diff --git a/README.md b/README.md index 109a321030..8de9667c7d 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,49 @@ The Python interpreter output will look like below. >>> ``` +### Testing +You can run integration and unit tests in interactive mode of IDE or in terminal mode using the command: +```bash +pytest tests/integration_tests +pytest tests/unit_tests +``` + +#### E2E tests have 2 options for launching (legacy runner): +- using a compiler based on the substrait code +- using an already built docker image (docker runner) + +#### Using `docker runner` (default for now): +- E2E tests with docker image do not require preliminary compilation +- are executed very quickly +- require docker installed in OS + +Ho to use: +```bash +pytest tests/e2e_tests +``` + +#### TUsing `legacy runner`: +- Will start compilation of the collected code in your subtensor repository +- you must provide the `LOCALNET_SH_PATH` variable in the local environment with the path to the file `/scripts/localnet.sh` in the cloned repository within your OS +- you can use the `BUILD_BINARY=0` variable, this will skip the copy step for each test. +- you can use the `USE_DOCKER=0` variable, this will run tests using the "legacy runner", even if docker is installed in your OS + +#### Ho to use: +Regular e2e tests run +```bash +LOCALNET_SH_PATH=/path/to/your/localnet.sh pytest tests/e2e_tests +``` + +If you want to skip re-build process for each e2e test +```bash +BUILD_BINARY=0 LOCALNET_SH_PATH=/path/to/your/localnet.sh pytest tests/e2e_tests +``` + +If you want to use legacy runner even with installed Docker in your OS +```bash +USE_DOCKER=0 BUILD_BINARY=0 LOCALNET_SH_PATH=/path/to/your/localnet.sh pytest tests/e2e_tests +``` + --- ## Release Guidelines diff --git a/VERSION b/VERSION index e977f5eae6..85f864fe85 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.1.0 \ No newline at end of file +9.2.0 \ No newline at end of file diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 546be4a9d0..7eb4f04eb2 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -1575,6 +1575,117 @@ async def get_stake( return Balance.from_rao(int(stake)).set_unit(netuid=netuid) + async def get_stake_add_fee( + self, + amount: Balance, + netuid: int, + coldkey_ss58: str, + hotkey_ss58: str, + block: Optional[int] = None, + ) -> Balance: + """ + Calculates the fee for adding new stake to a hotkey. + + Args: + amount: Amount of stake to add in TAO + netuid: Netuid of subnet + coldkey_ss58: SS58 address of source coldkey + hotkey_ss58: SS58 address of destination hotkey + block: Block number at which to perform the calculation + + Returns: + The calculated stake fee as a Balance object + """ + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + None, + coldkey_ss58, + (hotkey_ss58, netuid), + coldkey_ss58, + amount.rao, + ], + block=block, + ) + return Balance.from_rao(result) + + async def get_unstake_fee( + self, + amount: Balance, + netuid: int, + coldkey_ss58: str, + hotkey_ss58: str, + block: Optional[int] = None, + ) -> Balance: + """ + Calculates the fee for unstaking from a hotkey. + + Args: + amount: Amount of stake to unstake in TAO + netuid: Netuid of subnet + coldkey_ss58: SS58 address of source coldkey + hotkey_ss58: SS58 address of destination hotkey + block: Block number at which to perform the calculation + + Returns: + The calculated stake fee as a Balance object + """ + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + None, + coldkey_ss58, + (hotkey_ss58, netuid), + coldkey_ss58, + amount.rao, + ], + block=block, + ) + return Balance.from_rao(result) + + async def get_stake_movement_fee( + self, + amount: Balance, + origin_netuid: int, + origin_hotkey_ss58: str, + origin_coldkey_ss58: str, + destination_netuid: int, + destination_hotkey_ss58: str, + destination_coldkey_ss58: str, + block: Optional[int] = None, + ) -> Balance: + """ + Calculates the fee for moving stake between hotkeys/subnets/coldkeys. + + Args: + amount: Amount of stake to move in TAO + origin_netuid: Netuid of source subnet + origin_hotkey_ss58: SS58 address of source hotkey + origin_coldkey_ss58: SS58 address of source coldkey + destination_netuid: Netuid of destination subnet + destination_hotkey_ss58: SS58 address of destination hotkey + destination_coldkey_ss58: SS58 address of destination coldkey + block: Block number at which to perform the calculation + + Returns: + The calculated stake fee as a Balance object + """ + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + (origin_hotkey_ss58, origin_netuid), + origin_coldkey_ss58, + (destination_hotkey_ss58, destination_netuid), + destination_coldkey_ss58, + amount.rao, + ], + block=block, + ) + return Balance.from_rao(result) + async def get_stake_for_coldkey_and_hotkey( self, coldkey_ss58: str, diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index 3e4174f950..8a53f6c423 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -1,4 +1,4 @@ -__version__ = "9.1.0" +__version__ = "9.2.0" import os import re diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 244080ff51..e113c1b033 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -1210,6 +1210,117 @@ def get_stake( return Balance.from_rao(int(stake)).set_unit(netuid=netuid) + def get_stake_add_fee( + self, + amount: Balance, + netuid: int, + coldkey_ss58: str, + hotkey_ss58: str, + block: Optional[int] = None, + ) -> Balance: + """ + Calculates the fee for adding new stake to a hotkey. + + Args: + amount: Amount of stake to add in TAO + netuid: Netuid of subnet + coldkey_ss58: SS58 address of coldkey + hotkey_ss58: SS58 address of hotkey + block: Block number at which to perform the calculation + + Returns: + The calculated stake fee as a Balance object + """ + result = self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + None, + coldkey_ss58, + (hotkey_ss58, netuid), + coldkey_ss58, + amount.rao, + ], + block=block, + ) + return Balance.from_rao(result) + + def get_unstake_fee( + self, + amount: Balance, + netuid: int, + coldkey_ss58: str, + hotkey_ss58: str, + block: Optional[int] = None, + ) -> Balance: + """ + Calculates the fee for unstaking from a hotkey. + + Args: + amount: Amount of stake to unstake in TAO + netuid: Netuid of subnet + coldkey_ss58: SS58 address of coldkey + hotkey_ss58: SS58 address of hotkey + block: Block number at which to perform the calculation + + Returns: + The calculated stake fee as a Balance object + """ + result = self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + (hotkey_ss58, netuid), + coldkey_ss58, + None, + coldkey_ss58, + amount.rao, + ], + block=block, + ) + return Balance.from_rao(result) + + def get_stake_movement_fee( + self, + amount: Balance, + origin_netuid: int, + origin_hotkey_ss58: str, + origin_coldkey_ss58: str, + destination_netuid: int, + destination_hotkey_ss58: str, + destination_coldkey_ss58: str, + block: Optional[int] = None, + ) -> Balance: + """ + Calculates the fee for moving stake between hotkeys/subnets/coldkeys. + + Args: + amount: Amount of stake to move in TAO + origin_netuid: Netuid of origin subnet + origin_hotkey_ss58: SS58 address of origin hotkey + origin_coldkey_ss58: SS58 address of origin coldkey + destination_netuid: Netuid of destination subnet + destination_hotkey_ss58: SS58 address of destination hotkey + destination_coldkey_ss58: SS58 address of destination coldkey + block: Block number at which to perform the calculation + + Returns: + The calculated stake fee as a Balance object + """ + result = self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + (origin_hotkey_ss58, origin_netuid), + origin_coldkey_ss58, + (destination_hotkey_ss58, destination_netuid), + destination_coldkey_ss58, + amount.rao, + ], + block=block, + ) + return Balance.from_rao(result) + def get_stake_for_coldkey_and_hotkey( self, coldkey_ss58: str, diff --git a/pyproject.toml b/pyproject.toml index c95f3ba4f7..2c01c2858d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "9.1.0" +version = "9.2.0" description = "Bittensor" readme = "README.md" authors = [ {name = "bittensor.com"} ] license = { file = "LICENSE" } -requires-python = ">=3.9,<3.13" +requires-python = ">=3.9,<3.14" dependencies = [ "wheel", "setuptools~=70.0.0", @@ -38,7 +38,7 @@ dependencies = [ "websockets>=14.1", "bittensor-commit-reveal>=0.2.0", "bittensor-wallet>=3.0.4", - "async-substrate-interface>=1.0.5" + "async-substrate-interface>=1.0.8" ] [project.optional-dependencies] diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py index b4770ed053..51a865b1b8 100644 --- a/tests/e2e_tests/conftest.py +++ b/tests/e2e_tests/conftest.py @@ -1,10 +1,12 @@ import os import re import shlex +import shutil import signal import subprocess -import time +import sys import threading +import time import pytest from async_substrate_interface import SubstrateInterface @@ -18,10 +20,66 @@ ) -# Fixture for setting up and tearing down a localnet.sh chain between tests +def wait_for_node_start(process, timestamp=None): + """Waits for node to start in the docker.""" + while True: + line = process.stdout.readline() + if not line: + break + + timestamp = timestamp or int(time.time()) + print(line.strip()) + # 10 min as timeout + if int(time.time()) - timestamp > 20 * 30: + print("Subtensor not started in time") + raise TimeoutError + + pattern = re.compile(r"Imported #1") + if pattern.search(line): + print("Node started!") + break + + # Start a background reader after pattern is found + # To prevent the buffer filling up + def read_output(): + while True: + if not process.stdout.readline(): + break + + reader_thread = threading.Thread(target=read_output, daemon=True) + reader_thread.start() + + @pytest.fixture(scope="function") def local_chain(request): - param = request.param if hasattr(request, "param") else None + """Determines whether to run the localnet.sh script in a subprocess or a Docker container.""" + args = request.param if hasattr(request, "param") else None + params = "" if args is None else f"{args}" + if shutil.which("docker") and not os.getenv("USE_DOCKER") == "0": + yield from docker_runner(params) + else: + if not os.getenv("USE_DOCKER") == "0": + if sys.platform.startswith("linux"): + docker_command = ( + "Install docker with command " + "[blue]sudo apt-get update && sudo apt-get install docker.io -y[/blue]" + " or use documentation [blue]https://docs.docker.com/engine/install/[/blue]" + ) + elif sys.platform == "darwin": + docker_command = ( + "Install docker with command [blue]brew install docker[/blue]" + ) + else: + docker_command = "[blue]Unknown OS, install Docker manually: https://docs.docker.com/get-docker/[/blue]" + + logging.warning("Docker not found in the operating system!") + logging.warning(docker_command) + logging.warning("Tests are run in legacy mode.") + yield from legacy_runner(request) + + +def legacy_runner(params): + """Runs the localnet.sh script in a subprocess and waits for it to start.""" # Get the environment variable for the script path script_path = os.getenv("LOCALNET_SH_PATH") @@ -31,41 +89,11 @@ def local_chain(request): pytest.skip("LOCALNET_SH_PATH environment variable is not set.") # Check if param is None, and handle it accordingly - args = "" if param is None else f"{param}" + args = "" if params is None else f"{params}" # Compile commands to send to process cmds = shlex.split(f"{script_path} {args}") - # Pattern match indicates node is compiled and ready - pattern = re.compile(r"Imported #1") - timestamp = int(time.time()) - - def wait_for_node_start(process, pattern): - while True: - line = process.stdout.readline() - if not line: - break - - print(line.strip()) - # 10 min as timeout - if int(time.time()) - timestamp > 20 * 60: - print("Subtensor not started in time") - raise TimeoutError - if pattern.search(line): - print("Node started!") - break - - # Start a background reader after pattern is found - # To prevent the buffer filling up - def read_output(): - while True: - line = process.stdout.readline() - if not line: - break - - reader_thread = threading.Thread(target=read_output, daemon=True) - reader_thread.start() - with subprocess.Popen( cmds, start_new_session=True, @@ -74,11 +102,12 @@ def read_output(): text=True, ) as process: try: - wait_for_node_start(process, pattern) + wait_for_node_start(process) except TimeoutError: raise else: - yield SubstrateInterface(url="ws://127.0.0.1:9944") + with SubstrateInterface(url="ws://127.0.0.1:9944") as substrate: + yield substrate finally: # Terminate the process group (includes all child processes) os.killpg(os.getpgid(process.pid), signal.SIGTERM) @@ -91,6 +120,100 @@ def read_output(): process.wait() +def docker_runner(params): + """Starts a Docker container before tests and gracefully terminates it after.""" + + def is_docker_running(): + """Check if Docker has been run.""" + try: + subprocess.run( + ["docker", "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return True + except subprocess.CalledProcessError: + return False + + def try_start_docker(): + """Run docker based on OS.""" + try: + subprocess.run(["open", "-a", "Docker"], check=True) # macOS + except (FileNotFoundError, subprocess.CalledProcessError): + try: + subprocess.run(["systemctl", "start", "docker"], check=True) # Linux + except (FileNotFoundError, subprocess.CalledProcessError): + try: + subprocess.run( + ["sudo", "service", "docker", "start"], check=True + ) # Linux alternative + except (FileNotFoundError, subprocess.CalledProcessError): + print("Failed to start Docker. Manual start may be required.") + return False + + # Wait Docker run 10 attempts with 3 sec waits + for _ in range(10): + if is_docker_running(): + return True + time.sleep(3) + + print("Docker wasn't run. Manual start may be required.") + return False + + container_name = f"test_local_chain_{str(time.time()).replace(".", "_")}" + image_name = "ghcr.io/opentensor/subtensor-localnet:latest" + + # Command to start container + cmds = [ + "docker", + "run", + "--rm", + "--name", + container_name, + "-p", + "9944:9944", + "-p", + "9945:9945", + image_name, + params, + ] + + try_start_docker() + + # Start container + with subprocess.Popen( + cmds, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) as process: + try: + try: + wait_for_node_start(process, timestamp=int(time.time())) + except TimeoutError: + raise + + result = subprocess.run( + ["docker", "ps", "-q", "-f", f"name={container_name}"], + capture_output=True, + text=True, + ) + if not result.stdout.strip(): + raise RuntimeError("Docker container failed to start.") + + with SubstrateInterface(url="ws://127.0.0.1:9944") as substrate: + yield substrate + + finally: + try: + subprocess.run(["docker", "kill", container_name]) + process.wait() + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + + @pytest.fixture(scope="session") def templates(): with Templates() as templates: diff --git a/tests/e2e_tests/test_incentive.py b/tests/e2e_tests/test_incentive.py index 0467a0cd81..ac71a54fc2 100644 --- a/tests/e2e_tests/test_incentive.py +++ b/tests/e2e_tests/test_incentive.py @@ -5,6 +5,7 @@ from tests.e2e_tests.utils.chain_interactions import ( sudo_set_admin_utils, wait_epoch, + wait_interval, ) @@ -66,11 +67,15 @@ async def test_incentive(local_chain, subtensor, templates, alice_wallet, bob_wa assert bob_neuron.trust == 0 # update weights_set_rate_limit for fast-blocks + tempo = subtensor.tempo(netuid) status, error = sudo_set_admin_utils( local_chain, alice_wallet, call_function="sudo_set_weights_set_rate_limit", - call_params={"netuid": netuid, "weights_set_rate_limit": 10}, + call_params={ + "netuid": netuid, + "weights_set_rate_limit": tempo, + }, ) assert error is None @@ -82,8 +87,8 @@ async def test_incentive(local_chain, subtensor, templates, alice_wallet, bob_wa async with asyncio.timeout(60): await validator.set_weights.wait() - # Wait few epochs - await wait_epoch(subtensor, netuid, times=4) + # Wait till new epoch + await wait_interval(tempo, subtensor, netuid) # Refresh metagraph metagraph = subtensor.metagraph(netuid) diff --git a/tests/e2e_tests/test_stake_fee.py b/tests/e2e_tests/test_stake_fee.py new file mode 100644 index 0000000000..32062b6c6c --- /dev/null +++ b/tests/e2e_tests/test_stake_fee.py @@ -0,0 +1,117 @@ +import pytest +from bittensor import Balance + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +@pytest.mark.asyncio +async def test_stake_fee_api(local_chain, subtensor, alice_wallet, bob_wallet): + """ + Tests the stake fee calculation mechanism for various staking operations + + Steps: + 1. Register a subnet through Alice + 2. Test stake fees for: + - Adding new stake + - Removing stake + - Moving stake between hotkeys/subnets/coldkeys + """ + + netuid = 2 + root_netuid = 0 + stake_amount = Balance.from_tao(100) # 100 TAO + min_stake_fee = Balance.from_rao(50_000) + + # Register subnet as Alice + assert subtensor.register_subnet(alice_wallet), "Unable to register the subnet" + assert subtensor.subnet_exists(netuid), "Subnet wasn't created successfully" + + # Test add_stake fee + stake_fee_0 = subtensor.get_stake_add_fee( + amount=stake_amount, + netuid=netuid, + coldkey_ss58=alice_wallet.coldkeypub.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + assert isinstance(stake_fee_0, Balance), "Stake fee should be a Balance object" + assert ( + stake_fee_0 >= min_stake_fee + ), "Stake fee should be greater than the minimum stake fee" + + # Test unstake fee + stake_fee_1 = subtensor.get_unstake_fee( + amount=stake_amount, + netuid=root_netuid, + coldkey_ss58=alice_wallet.coldkeypub.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + assert isinstance(stake_fee_1, Balance), "Stake fee should be a Balance object" + assert ( + stake_fee_1 >= min_stake_fee + ), "Stake fee should be greater than the minimum stake fee" + + # Test various stake movement scenarios + movement_scenarios = [ + # Move from root to non-root + { + "origin_netuid": root_netuid, + "origin_hotkey": alice_wallet.hotkey.ss58_address, + "origin_coldkey": alice_wallet.coldkeypub.ss58_address, + "dest_netuid": netuid, + "dest_hotkey": alice_wallet.hotkey.ss58_address, + "dest_coldkey": alice_wallet.coldkeypub.ss58_address, + }, + # Move between hotkeys on root + { + "origin_netuid": root_netuid, + "origin_hotkey": alice_wallet.hotkey.ss58_address, + "origin_coldkey": alice_wallet.coldkeypub.ss58_address, + "dest_netuid": root_netuid, + "dest_hotkey": bob_wallet.hotkey.ss58_address, + "dest_coldkey": alice_wallet.coldkeypub.ss58_address, + }, + # Move between coldkeys + { + "origin_netuid": root_netuid, + "origin_hotkey": bob_wallet.hotkey.ss58_address, + "origin_coldkey": alice_wallet.coldkeypub.ss58_address, + "dest_netuid": root_netuid, + "dest_hotkey": bob_wallet.hotkey.ss58_address, + "dest_coldkey": bob_wallet.coldkeypub.ss58_address, + }, + ] + + for scenario in movement_scenarios: + stake_fee = subtensor.get_stake_movement_fee( + amount=stake_amount, + origin_netuid=scenario["origin_netuid"], + origin_hotkey_ss58=scenario["origin_hotkey"], + origin_coldkey_ss58=scenario["origin_coldkey"], + destination_netuid=scenario["dest_netuid"], + destination_hotkey_ss58=scenario["dest_hotkey"], + destination_coldkey_ss58=scenario["dest_coldkey"], + ) + assert isinstance(stake_fee, Balance), "Stake fee should be a Balance object" + assert ( + stake_fee >= min_stake_fee + ), "Stake fee should be greater than the minimum stake fee" + + # Test cross-subnet movement + netuid2 = 3 + assert subtensor.register_subnet( + alice_wallet + ), "Unable to register the second subnet" + assert subtensor.subnet_exists(netuid2), "Second subnet wasn't created successfully" + + stake_fee = subtensor.get_stake_movement_fee( + amount=stake_amount, + origin_netuid=netuid, + origin_hotkey_ss58=bob_wallet.hotkey.ss58_address, + origin_coldkey_ss58=alice_wallet.coldkeypub.ss58_address, + destination_netuid=netuid2, + destination_hotkey_ss58=bob_wallet.hotkey.ss58_address, + destination_coldkey_ss58=alice_wallet.coldkeypub.ss58_address, + ) + assert isinstance(stake_fee, Balance), "Stake fee should be a Balance object" + assert ( + stake_fee >= min_stake_fee + ), "Stake fee should be greater than the minimum stake fee" diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 2d7f1efb78..3af7f70072 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -3242,3 +3242,93 @@ def test_get_timestamp(mocker, subtensor): ) actual_result = subtensor.get_timestamp(block=fake_block) assert expected_result == actual_result + + +def test_stake_fee_methods(mocker, subtensor): + """Test the three stake fee calculation methods.""" + # Mock data + fake_hotkey = "hk1" + fake_coldkey = "ck1" + fake_amount = Balance.from_tao(100) + netuid = 1 + fake_fee = 1_000_000 + + # Mock return fee + mock_query = mocker.patch.object( + subtensor, + "query_runtime_api", + side_effect=lambda runtime_api, method, params, block: ( + fake_fee + if runtime_api == "StakeInfoRuntimeApi" and method == "get_stake_fee" + else None + ), + ) + + # get_stake_add_fee + result = subtensor.get_stake_add_fee( + amount=fake_amount, + netuid=netuid, + coldkey_ss58=fake_coldkey, + hotkey_ss58=fake_hotkey, + ) + assert isinstance(result, Balance) + assert result == Balance.from_rao(fake_fee) + mock_query.assert_called_with( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + None, + fake_coldkey, + (fake_hotkey, netuid), + fake_coldkey, + fake_amount.rao, + ], + block=None, + ) + + # get_unstake_fee + result = subtensor.get_unstake_fee( + amount=fake_amount, + netuid=netuid, + coldkey_ss58=fake_coldkey, + hotkey_ss58=fake_hotkey, + ) + assert isinstance(result, Balance) + assert result == Balance.from_rao(fake_fee) + mock_query.assert_called_with( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + (fake_hotkey, netuid), + fake_coldkey, + None, + fake_coldkey, + fake_amount.rao, + ], + block=None, + ) + + # get_stake_movement_fee + result = subtensor.get_stake_movement_fee( + amount=fake_amount, + origin_netuid=2, + origin_hotkey_ss58=fake_hotkey, + origin_coldkey_ss58=fake_coldkey, + destination_netuid=3, + destination_hotkey_ss58="hk2", + destination_coldkey_ss58=fake_coldkey, + ) + assert isinstance(result, Balance) + assert result == Balance.from_rao(fake_fee) + mock_query.assert_called_with( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + (fake_hotkey, 2), + fake_coldkey, + ("hk2", 3), + fake_coldkey, + fake_amount.rao, + ], + block=None, + )