From 8af655dcdfc6f9d77f2ab18105cee83d262a93d1 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Mon, 18 May 2026 18:51:43 +0200 Subject: [PATCH 01/18] Fixes tests in subtensor 2569 --- bittensor/core/errors.py | 2 ++ tests/unit_tests/test_errors.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/bittensor/core/errors.py b/bittensor/core/errors.py index e616a9fccd..9e8bec3385 100644 --- a/bittensor/core/errors.py +++ b/bittensor/core/errors.py @@ -285,6 +285,8 @@ def __init__( 4: HotKeyAccountNotExists, 6: TxRateLimitExceeded, 25: NonAssociatedColdKey, + 26: DelegateTakeTooLow, + 27: DelegateTakeTooHigh, } diff --git a/tests/unit_tests/test_errors.py b/tests/unit_tests/test_errors.py index 59da9d3e5a..2d4d1da82e 100644 --- a/tests/unit_tests/test_errors.py +++ b/tests/unit_tests/test_errors.py @@ -2,6 +2,8 @@ from bittensor.core.errors import ( ChainError, + DelegateTakeTooHigh, + DelegateTakeTooLow, HotKeyAccountNotExists, NonAssociatedColdKey, SubnetNotExists, @@ -112,6 +114,14 @@ def test_maps_subnet_not_exists(self): exc = chain_error_from_substrate_exception(_validity_error(3)) assert isinstance(exc, SubnetNotExists) + def test_maps_delegate_take_too_low(self): + exc = chain_error_from_substrate_exception(_validity_error(26)) + assert isinstance(exc, DelegateTakeTooLow) + + def test_maps_delegate_take_too_high(self): + exc = chain_error_from_substrate_exception(_validity_error(27)) + assert isinstance(exc, DelegateTakeTooHigh) + def test_unmapped_code_returns_none(self): assert chain_error_from_substrate_exception(_validity_error(255)) is None From 59ae06f8ce94e44aa4bfc6cf7cdf8062ee170656 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 19 May 2026 14:15:52 -0700 Subject: [PATCH 02/18] extend waiting time in test --- tests/e2e_tests/test_staking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e_tests/test_staking.py b/tests/e2e_tests/test_staking.py index 0060957d81..fc96720ef1 100644 --- a/tests/e2e_tests/test_staking.py +++ b/tests/e2e_tests/test_staking.py @@ -1738,7 +1738,7 @@ def test_unstaking_with_limit( ).success # time for chain update - subtensor.wait_for_block() + subtensor.wait_for_block(subtensor.block + 5) # Make sure both unstake were successful. bob_stakes = subtensor.staking.get_stake_info_for_coldkey( @@ -1839,7 +1839,7 @@ async def test_unstaking_with_limit_async( ).success # time for chain update - await async_subtensor.wait_for_block() + await async_subtensor.wait_for_block(await async_subtensor.block + 5) # Make sure both unstake were successful. bob_stakes = await async_subtensor.staking.get_stake_info_for_coldkey( From caf38ccf894bb3ce550993c1f6397941d783d33a Mon Sep 17 00:00:00 2001 From: BD Himes Date: Fri, 22 May 2026 17:19:07 +0200 Subject: [PATCH 03/18] Improves monitor_requirements_size_master workflow --- .../monitor_requirements_size_master.yml | 121 +++++++++++++----- 1 file changed, 87 insertions(+), 34 deletions(-) diff --git a/.github/workflows/monitor_requirements_size_master.yml b/.github/workflows/monitor_requirements_size_master.yml index bb879689c1..16a1c2aa6d 100644 --- a/.github/workflows/monitor_requirements_size_master.yml +++ b/.github/workflows/monitor_requirements_size_master.yml @@ -1,18 +1,17 @@ -# This workflow measures the disk size of a virtual environment -# after installing the Bittensor SDK across multiple Python versions. -# It runs only when a new pull request targets the master branch, -# and posts a comment with the results. +# This workflow measures the disk size of a virtual environment after +# installing the Bittensor SDK across multiple Python versions, for both +# the PR base ref and the PR head ref. It posts (or updates) a single +# sticky comment on the PR comparing Before / After / Δ per Python version. name: Monitor SDK Requirements Size on: pull_request: - types: [opened, labeled] + types: [opened, labeled, synchronize] branches: [master, staging] permissions: pull-requests: write contents: read - issues: write jobs: read-python-versions: @@ -28,19 +27,20 @@ jobs: measure-venv: needs: read-python-versions - if: github.event_name == 'pull_request' && github.base_ref == 'master' || contains( github.event.pull_request.labels.*.name, 'show-venv-size') + if: github.event_name == 'pull_request' && (github.base_ref == 'master' || contains(github.event.pull_request.labels.*.name, 'show-venv-size')) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ${{ fromJson(needs.read-python-versions.outputs.python-versions) }} - outputs: - py310: ${{ steps.set-output.outputs.py310 }} - py311: ${{ steps.set-output.outputs.py311 }} - py312: ${{ steps.set-output.outputs.py312 }} - py313: ${{ steps.set-output.outputs.py313 }} + ref: [base, head] steps: - - uses: actions/checkout@v6 + - name: Checkout ${{ matrix.ref }} + uses: actions/checkout@v6 + with: + ref: ${{ matrix.ref == 'base' && github.event.pull_request.base.sha || github.event.pull_request.head.sha }} + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -53,46 +53,99 @@ jobs: pip install . - name: Measure venv size - id: set-output run: | SIZE=$(du -sm venv | cut -f1) - VERSION=${{ matrix.python-version }} - echo "Detected size: $SIZE MB for Python $VERSION" - case "$VERSION" in - 3.10) echo "py310=$SIZE" >> $GITHUB_OUTPUT ;; - 3.11) echo "py311=$SIZE" >> $GITHUB_OUTPUT ;; - 3.12) echo "py312=$SIZE" >> $GITHUB_OUTPUT ;; - 3.13) echo "py313=$SIZE" >> $GITHUB_OUTPUT ;; - esac + echo "Detected size: $SIZE MB for Python ${{ matrix.python-version }} (${{ matrix.ref }})" + echo "$SIZE" > size.txt + + - name: Upload size artifact + uses: actions/upload-artifact@v4 + with: + name: size-${{ matrix.ref }}-py${{ matrix.python-version }} + path: size.txt + retention-days: 1 comment-on-pr: needs: measure-venv + if: always() && needs.measure-venv.result != 'skipped' runs-on: ubuntu-latest steps: + - name: Download size artifacts + uses: actions/download-artifact@v4 + with: + path: sizes + pattern: size-* + - name: Post venv size summary to PR uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const sizes = { - "3.10": "${{ needs.measure-venv.outputs.py310 || 'N/A' }}", - "3.11": "${{ needs.measure-venv.outputs.py311 || 'N/A' }}", - "3.12": "${{ needs.measure-venv.outputs.py312 || 'N/A' }}", - "3.13": "${{ needs.measure-venv.outputs.py313 || 'N/A' }}", + const fs = require('fs'); + const path = require('path'); + + const rows = {}; + for (const entry of fs.readdirSync('sizes')) { + const m = entry.match(/^size-(base|head)-py(.+)$/); + if (!m) continue; + const [, ref, version] = m; + const size = parseInt( + fs.readFileSync(path.join('sizes', entry, 'size.txt'), 'utf8').trim(), + 10, + ); + rows[version] ??= {}; + rows[version][ref] = Number.isFinite(size) ? size : null; + } + + const versions = Object.keys(rows).sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ); + + const fmt = (n) => (n == null ? 'N/A' : `${n} MB`); + const fmtDelta = (before, after) => { + if (before == null || after == null) return 'N/A'; + const d = after - before; + if (d === 0) return '0 MB'; + return `${d > 0 ? '+' : ''}${d} MB`; }; + const lines = versions.map((v) => { + const { base, head } = rows[v]; + return `| ${v} | ${fmt(base)} | ${fmt(head)} | ${fmtDelta(base, head)} |`; + }); + + const marker = ''; const body = [ + marker, '**Bittensor SDK virtual environment sizes by Python version:**', '', - '```' - ] - .concat(Object.entries(sizes).map(([v, s]) => `Python ${v}: ${s} MB`)) - .concat(['```']) - .join('\n'); + `Comparing \`${context.payload.pull_request.base.sha.slice(0, 7)}\` (before) → \`${context.payload.pull_request.head.sha.slice(0, 7)}\` (after).`, + '', + '| Python | Before | After | Δ |', + '|---|---|---|---|', + ...lines, + ].join('\n'); - github.rest.issues.createComment({ + const { data: comments } = await github.rest.issues.listComments({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body + per_page: 100, }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } \ No newline at end of file From d608fe3aee42012d451fa7336aa19ca171e39bde Mon Sep 17 00:00:00 2001 From: BD Himes Date: Sat, 23 May 2026 09:59:51 +0200 Subject: [PATCH 04/18] Adds PR Guard workflow --- .github/workflows/pr-guard.yml | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/pr-guard.yml diff --git a/.github/workflows/pr-guard.yml b/.github/workflows/pr-guard.yml new file mode 100644 index 0000000000..3e336d9a2a --- /dev/null +++ b/.github/workflows/pr-guard.yml @@ -0,0 +1,56 @@ +name: PR guard + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +jobs: + target-branch: + if: github.base_ref == 'master' && !startsWith(github.head_ref, 'release') + runs-on: ubuntu-latest + steps: + - name: Comment and fail when targeting master from a non-release branch + uses: actions/github-script@v9 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'PRs need to be open against staging.', + }); + core.setFailed('PRs need to be open against the 'staging' branch.'); + + signed-commits: + runs-on: ubuntu-latest + steps: + - name: Check that all commits in the PR are signed + uses: actions/github-script@v9 + with: + script: | + const commits = await github.paginate( + github.rest.pulls.listCommits, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + } + ); + + const unsigned = commits.filter(c => !c.commit.verification || !c.commit.verification.verified); + + if (unsigned.length > 0) { + const list = unsigned.map(c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`).join('\n'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `PRs must use signed commits.\n\nUnsigned commits:\n${list}`, + }); + core.setFailed("PRs must use signed commits."); + } \ No newline at end of file From a03fc873d9720d1c14e6ff93771951f7f0a25c18 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Sat, 23 May 2026 10:00:06 +0200 Subject: [PATCH 05/18] Bumps github script of other workflows --- .github/workflows/changelog-checker.yml | 2 +- .github/workflows/monitor_requirements_size_master.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changelog-checker.yml b/.github/workflows/changelog-checker.yml index a5038e860b..cd07fbce1e 100644 --- a/.github/workflows/changelog-checker.yml +++ b/.github/workflows/changelog-checker.yml @@ -19,6 +19,6 @@ jobs: id: changed - name: Ensure CHANGELOG.md updated if: contains(steps.changed.outputs.all_changed_files, 'CHANGELOG.md') == false - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: core.setFailed('CHANGELOG.md must be updated.') diff --git a/.github/workflows/monitor_requirements_size_master.yml b/.github/workflows/monitor_requirements_size_master.yml index 16a1c2aa6d..4ab035ee32 100644 --- a/.github/workflows/monitor_requirements_size_master.yml +++ b/.github/workflows/monitor_requirements_size_master.yml @@ -77,7 +77,7 @@ jobs: pattern: size-* - name: Post venv size summary to PR - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | From 072900230f69fcadabf9c25253208e5c1dd5ec82 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 15:02:34 -0700 Subject: [PATCH 06/18] update pallets --- .../extrinsics/pallets/subtensor_module.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/bittensor/core/extrinsics/pallets/subtensor_module.py b/bittensor/core/extrinsics/pallets/subtensor_module.py index 0dddc4c3e8..9f6daf5f17 100644 --- a/bittensor/core/extrinsics/pallets/subtensor_module.py +++ b/bittensor/core/extrinsics/pallets/subtensor_module.py @@ -215,6 +215,47 @@ def increase_take(self, hotkey: str, take: int) -> Call: """ return self.create_composed_call(hotkey=hotkey, take=take) + def lock_stake( + self, + hotkey: str, + netuid: int, + amount: int, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.lock_stake. + + Parameters: + hotkey: The hotkey SS58 address to lock stake on. + netuid: The subnet UID on which to lock. + amount: Amount of alpha in RAO to lock. + + Returns: + GenericCall instance. + """ + return self.create_composed_call( + hotkey=hotkey, + netuid=netuid, + amount=amount, + ) + + def move_lock( + self, + destination_hotkey: str, + netuid: int, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.move_lock. + + Parameters: + destination_hotkey: The SS58 address of the hotkey to move the lock to. + netuid: The subnet UID on which the lock exists. + + Returns: + GenericCall instance. + """ + return self.create_composed_call( + destination_hotkey=destination_hotkey, + netuid=netuid, + ) + def move_stake( self, origin_netuid: int, @@ -622,6 +663,25 @@ def set_pending_childkey_cooldown( """ return self.create_composed_call(cooldown=cooldown) + def set_perpetual_lock( + self, + netuid: int, + enabled: bool, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.set_perpetual_lock. + + Parameters: + netuid: The subnet UID for which to set the perpetual lock flag. + enabled: If True, the lock will not decay. If False, normal decay resumes. + + Returns: + GenericCall instance. + """ + return self.create_composed_call( + netuid=netuid, + enabled=enabled, + ) + def set_root_claim_type( self, new_root_claim_type: Literal["Swap", "Keep"] | dict, From a761f977ea8112a4b3397172ee05455d30d24508 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 15:02:43 -0700 Subject: [PATCH 07/18] add extrinsics --- bittensor/core/extrinsics/asyncex/lock.py | 238 +++++++++++++++++++++ bittensor/core/extrinsics/lock.py | 239 ++++++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 bittensor/core/extrinsics/asyncex/lock.py create mode 100644 bittensor/core/extrinsics/lock.py diff --git a/bittensor/core/extrinsics/asyncex/lock.py b/bittensor/core/extrinsics/asyncex/lock.py new file mode 100644 index 0000000000..711e03e6a9 --- /dev/null +++ b/bittensor/core/extrinsics/asyncex/lock.py @@ -0,0 +1,238 @@ +from typing import Optional, TYPE_CHECKING + +from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance +from bittensor.utils.btlogging import logging + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.async_subtensor import AsyncSubtensor + + +async def lock_stake_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + amount: Balance, + *, + 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: + """ + Locks alpha stake on a hotkey within a subnet, building conviction over time. + + Parameters: + subtensor: AsyncSubtensor instance. + wallet: The wallet whose coldkey owns the stake to lock. + hotkey_ss58: The SS58 address of the hotkey to lock stake on. + netuid: The subnet UID on which to lock. + amount: Amount of alpha to lock. + mev_protection: If True, encrypts and submits the transaction through MEV Shield. + period: Number of blocks during which the transaction remains valid. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Whether to wait for inclusion in a block. + wait_for_finalization: Whether to wait for finalization. + wait_for_revealed_execution: Whether to wait for revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Locking stake on hotkey [blue]{hotkey_ss58}[/blue] " + f"on subnet [yellow]{netuid}[/yellow], amount: [green]{amount}[/green]" + ) + + call = await SubtensorModule(subtensor).lock_stake( + hotkey=hotkey_ss58, + netuid=netuid, + amount=amount.rao, + ) + + 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]Lock stake finalized[/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 move_lock_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + destination_hotkey_ss58: str, + netuid: int, + *, + 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: + """ + Moves an existing lock from its current hotkey to a different hotkey on the same subnet. + + Parameters: + subtensor: AsyncSubtensor instance. + wallet: The wallet whose coldkey owns the lock. + destination_hotkey_ss58: The SS58 address of the hotkey to move the lock to. + netuid: The subnet UID on which the lock exists. + mev_protection: If True, encrypts and submits the transaction through MEV Shield. + period: Number of blocks during which the transaction remains valid. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Whether to wait for inclusion in a block. + wait_for_finalization: Whether to wait for finalization. + wait_for_revealed_execution: Whether to wait for revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Moving lock to hotkey [blue]{destination_hotkey_ss58}[/blue] " + f"on subnet [yellow]{netuid}[/yellow]" + ) + + call = await SubtensorModule(subtensor).move_lock( + destination_hotkey=destination_hotkey_ss58, + netuid=netuid, + ) + + 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]Move lock finalized[/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 set_perpetual_lock_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + enabled: bool, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> ExtrinsicResponse: + """ + Sets or clears the perpetual lock flag for the caller's lock on a subnet. + + When enabled, the lock does not decay over time. When disabled, normal decay resumes. + + Parameters: + subtensor: AsyncSubtensor instance. + wallet: The wallet whose coldkey owns the lock. + netuid: The subnet UID for which to set the perpetual lock flag. + enabled: If True, the lock will not decay. If False, normal decay resumes. + period: Number of blocks during which the transaction remains valid. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Whether to wait for inclusion in a block. + wait_for_finalization: Whether to wait for finalization. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Setting perpetual lock to [green]{enabled}[/green] " + f"on subnet [yellow]{netuid}[/yellow]" + ) + + call = await SubtensorModule(subtensor).set_perpetual_lock( + netuid=netuid, + enabled=enabled, + ) + + 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]Set perpetual lock finalized[/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/lock.py b/bittensor/core/extrinsics/lock.py new file mode 100644 index 0000000000..7d41d0680b --- /dev/null +++ b/bittensor/core/extrinsics/lock.py @@ -0,0 +1,239 @@ +from typing import Optional, TYPE_CHECKING + +from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance +from bittensor.utils.btlogging import logging + + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.subtensor import Subtensor + + +def lock_stake_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + amount: Balance, + *, + 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: + """ + Locks alpha stake on a hotkey within a subnet, building conviction over time. + + Parameters: + subtensor: Subtensor instance. + wallet: The wallet whose coldkey owns the stake to lock. + hotkey_ss58: The SS58 address of the hotkey to lock stake on. + netuid: The subnet UID on which to lock. + amount: Amount of alpha to lock. + mev_protection: If True, encrypts and submits the transaction through MEV Shield. + period: Number of blocks during which the transaction remains valid. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Whether to wait for inclusion in a block. + wait_for_finalization: Whether to wait for finalization. + wait_for_revealed_execution: Whether to wait for revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Locking stake on hotkey [blue]{hotkey_ss58}[/blue] " + f"on subnet [yellow]{netuid}[/yellow], amount: [green]{amount}[/green]" + ) + + call = SubtensorModule(subtensor).lock_stake( + hotkey=hotkey_ss58, + netuid=netuid, + amount=amount.rao, + ) + + 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]Lock stake finalized[/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 move_lock_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + destination_hotkey_ss58: str, + netuid: int, + *, + 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: + """ + Moves an existing lock from its current hotkey to a different hotkey on the same subnet. + + Parameters: + subtensor: Subtensor instance. + wallet: The wallet whose coldkey owns the lock. + destination_hotkey_ss58: The SS58 address of the hotkey to move the lock to. + netuid: The subnet UID on which the lock exists. + mev_protection: If True, encrypts and submits the transaction through MEV Shield. + period: Number of blocks during which the transaction remains valid. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Whether to wait for inclusion in a block. + wait_for_finalization: Whether to wait for finalization. + wait_for_revealed_execution: Whether to wait for revealed execution if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Moving lock to hotkey [blue]{destination_hotkey_ss58}[/blue] " + f"on subnet [yellow]{netuid}[/yellow]" + ) + + call = SubtensorModule(subtensor).move_lock( + destination_hotkey=destination_hotkey_ss58, + netuid=netuid, + ) + + 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]Move lock finalized[/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 set_perpetual_lock_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + enabled: bool, + *, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> ExtrinsicResponse: + """ + Sets or clears the perpetual lock flag for the caller's lock on a subnet. + + When enabled, the lock does not decay over time. When disabled, normal decay resumes. + + Parameters: + subtensor: Subtensor instance. + wallet: The wallet whose coldkey owns the lock. + netuid: The subnet UID for which to set the perpetual lock flag. + enabled: If True, the lock will not decay. If False, normal decay resumes. + period: Number of blocks during which the transaction remains valid. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Whether to wait for inclusion in a block. + wait_for_finalization: Whether to wait for finalization. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + logging.debug( + f"Setting perpetual lock to [green]{enabled}[/green] " + f"on subnet [yellow]{netuid}[/yellow]" + ) + + call = SubtensorModule(subtensor).set_perpetual_lock( + netuid=netuid, + enabled=enabled, + ) + + 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]Set perpetual lock finalized[/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) From 2c717df65dda5f16029a92ab9282d9b7dc35eadd Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 15:03:03 -0700 Subject: [PATCH 08/18] add LockState type --- bittensor/core/types.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bittensor/core/types.py b/bittensor/core/types.py index 67ce355cf4..03d47d58ac 100644 --- a/bittensor/core/types.py +++ b/bittensor/core/types.py @@ -658,3 +658,18 @@ class DynamicInfoResponse(TypedDict): subnet_identity: SubnetIdentityResponse moving_price: FixedPoint price: NotRequired["Balance"] + + +class LockState(TypedDict): + """ + Exponential lock state for a coldkey on a subnet. + + Attributes: + locked_mass: Exponentially decaying locked amount (Balance in subnet Alpha). + conviction: Matured decaying score (integral of locked_mass over time). + last_update: Block number of last roll-forward. + """ + + locked_mass: "Balance" + conviction: float + last_update: int From faa08e5631c116bcc4ccba4ce0cca32f057a4654 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 15:03:27 -0700 Subject: [PATCH 09/18] add extrinsic wrapper calls --- bittensor/core/async_subtensor.py | 352 ++++++++++++++++++++++++++++++ bittensor/core/subtensor.py | 312 +++++++++++++++++++++++++- 2 files changed, 663 insertions(+), 1 deletion(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 00eb1a8e9c..4ba2459d87 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -81,6 +81,11 @@ toggle_user_liquidity_extrinsic, ) from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.asyncex.lock import ( + lock_stake_extrinsic, + move_lock_extrinsic, + set_perpetual_lock_extrinsic, +) from bittensor.core.extrinsics.asyncex.move_stake import ( move_stake_extrinsic, swap_stake_extrinsic, @@ -147,6 +152,7 @@ from bittensor.core.types import ( BlockInfo, ExtrinsicResponse, + LockState, Salt, SubtensorMixin, UIDs, @@ -1940,6 +1946,47 @@ async def get_children_pending( cooldown, ) + async def get_coldkey_lock( + self, + coldkey_ss58: str, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional["LockState"]: + """ + Returns the current lock for a coldkey on a subnet, rolled forward to the current block. + + Unlike get_stake_lock which returns the raw stored state, this method applies decay to return the actual current + lock values accounting for time elapsed since last update. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey. + netuid: The subnet UID to query. + block: The block number to query. Do not specify if using block_hash or reuse_block. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + LockState with current locked_mass, conviction, and last_update, or None if no lock exists. + """ + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_coldkey_lock", + params=[coldkey_ss58, netuid], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + if result is None: + return None + + return LockState( + locked_mass=Balance.from_rao(result["locked_mass"], netuid), + conviction=fixed_to_float(result["conviction"]), + last_update=int(result["last_update"]), + ) + async def get_coldkey_swap_announcement( self, coldkey_ss58: str, @@ -2781,6 +2828,39 @@ async def get_ema_tao_inflow( # TODO verify this from rao, seems like we're just rounding down return block_updated, Balance.from_rao(ema_value) + async def get_hotkey_conviction( + self, + hotkey_ss58: str, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> float: + """ + Gets the total conviction score for a hotkey on a subnet. + + Parameters: + hotkey_ss58: The SS58 address of the hotkey to query. + netuid: The subnet UID to query. + block: The block number to query. Do not specify if using block_hash or reuse_block. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The conviction score as a float. + """ + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_hotkey_conviction", + params=[hotkey_ss58, netuid], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + if result is None: + return 0.0 + return fixed_to_float(result) + async def get_hotkey_owner( self, hotkey_ss58: str, @@ -3359,6 +3439,34 @@ async def get_minimum_required_stake(self) -> Balance: return Balance.from_rao(result.value or 0) + async def get_most_convicted_hotkey_on_subnet( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional[str]: + """ + Gets the hotkey with the highest conviction score on a subnet (the "subnet king"). + + Parameters: + netuid: The subnet UID to query. + block: The block number to query. Do not specify if using block_hash or reuse_block. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The SS58 address of the most convicted hotkey, or None if no locks exist. + """ + return await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_most_convicted_hotkey_on_subnet", + params=[netuid], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + async def get_netuids_for_hotkey( self, hotkey_ss58: str, @@ -4194,6 +4302,88 @@ async def get_stake_add_fee( ) return sim_swap_result.tao_fee + async def get_stake_lock( + self, + coldkey_ss58: str, + netuid: int, + hotkey_ss58: str, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Optional["LockState"]: + """ + Retrieves the lock state for a specific coldkey-netuid-hotkey combination. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey that owns the lock. + netuid: The subnet UID on which to query. + hotkey_ss58: The SS58 address of the hotkey the lock is on. + block: The block number to query. Do not specify if using block_hash or reuse_block. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + LockState dict with locked_mass, conviction, last_update, or None if no lock exists. + """ + query = await self.query_subtensor( + "Lock", + params=[coldkey_ss58, netuid, hotkey_ss58], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + if query.value is None: + return None + + value = cast(dict, query.value) + return LockState( + locked_mass=Balance.from_rao(value["locked_mass"], netuid), + conviction=fixed_to_float(value["conviction"]), + last_update=int(value["last_update"]), + ) + + async def get_stake_locks( + self, + coldkey_ss58: str, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> "list[tuple[str, LockState]]": + """ + Retrieves all lock states for a coldkey on a subnet. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey that owns the locks. + netuid: The subnet UID on which to query. + block: The block number to query. Do not specify if using block_hash or reuse_block. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + List of (hotkey_ss58, LockState) tuples. + """ + query_map = await self.query_map_subtensor( + "Lock", + params=[coldkey_ss58, netuid], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + locks = [] + async for hotkey, lock_state in query_map: + locks.append( + ( + hotkey, + LockState( + locked_mass=Balance.from_rao(lock_state["locked_mass"], netuid), + conviction=fixed_to_float(lock_state["conviction"]), + last_update=int(lock_state["last_update"]), + ), + ) + ) + return locks + async def get_stake_movement_fee( self, origin_netuid: int, @@ -5280,6 +5470,39 @@ async def is_hotkey_registered_on_subnet( is not None ) + async def is_perpetual_lock( + self, + coldkey_ss58: str, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> bool: + """ + Checks whether a coldkey's lock on a subnet is perpetual (non-decaying). + + Locks decay by default. A lock becomes perpetual only when the coldkey explicitly opts in via + set_perpetual_lock(enabled=True), which inserts a DecayingLock entry. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey to check. + netuid: The subnet UID to check. + block: The block number to query. Do not specify if using block_hash or reuse_block. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + True if the lock is perpetual (does not decay), False if it decays. + """ + query = await self.query_subtensor( + name="DecayingLock", + params=[coldkey_ss58, netuid], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + return query.value is not None + async def is_subnet_active( self, netuid: int, @@ -7332,6 +7555,53 @@ async def kill_pure_proxy( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def lock_stake( + self, + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + amount: Balance, + *, + 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: + """ + Locks alpha stake on a hotkey within a subnet, building conviction over time. + + Parameters: + wallet: The wallet whose coldkey owns the stake to lock. + hotkey_ss58: The SS58 address of the hotkey to lock stake on. + netuid: The subnet UID on which to lock. + amount: Amount of alpha to lock as a Balance object. + mev_protection: If `True`, encrypts and submits the transaction through MEV Shield. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + check_balance_amount(amount) + return await lock_stake_extrinsic( + subtensor=self, + wallet=wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + 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 mev_submit_encrypted( self, wallet: "Wallet", @@ -7481,6 +7751,49 @@ async def modify_liquidity( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def move_lock( + self, + wallet: "Wallet", + destination_hotkey_ss58: str, + netuid: int, + *, + 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: + """ + Moves an existing lock from its current hotkey to a different hotkey on the same subnet. + + Parameters: + wallet: The wallet whose coldkey owns the lock. + destination_hotkey_ss58: The SS58 address of the hotkey to move the lock to. + netuid: The subnet UID on which the lock exists. + mev_protection: If `True`, encrypts and submits the transaction through MEV Shield. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + return await move_lock_extrinsic( + subtensor=self, + wallet=wallet, + destination_hotkey_ss58=destination_hotkey_ss58, + netuid=netuid, + 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 move_stake( self, wallet: "Wallet", @@ -8602,6 +8915,45 @@ async def set_delegate_take( logging.error(f"[red]{response.message}[/red]") return response + async def set_perpetual_lock( + self, + wallet: "Wallet", + netuid: int, + enabled: bool, + *, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Sets or clears the perpetual lock flag for the caller's lock on a subnet. + + When enabled, the lock does not decay over time. When disabled, normal decay resumes. + + Parameters: + wallet: The wallet whose coldkey owns the lock. + netuid: The subnet UID for which to set the perpetual lock flag. + enabled: If True, the lock will not decay. If False, normal decay resumes. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await set_perpetual_lock_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + enabled=enabled, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + async def set_root_claim_type( self, wallet: "Wallet", diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index ce51579431..d878ce8e0a 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -77,6 +77,11 @@ toggle_user_liquidity_extrinsic, ) from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.lock import ( + lock_stake_extrinsic, + move_lock_extrinsic, + set_perpetual_lock_extrinsic, +) from bittensor.core.extrinsics.move_stake import ( move_stake_extrinsic, swap_stake_extrinsic, @@ -143,6 +148,7 @@ from bittensor.core.types import ( BlockInfo, ExtrinsicResponse, + LockState, Salt, SubtensorMixin, UIDs, @@ -1572,6 +1578,41 @@ def get_children_pending( cooldown, ) + def get_coldkey_lock( + self, + coldkey_ss58: str, + netuid: int, + block: Optional[int] = None, + ) -> Optional["LockState"]: + """ + Returns the current lock for a coldkey on a subnet, rolled forward to the current block. + + Unlike get_stake_lock which returns the raw stored state, this method applies decay to return the actual current + lock values accounting for time elapsed since last update. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey. + netuid: The subnet UID to query. + block: The block number to query. If None, queries the current block. + + Returns: + LockState with current locked_mass, conviction, and last_update, or None if no lock exists. + """ + result = self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_coldkey_lock", + params=[coldkey_ss58, netuid], + block=block, + ) + if result is None: + return None + + return LockState( + locked_mass=Balance.from_rao(result["locked_mass"], netuid), + conviction=fixed_to_float(result["conviction"]), + last_update=int(result["last_update"]), + ) + def get_coldkey_swap_announcement( self, coldkey_ss58: str, @@ -2271,6 +2312,33 @@ def get_ema_tao_inflow( # TODO verify this from rao, seems like we're just rounding down return block_updated, Balance.from_rao(ema_value) + def get_hotkey_conviction( + self, + hotkey_ss58: str, + netuid: int, + block: Optional[int] = None, + ) -> float: + """ + Gets the total conviction score for a hotkey on a subnet. + + Parameters: + hotkey_ss58: The SS58 address of the hotkey to query. + netuid: The subnet UID to query. + block: The block number to query. If None, queries the current block. + + Returns: + The conviction score as a float. + """ + result = self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_hotkey_conviction", + params=[hotkey_ss58, netuid], + block=block, + ) + if result is None: + return 0.0 + return fixed_to_float(result) + def get_hotkey_owner( self, hotkey_ss58: str, block: Optional[int] = None ) -> Optional[str]: @@ -2792,6 +2860,28 @@ def get_minimum_required_stake(self) -> Balance: return Balance.from_rao(result.value or 0) + def get_most_convicted_hotkey_on_subnet( + self, + netuid: int, + block: Optional[int] = None, + ) -> Optional[str]: + """ + Gets the hotkey with the highest conviction score on a subnet (the "subnet king"). + + Parameters: + netuid: The subnet UID to query. + block: The block number to query. If None, queries the current block. + + Returns: + The SS58 address of the most convicted hotkey, or None if no locks exist. + """ + return self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_most_convicted_hotkey_on_subnet", + params=[netuid], + block=block, + ) + def get_netuids_for_hotkey( self, hotkey_ss58: str, block: Optional[int] = None ) -> list[int]: @@ -3596,6 +3686,72 @@ def get_stake_add_fee( ) return sim_swap_result.tao_fee + def get_stake_lock( + self, + coldkey_ss58: str, + netuid: int, + hotkey_ss58: str, + block: Optional[int] = None, + ) -> Optional["LockState"]: + """ + Retrieves the lock state for a specific coldkey-netuid-hotkey combination. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey that owns the lock. + netuid: The subnet UID on which to query. + hotkey_ss58: The SS58 address of the hotkey the lock is on. + block: The block number to query. If None, queries the current block. + + Returns: + LockState dict with locked_mass, conviction, last_update, or None if no lock exists. + """ + query = self.query_subtensor( + "Lock", params=[coldkey_ss58, netuid, hotkey_ss58], block=block + ) + if query.value is None: + return None + + value = cast(dict, query.value) + return LockState( + locked_mass=Balance.from_rao(value["locked_mass"], netuid), + conviction=fixed_to_float(value["conviction"]), + last_update=int(value["last_update"]), + ) + + def get_stake_locks( + self, + coldkey_ss58: str, + netuid: int, + block: Optional[int] = None, + ) -> "list[tuple[str, LockState]]": + """ + Retrieves all lock states for a coldkey on a subnet. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey that owns the locks. + netuid: The subnet UID on which to query. + block: The block number to query. If None, queries the current block. + + Returns: + List of (hotkey_ss58, LockState) tuples. + """ + query_map = self.query_map_subtensor( + "Lock", params=[coldkey_ss58, netuid], block=block + ) + locks = [] + for hotkey, lock_state in query_map: + locks.append( + ( + hotkey, + LockState( + locked_mass=Balance.from_rao(lock_state["locked_mass"], netuid), + conviction=fixed_to_float(lock_state["conviction"]), + last_update=int(lock_state["last_update"]), + ), + ) + ) + return locks + def get_stake_movement_fee( self, origin_netuid: int, @@ -4320,6 +4476,31 @@ def is_hotkey_registered_on_subnet( is not None ) + def is_perpetual_lock( + self, + coldkey_ss58: str, + netuid: int, + block: Optional[int] = None, + ) -> bool: + """ + Checks whether a coldkey's lock on a subnet is perpetual (non-decaying). + + Locks decay by default. A lock becomes perpetual only when the coldkey explicitly opts in via + set_perpetual_lock(enabled=True), which inserts a DecayingLock entry. + + Parameters: + coldkey_ss58: The SS58 address of the coldkey to check. + netuid: The subnet UID to check. + block: The block number to query. If None, queries the current block. + + Returns: + True if the lock is perpetual (does not decay), False if it decays. + """ + query = self.query_subtensor( + name="DecayingLock", params=[coldkey_ss58, netuid], block=block + ) + return query.value is not None + def is_subnet_active(self, netuid: int, block: Optional[int] = None) -> bool: """Verifies if a subnet with the provided netuid is active. @@ -4731,7 +4912,7 @@ def handler(block_data: dict): return True return None - current_block = cast(Optional[dict[str, Any]], self.substrate.get_block()) + current_block = self.substrate.get_block() if current_block is None: return False current_block_hash = current_block.get("header", {}).get("hash") @@ -6169,6 +6350,53 @@ def kill_pure_proxy( wait_for_revealed_execution=wait_for_revealed_execution, ) + def lock_stake( + self, + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + amount: Balance, + *, + 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: + """ + Locks alpha stake on a hotkey within a subnet, building conviction over time. + + Parameters: + wallet: The wallet whose coldkey owns the stake to lock. + hotkey_ss58: The SS58 address of the hotkey to lock stake on. + netuid: The subnet UID on which to lock. + amount: Amount of alpha to lock as a Balance object. + mev_protection: If `True`, encrypts and submits the transaction through MEV Shield. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + check_balance_amount(amount) + return lock_stake_extrinsic( + subtensor=self, + wallet=wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + 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 mev_submit_encrypted( self, wallet: "Wallet", @@ -6315,6 +6543,49 @@ def modify_liquidity( wait_for_revealed_execution=wait_for_revealed_execution, ) + def move_lock( + self, + wallet: "Wallet", + destination_hotkey_ss58: str, + netuid: int, + *, + 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: + """ + Moves an existing lock from its current hotkey to a different hotkey on the same subnet. + + Parameters: + wallet: The wallet whose coldkey owns the lock. + destination_hotkey_ss58: The SS58 address of the hotkey to move the lock to. + netuid: The subnet UID on which the lock exists. + mev_protection: If `True`, encrypts and submits the transaction through MEV Shield. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + return move_lock_extrinsic( + subtensor=self, + wallet=wallet, + destination_hotkey_ss58=destination_hotkey_ss58, + netuid=netuid, + 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 move_stake( self, wallet: "Wallet", @@ -7410,6 +7681,45 @@ def set_delegate_take( logging.error(f"[red]{response.message}[/red]") return response + def set_perpetual_lock( + self, + wallet: "Wallet", + netuid: int, + enabled: bool, + *, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + ) -> ExtrinsicResponse: + """ + Sets or clears the perpetual lock flag for the caller's lock on a subnet. + + When enabled, the lock does not decay over time. When disabled, normal decay resumes. + + Parameters: + wallet: The wallet whose coldkey owns the lock. + netuid: The subnet UID for which to set the perpetual lock flag. + enabled: If True, the lock will not decay. If False, normal decay resumes. + period: The number of blocks during which the transaction will remain valid after it's submitted. + raise_error: Raises exception rather than returning failure response. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return set_perpetual_lock_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + enabled=enabled, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + def set_root_claim_type( self, wallet: "Wallet", From d033fc61c39eb99cee2837da7873e89a2c510ba7 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 15:03:38 -0700 Subject: [PATCH 10/18] update SubtensorApi --- bittensor/extras/subtensor_api/staking.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bittensor/extras/subtensor_api/staking.py b/bittensor/extras/subtensor_api/staking.py index 7c6f31ae9c..23ee27e6a4 100644 --- a/bittensor/extras/subtensor_api/staking.py +++ b/bittensor/extras/subtensor_api/staking.py @@ -12,8 +12,12 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.add_stake_multiple = subtensor.add_stake_multiple self.claim_root = subtensor.claim_root self.get_auto_stakes = subtensor.get_auto_stakes + self.get_hotkey_conviction = subtensor.get_hotkey_conviction self.get_hotkey_stake = subtensor.get_hotkey_stake self.get_minimum_required_stake = subtensor.get_minimum_required_stake + self.get_most_convicted_hotkey_on_subnet = ( + subtensor.get_most_convicted_hotkey_on_subnet + ) self.get_root_alpha_dividends_per_subnet = ( subtensor.get_root_alpha_dividends_per_subnet ) @@ -21,9 +25,12 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_root_claimable_all_rates = subtensor.get_root_claimable_all_rates self.get_root_claimable_rate = subtensor.get_root_claimable_rate self.get_root_claimable_stake = subtensor.get_root_claimable_stake + self.get_coldkey_lock = subtensor.get_coldkey_lock self.get_root_claimed = subtensor.get_root_claimed self.get_stake = subtensor.get_stake self.get_stake_add_fee = subtensor.get_stake_add_fee + self.get_stake_lock = subtensor.get_stake_lock + self.get_stake_locks = subtensor.get_stake_locks self.get_stake_for_coldkey_and_hotkey = ( subtensor.get_stake_for_coldkey_and_hotkey ) @@ -33,8 +40,12 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_stake_weight = subtensor.get_stake_weight self.get_staking_hotkeys = subtensor.get_staking_hotkeys self.get_unstake_fee = subtensor.get_unstake_fee + self.is_perpetual_lock = subtensor.is_perpetual_lock + self.lock_stake = subtensor.lock_stake + self.move_lock = subtensor.move_lock self.move_stake = subtensor.move_stake self.set_auto_stake = subtensor.set_auto_stake + self.set_perpetual_lock = subtensor.set_perpetual_lock self.set_root_claim_type = subtensor.set_root_claim_type self.sim_swap = subtensor.sim_swap self.swap_stake = subtensor.swap_stake From 1d064b0aa26c5fa738bc42fa0a56e48d5b933571 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 15:50:43 -0700 Subject: [PATCH 11/18] unit tests --- .../extrinsics/asyncex/test_lock.py | 365 ++++++++++++++++++ tests/unit_tests/extrinsics/test_lock.py | 329 ++++++++++++++++ tests/unit_tests/test_async_subtensor.py | 234 +++++++++++ tests/unit_tests/test_subtensor.py | 195 ++++++++++ 4 files changed, 1123 insertions(+) create mode 100644 tests/unit_tests/extrinsics/asyncex/test_lock.py create mode 100644 tests/unit_tests/extrinsics/test_lock.py diff --git a/tests/unit_tests/extrinsics/asyncex/test_lock.py b/tests/unit_tests/extrinsics/asyncex/test_lock.py new file mode 100644 index 0000000000..a371dab9ae --- /dev/null +++ b/tests/unit_tests/extrinsics/asyncex/test_lock.py @@ -0,0 +1,365 @@ +import pytest + +from bittensor.core.extrinsics.asyncex import lock +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance + + +@pytest.mark.asyncio +async def test_lock_stake_extrinsic(mocker): + """Verify that async lock_stake_extrinsic composes correct call and submits it.""" + # Preps + fake_subtensor = mocker.AsyncMock( + **{ + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock() + hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 1 + amount = Balance.from_tao(5) + + result = await lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + mev_protection=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + # Asserts + assert result.success is True + fake_subtensor.compose_call.assert_awaited_once_with( + call_module="SubtensorModule", + call_function="lock_stake", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount": amount.rao, + }, + ) + fake_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + call=fake_subtensor.compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + +@pytest.mark.asyncio +async def test_lock_stake_extrinsic_mev_protection(mocker): + """Verify that async lock_stake_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + fake_subtensor = mocker.AsyncMock() + fake_wallet = mocker.Mock() + hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 1 + amount = Balance.from_tao(5) + + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.lock.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + + result = await lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert result.success is True + mock_submit.assert_awaited_once_with( + subtensor=fake_subtensor, + wallet=fake_wallet, + call=fake_subtensor.compose_call.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + fake_subtensor.sign_and_send_extrinsic.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_move_lock_extrinsic(mocker): + """Verify that async move_lock_extrinsic composes correct call and submits it.""" + # Preps + fake_subtensor = mocker.AsyncMock( + **{ + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock() + destination_hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 2 + + result = await lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58=destination_hotkey_ss58, + netuid=netuid, + mev_protection=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + # Asserts + assert result.success is True + fake_subtensor.compose_call.assert_awaited_once_with( + call_module="SubtensorModule", + call_function="move_lock", + call_params={ + "destination_hotkey": destination_hotkey_ss58, + "netuid": netuid, + }, + ) + fake_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + call=fake_subtensor.compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + +@pytest.mark.asyncio +async def test_move_lock_extrinsic_mev_protection(mocker): + """Verify that async move_lock_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + fake_subtensor = mocker.AsyncMock() + fake_wallet = mocker.Mock() + destination_hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 2 + + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.lock.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + + result = await lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58=destination_hotkey_ss58, + netuid=netuid, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert result.success is True + mock_submit.assert_awaited_once_with( + subtensor=fake_subtensor, + wallet=fake_wallet, + call=fake_subtensor.compose_call.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + fake_subtensor.sign_and_send_extrinsic.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_set_perpetual_lock_extrinsic(mocker): + """Verify that async set_perpetual_lock_extrinsic composes correct call and submits it.""" + # Preps + fake_subtensor = mocker.AsyncMock( + **{ + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock() + netuid = 3 + enabled = False + + result = await lock.set_perpetual_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + netuid=netuid, + enabled=enabled, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + # Asserts + assert result.success is True + fake_subtensor.compose_call.assert_awaited_once_with( + call_module="SubtensorModule", + call_function="set_perpetual_lock", + call_params={ + "netuid": netuid, + "enabled": enabled, + }, + ) + fake_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + call=fake_subtensor.compose_call.return_value, + wallet=fake_wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + period=None, + raise_error=False, + ) + + +@pytest.mark.asyncio +async def test_lock_stake_wallet_unlock_failure(mocker): + """Verify that async lock_stake_extrinsic returns early on wallet unlock failure.""" + # Preps + fake_subtensor = mocker.AsyncMock() + fake_wallet = mocker.Mock() + + mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(False, "Wallet unlock failed"), + ) + + result = await lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58="hotkey", + netuid=1, + amount=Balance.from_tao(1), + ) + + # Asserts + assert result.success is False + assert "unlock" in result.message.lower() + fake_subtensor.compose_call.assert_not_awaited() + fake_subtensor.sign_and_send_extrinsic.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_move_lock_wallet_unlock_failure(mocker): + """Verify that async move_lock_extrinsic returns early on wallet unlock failure.""" + # Preps + fake_subtensor = mocker.AsyncMock() + fake_wallet = mocker.Mock() + + mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(False, "Wallet unlock failed"), + ) + + result = await lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58="hotkey", + netuid=1, + ) + + # Asserts + assert result.success is False + assert "unlock" in result.message.lower() + fake_subtensor.compose_call.assert_not_awaited() + fake_subtensor.sign_and_send_extrinsic.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_set_perpetual_lock_wallet_unlock_failure(mocker): + """Verify that async set_perpetual_lock_extrinsic returns early on wallet unlock failure.""" + # Preps + fake_subtensor = mocker.AsyncMock() + fake_wallet = mocker.Mock() + + mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(False, "Wallet unlock failed"), + ) + + result = await lock.set_perpetual_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + netuid=1, + enabled=True, + ) + + # Asserts + assert result.success is False + assert "unlock" in result.message.lower() + fake_subtensor.compose_call.assert_not_awaited() + fake_subtensor.sign_and_send_extrinsic.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_lock_stake_extrinsic_exception(mocker): + """Verify that async lock_stake_extrinsic handles exceptions gracefully.""" + # Preps + fake_subtensor = mocker.AsyncMock( + **{"sign_and_send_extrinsic.side_effect": RuntimeError("chain error")} + ) + fake_wallet = mocker.Mock() + + result = await lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58="hotkey", + netuid=1, + amount=Balance.from_tao(1), + mev_protection=False, + ) + + # Asserts + assert result.success is False + + +@pytest.mark.asyncio +async def test_move_lock_extrinsic_exception(mocker): + """Verify that async move_lock_extrinsic handles exceptions gracefully.""" + # Preps + fake_subtensor = mocker.AsyncMock( + **{"sign_and_send_extrinsic.side_effect": RuntimeError("chain error")} + ) + fake_wallet = mocker.Mock() + + result = await lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58="hotkey", + netuid=1, + mev_protection=False, + ) + + # Asserts + assert result.success is False + + +@pytest.mark.asyncio +async def test_set_perpetual_lock_extrinsic_exception(mocker): + """Verify that async set_perpetual_lock_extrinsic handles exceptions gracefully.""" + # Preps + fake_subtensor = mocker.AsyncMock( + **{"sign_and_send_extrinsic.side_effect": RuntimeError("chain error")} + ) + fake_wallet = mocker.Mock() + + result = await lock.set_perpetual_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + netuid=1, + enabled=True, + ) + + # Asserts + assert result.success is False diff --git a/tests/unit_tests/extrinsics/test_lock.py b/tests/unit_tests/extrinsics/test_lock.py new file mode 100644 index 0000000000..f636a21548 --- /dev/null +++ b/tests/unit_tests/extrinsics/test_lock.py @@ -0,0 +1,329 @@ +from bittensor.core.extrinsics import lock +from bittensor.core.types import ExtrinsicResponse +from bittensor.utils.balance import Balance + + +def test_lock_stake_extrinsic(mocker): + """Verify that lock_stake_extrinsic composes correct call and submits it.""" + fake_subtensor = mocker.Mock( + **{ + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock() + hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 1 + amount = Balance.from_tao(5) + + result = lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + mev_protection=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + assert result.success is True + fake_subtensor.compose_call.assert_called_once_with( + call_module="SubtensorModule", + call_function="lock_stake", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount": amount.rao, + }, + ) + 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, + period=None, + raise_error=False, + ) + + +def test_lock_stake_extrinsic_mev_protection(mocker): + """Verify that lock_stake_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + fake_subtensor = mocker.Mock() + fake_wallet = mocker.Mock() + hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 1 + amount = Balance.from_tao(5) + + mock_submit = mocker.patch( + "bittensor.core.extrinsics.lock.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + result = lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + amount=amount, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + assert result.success is True + mock_submit.assert_called_once_with( + subtensor=fake_subtensor, + wallet=fake_wallet, + call=fake_subtensor.compose_call.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + fake_subtensor.sign_and_send_extrinsic.assert_not_called() + + +def test_move_lock_extrinsic(mocker): + """Verify that move_lock_extrinsic composes correct call and submits it.""" + fake_subtensor = mocker.Mock( + **{ + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock() + destination_hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 2 + + result = lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58=destination_hotkey_ss58, + netuid=netuid, + mev_protection=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + assert result.success is True + fake_subtensor.compose_call.assert_called_once_with( + call_module="SubtensorModule", + call_function="move_lock", + call_params={ + "destination_hotkey": destination_hotkey_ss58, + "netuid": netuid, + }, + ) + 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, + period=None, + raise_error=False, + ) + + +def test_move_lock_extrinsic_mev_protection(mocker): + """Verify that move_lock_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + fake_subtensor = mocker.Mock() + fake_wallet = mocker.Mock() + destination_hotkey_ss58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 2 + + mock_submit = mocker.patch( + "bittensor.core.extrinsics.lock.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + result = lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58=destination_hotkey_ss58, + netuid=netuid, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + assert result.success is True + mock_submit.assert_called_once_with( + subtensor=fake_subtensor, + wallet=fake_wallet, + call=fake_subtensor.compose_call.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + fake_subtensor.sign_and_send_extrinsic.assert_not_called() + + +def test_set_perpetual_lock_extrinsic(mocker): + """Verify that set_perpetual_lock_extrinsic composes correct call and submits it.""" + fake_subtensor = mocker.Mock( + **{ + "sign_and_send_extrinsic.return_value": ExtrinsicResponse(True, "Success"), + } + ) + fake_wallet = mocker.Mock() + netuid = 3 + enabled = False + + result = lock.set_perpetual_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + netuid=netuid, + enabled=enabled, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + assert result.success is True + fake_subtensor.compose_call.assert_called_once_with( + call_module="SubtensorModule", + call_function="set_perpetual_lock", + call_params={ + "netuid": netuid, + "enabled": enabled, + }, + ) + 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, + period=None, + raise_error=False, + ) + + +def test_lock_stake_wallet_unlock_failure(mocker): + """Verify that lock_stake_extrinsic returns early on wallet unlock failure.""" + fake_subtensor = mocker.Mock() + fake_wallet = mocker.Mock() + amount = Balance.from_tao(1) + + mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(False, "Wallet unlock failed"), + ) + + result = lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58="hotkey", + netuid=1, + amount=amount, + ) + + assert result.success is False + assert "unlock" in result.message.lower() + fake_subtensor.compose_call.assert_not_called() + fake_subtensor.sign_and_send_extrinsic.assert_not_called() + + +def test_move_lock_wallet_unlock_failure(mocker): + """Verify that move_lock_extrinsic returns early on wallet unlock failure.""" + fake_subtensor = mocker.Mock() + fake_wallet = mocker.Mock() + + mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(False, "Wallet unlock failed"), + ) + + result = lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58="hotkey", + netuid=1, + ) + + assert result.success is False + assert "unlock" in result.message.lower() + fake_subtensor.compose_call.assert_not_called() + fake_subtensor.sign_and_send_extrinsic.assert_not_called() + + +def test_set_perpetual_lock_wallet_unlock_failure(mocker): + """Verify that set_perpetual_lock_extrinsic returns early on wallet unlock failure.""" + fake_subtensor = mocker.Mock() + fake_wallet = mocker.Mock() + + mocker.patch.object( + ExtrinsicResponse, + "unlock_wallet", + return_value=ExtrinsicResponse(False, "Wallet unlock failed"), + ) + + result = lock.set_perpetual_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + netuid=1, + enabled=True, + ) + + assert result.success is False + assert "unlock" in result.message.lower() + fake_subtensor.compose_call.assert_not_called() + fake_subtensor.sign_and_send_extrinsic.assert_not_called() + + +def test_lock_stake_extrinsic_exception(mocker): + """Verify that lock_stake_extrinsic handles exceptions gracefully.""" + fake_subtensor = mocker.Mock( + **{"sign_and_send_extrinsic.side_effect": RuntimeError("chain error")} + ) + fake_wallet = mocker.Mock() + + result = lock.lock_stake_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + hotkey_ss58="hotkey", + netuid=1, + amount=Balance.from_tao(1), + mev_protection=False, + ) + + assert result.success is False + + +def test_move_lock_extrinsic_exception(mocker): + """Verify that move_lock_extrinsic handles exceptions gracefully.""" + fake_subtensor = mocker.Mock( + **{"sign_and_send_extrinsic.side_effect": RuntimeError("chain error")} + ) + fake_wallet = mocker.Mock() + + result = lock.move_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + destination_hotkey_ss58="hotkey", + netuid=1, + mev_protection=False, + ) + + assert result.success is False + + +def test_set_perpetual_lock_extrinsic_exception(mocker): + """Verify that set_perpetual_lock_extrinsic handles exceptions gracefully.""" + fake_subtensor = mocker.Mock( + **{"sign_and_send_extrinsic.side_effect": RuntimeError("chain error")} + ) + fake_wallet = mocker.Mock() + + result = lock.set_perpetual_lock_extrinsic( + subtensor=fake_subtensor, + wallet=fake_wallet, + netuid=1, + enabled=True, + ) + + assert result.success is False diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index aba3085fc2..9a6e6d867d 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -3951,6 +3951,240 @@ async def test_get_stake_weight(subtensor, mocker): assert result == expected_result +# Lock / Conviction tests ===================================================================== + + +@pytest.mark.asyncio +async def test_get_stake_lock_success(subtensor, mocker): + """Tests get_stake_lock returns LockState when lock exists.""" + # Preps + coldkey = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + hotkey = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + netuid = 1 + raw_value = {"locked_mass": 1_000_000_000, "conviction": 1 << 64, "last_update": 50} + mock_query = mocker.MagicMock(value=raw_value) + subtensor.query_subtensor = mocker.AsyncMock(return_value=mock_query) + + # Call + result = await subtensor.get_stake_lock(coldkey, netuid, hotkey) + + # Asserts + subtensor.query_subtensor.assert_awaited_once_with( + "Lock", + params=[coldkey, netuid, hotkey], + block=None, + block_hash=None, + reuse_block=False, + ) + assert result is not None + assert result["locked_mass"].rao == 1_000_000_000 + assert isinstance(result["conviction"], float) + assert result["last_update"] == 50 + + +@pytest.mark.asyncio +async def test_get_stake_lock_none(subtensor, mocker): + """Tests get_stake_lock returns None when no lock exists.""" + # Preps + mock_query = mocker.MagicMock(value=None) + subtensor.query_subtensor = mocker.AsyncMock(return_value=mock_query) + + # Call + result = await subtensor.get_stake_lock("coldkey", 1, "hotkey") + + # Asserts + assert result is None + + +@pytest.mark.asyncio +async def test_get_stake_locks_success(subtensor, mocker): + """Tests get_stake_locks returns list of (hotkey, LockState) tuples.""" + # Preps + coldkey = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 1 + hotkey1 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + hotkey2 = "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" + raw_locks = [ + (hotkey1, {"locked_mass": 500, "conviction": 0, "last_update": 10}), + (hotkey2, {"locked_mass": 1000, "conviction": 1 << 63, "last_update": 20}), + ] + + async def async_iter(): + for item in raw_locks: + yield item + + subtensor.query_map_subtensor = mocker.AsyncMock(return_value=async_iter()) + + # Call + result = await subtensor.get_stake_locks(coldkey, netuid) + + # Asserts + subtensor.query_map_subtensor.assert_awaited_once_with( + "Lock", + params=[coldkey, netuid], + block=None, + block_hash=None, + reuse_block=False, + ) + assert len(result) == 2 + assert result[0][0] == hotkey1 + assert result[0][1]["locked_mass"].rao == 500 + assert result[1][0] == hotkey2 + assert result[1][1]["locked_mass"].rao == 1000 + + +@pytest.mark.asyncio +async def test_get_stake_locks_empty(subtensor, mocker): + """Tests get_stake_locks returns empty list when no locks exist.""" + + # Preps + async def async_iter(): + return + yield + + subtensor.query_map_subtensor = mocker.AsyncMock(return_value=async_iter()) + + # Call + result = await subtensor.get_stake_locks("coldkey", 1) + + # Asserts + assert result == [] + + +@pytest.mark.parametrize( + "query_value, expected", + [(False, True), (None, False)], + ids=["perpetual-entry-exists", "decaying-no-entry"], +) +@pytest.mark.asyncio +async def test_is_perpetual_lock(subtensor, mocker, query_value, expected): + """Tests is_perpetual_lock correctly distinguishes perpetual from decaying.""" + # Preps + mock_query = mocker.MagicMock(value=query_value) + subtensor.query_subtensor = mocker.AsyncMock(return_value=mock_query) + + # Call + result = await subtensor.is_perpetual_lock("coldkey", 1) + + # Asserts + subtensor.query_subtensor.assert_awaited_once_with( + name="DecayingLock", + params=["coldkey", 1], + block=None, + block_hash=None, + reuse_block=False, + ) + assert result is expected + + +@pytest.mark.asyncio +async def test_get_coldkey_lock_success(subtensor, mocker): + """Tests get_coldkey_lock returns LockState when lock exists.""" + # Preps + raw_result = {"locked_mass": 2000, "conviction": 1 << 64, "last_update": 100} + subtensor.query_runtime_api = mocker.AsyncMock(return_value=raw_result) + + # Call + result = await subtensor.get_coldkey_lock("coldkey", 1) + + # Asserts + subtensor.query_runtime_api.assert_awaited_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_coldkey_lock", + params=["coldkey", 1], + block=None, + block_hash=None, + reuse_block=False, + ) + assert result is not None + assert result["locked_mass"].rao == 2000 + assert isinstance(result["conviction"], float) + assert result["last_update"] == 100 + + +@pytest.mark.asyncio +async def test_get_coldkey_lock_none(subtensor, mocker): + """Tests get_coldkey_lock returns None when no lock exists.""" + # Preps + subtensor.query_runtime_api = mocker.AsyncMock(return_value=None) + + # Call + result = await subtensor.get_coldkey_lock("coldkey", 1) + + # Asserts + assert result is None + + +@pytest.mark.asyncio +async def test_get_hotkey_conviction_success(subtensor, mocker): + """Tests get_hotkey_conviction returns float conviction score.""" + # Preps + subtensor.query_runtime_api = mocker.AsyncMock(return_value=(1 << 64)) + + # Call + result = await subtensor.get_hotkey_conviction("hotkey", 1) + + # Asserts + subtensor.query_runtime_api.assert_awaited_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_hotkey_conviction", + params=["hotkey", 1], + block=None, + block_hash=None, + reuse_block=False, + ) + assert isinstance(result, float) + assert result > 0.0 + + +@pytest.mark.asyncio +async def test_get_hotkey_conviction_none(subtensor, mocker): + """Tests get_hotkey_conviction returns 0.0 when no data.""" + # Preps + subtensor.query_runtime_api = mocker.AsyncMock(return_value=None) + + # Call + result = await subtensor.get_hotkey_conviction("hotkey", 1) + + # Asserts + assert result == 0.0 + + +@pytest.mark.asyncio +async def test_get_most_convicted_hotkey_on_subnet_success(subtensor, mocker): + """Tests get_most_convicted_hotkey_on_subnet returns hotkey SS58.""" + # Preps + expected_hotkey = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + subtensor.query_runtime_api = mocker.AsyncMock(return_value=expected_hotkey) + + # Call + result = await subtensor.get_most_convicted_hotkey_on_subnet(1) + + # Asserts + subtensor.query_runtime_api.assert_awaited_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_most_convicted_hotkey_on_subnet", + params=[1], + block=None, + block_hash=None, + reuse_block=False, + ) + assert result == expected_hotkey + + +@pytest.mark.asyncio +async def test_get_most_convicted_hotkey_on_subnet_none(subtensor, mocker): + """Tests get_most_convicted_hotkey_on_subnet returns None when no locks.""" + # Preps + subtensor.query_runtime_api = mocker.AsyncMock(return_value=None) + + # Call + result = await subtensor.get_most_convicted_hotkey_on_subnet(1) + + # Asserts + assert result is None + + @pytest.mark.asyncio async def test_get_timelocked_weight_commits(subtensor, mocker): """Verify that `get_timelocked_weight_commits` method calls proper methods and returns the correct value.""" diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index d2324d435d..550a59732d 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -4075,6 +4075,201 @@ def test_get_stake_weight(subtensor, mocker): assert result == expected_result +# Lock / Conviction tests ===================================================================== + + +def test_get_stake_lock_success(subtensor, mocker): + """Tests get_stake_lock returns LockState when lock exists.""" + # Preps + coldkey = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + hotkey = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + netuid = 1 + raw_value = {"locked_mass": 1_000_000_000, "conviction": 1 << 64, "last_update": 50} + mock_query = mocker.MagicMock(value=raw_value) + mocker.patch.object(subtensor, "query_subtensor", return_value=mock_query) + + # Call + result = subtensor.get_stake_lock(coldkey, netuid, hotkey) + + # Asserts + subtensor.query_subtensor.assert_called_once_with( + "Lock", params=[coldkey, netuid, hotkey], block=None + ) + assert result is not None + assert result["locked_mass"].rao == 1_000_000_000 + assert isinstance(result["conviction"], float) + assert result["last_update"] == 50 + + +def test_get_stake_lock_none(subtensor, mocker): + """Tests get_stake_lock returns None when no lock exists.""" + # Preps + mock_query = mocker.MagicMock(value=None) + mocker.patch.object(subtensor, "query_subtensor", return_value=mock_query) + + # Call + result = subtensor.get_stake_lock("coldkey", 1, "hotkey") + + # Asserts + assert result is None + + +def test_get_stake_locks_success(subtensor, mocker): + """Tests get_stake_locks returns list of (hotkey, LockState) tuples.""" + # Preps + coldkey = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + netuid = 1 + hotkey1 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + hotkey2 = "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" + raw_locks = [ + (hotkey1, {"locked_mass": 500, "conviction": 0, "last_update": 10}), + (hotkey2, {"locked_mass": 1000, "conviction": 1 << 63, "last_update": 20}), + ] + mocker.patch.object(subtensor, "query_map_subtensor", return_value=iter(raw_locks)) + + # Call + result = subtensor.get_stake_locks(coldkey, netuid) + + # Asserts + subtensor.query_map_subtensor.assert_called_once_with( + "Lock", params=[coldkey, netuid], block=None + ) + assert len(result) == 2 + assert result[0][0] == hotkey1 + assert result[0][1]["locked_mass"].rao == 500 + assert result[1][0] == hotkey2 + assert result[1][1]["locked_mass"].rao == 1000 + + +def test_get_stake_locks_empty(subtensor, mocker): + """Tests get_stake_locks returns empty list when no locks exist.""" + # Preps + mocker.patch.object(subtensor, "query_map_subtensor", return_value=iter([])) + + # Call + result = subtensor.get_stake_locks("coldkey", 1) + + # Asserts + assert result == [] + + +@pytest.mark.parametrize( + "query_value, expected", + [(False, True), (None, False)], + ids=["perpetual-entry-exists", "decaying-no-entry"], +) +def test_is_perpetual_lock(subtensor, mocker, query_value, expected): + """Tests is_perpetual_lock correctly distinguishes perpetual from decaying.""" + # Preps + mock_query = mocker.MagicMock(value=query_value) + mocker.patch.object(subtensor, "query_subtensor", return_value=mock_query) + + # Call + result = subtensor.is_perpetual_lock("coldkey", 1) + + # Asserts + subtensor.query_subtensor.assert_called_once_with( + name="DecayingLock", params=["coldkey", 1], block=None + ) + assert result is expected + + +def test_get_coldkey_lock_success(subtensor, mocker): + """Tests get_coldkey_lock returns LockState when lock exists.""" + # Preps + raw_result = {"locked_mass": 2000, "conviction": 1 << 64, "last_update": 100} + mocker.patch.object(subtensor, "query_runtime_api", return_value=raw_result) + + # Call + result = subtensor.get_coldkey_lock("coldkey", 1) + + # Asserts + subtensor.query_runtime_api.assert_called_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_coldkey_lock", + params=["coldkey", 1], + block=None, + ) + assert result is not None + assert result["locked_mass"].rao == 2000 + assert isinstance(result["conviction"], float) + assert result["last_update"] == 100 + + +def test_get_coldkey_lock_none(subtensor, mocker): + """Tests get_coldkey_lock returns None when no lock exists.""" + # Preps + mocker.patch.object(subtensor, "query_runtime_api", return_value=None) + + # Call + result = subtensor.get_coldkey_lock("coldkey", 1) + + # Asserts + assert result is None + + +def test_get_hotkey_conviction_success(subtensor, mocker): + """Tests get_hotkey_conviction returns float conviction score.""" + # Preps + mocker.patch.object(subtensor, "query_runtime_api", return_value=(1 << 64)) + + # Call + result = subtensor.get_hotkey_conviction("hotkey", 1) + + # Asserts + subtensor.query_runtime_api.assert_called_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_hotkey_conviction", + params=["hotkey", 1], + block=None, + ) + assert isinstance(result, float) + assert result > 0.0 + + +def test_get_hotkey_conviction_none(subtensor, mocker): + """Tests get_hotkey_conviction returns 0.0 when no data.""" + # Preps + mocker.patch.object(subtensor, "query_runtime_api", return_value=None) + + # Call + result = subtensor.get_hotkey_conviction("hotkey", 1) + + # Asserts + assert result == 0.0 + + +def test_get_most_convicted_hotkey_on_subnet_success(subtensor, mocker): + """Tests get_most_convicted_hotkey_on_subnet returns hotkey SS58.""" + # Preps + expected_hotkey = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + mocker.patch.object(subtensor, "query_runtime_api", return_value=expected_hotkey) + + # Call + result = subtensor.get_most_convicted_hotkey_on_subnet(1) + + # Asserts + subtensor.query_runtime_api.assert_called_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_most_convicted_hotkey_on_subnet", + params=[1], + block=None, + ) + assert result == expected_hotkey + + +def test_get_most_convicted_hotkey_on_subnet_none(subtensor, mocker): + """Tests get_most_convicted_hotkey_on_subnet returns None when no locks.""" + # Preps + mocker.patch.object(subtensor, "query_runtime_api", return_value=None) + + # Call + result = subtensor.get_most_convicted_hotkey_on_subnet(1) + + # Asserts + assert result is None + + def test_get_timelocked_weight_commits(subtensor, mocker): """Verify that `get_timelocked_weight_commits` method calls proper methods and returns the correct value.""" # Preps From 1645b746960be5e39b898366dd2b9d04b08d7d03 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 16:19:19 -0700 Subject: [PATCH 12/18] add e2e tests --- tests/e2e_tests/test_lock_stake.py | 966 +++++++++++++++++++++++++++++ 1 file changed, 966 insertions(+) create mode 100644 tests/e2e_tests/test_lock_stake.py diff --git a/tests/e2e_tests/test_lock_stake.py b/tests/e2e_tests/test_lock_stake.py new file mode 100644 index 0000000000..b430e00697 --- /dev/null +++ b/tests/e2e_tests/test_lock_stake.py @@ -0,0 +1,966 @@ +import pytest + +from bittensor import logging +from bittensor.utils.balance import Balance +from tests.e2e_tests.utils import ( + TestSubnet, + ACTIVATE_SUBNET, + REGISTER_SUBNET, + REGISTER_NEURON, +) + + +def test_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet): + """ + Tests owner lock lifecycle including auto-lock from emission. + + 1. After emission, owner already has an auto-lock on their own hotkey + 2. Query lock state and verify conviction > 0 (owner gets instant conviction) + 3. Verify get_most_convicted_hotkey_on_subnet returns owner hotkey + 4. Fail to lock on a different hotkey (LockHotkeyMismatch) + 5. Top-up the existing lock on the same hotkey + 6. Unstake within available limit succeeds, lock remains unchanged + """ + alice_sn = TestSubnet(subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + ] + alice_sn.execute_steps(steps) + + # Wait for at least one epoch so that emission fires auto_lock_owner_cut + next_epoch = subtensor.subnets.get_next_epoch_start_block(alice_sn.netuid) + subtensor.wait_for_block(next_epoch + 1) + + # Owner should already have an auto-lock on their own hotkey + locks = subtensor.staking.get_stake_locks( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Owner locks after emission: {locks}") + assert len(locks) == 1 + hotkey, lock_state = locks[0] + assert hotkey == alice_wallet.hotkey.ss58_address + assert lock_state["locked_mass"].rao > 0 + + # Owner conviction should be > 0 (owner gets conviction = locked_mass instantly) + conviction = subtensor.staking.get_hotkey_conviction( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Owner hotkey conviction: {conviction}") + assert conviction > 0.0 + + # Subnet king should be the owner hotkey + king = subtensor.staking.get_most_convicted_hotkey_on_subnet( + netuid=alice_sn.netuid, + ) + logging.console.info(f"Subnet king: {king}") + assert king == alice_wallet.hotkey.ss58_address + + # Trying to lock on a different hotkey should fail (LockHotkeyMismatch) + response = subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(1).set_unit(alice_sn.netuid), + ) + logging.console.info(f"Lock on different hotkey response: {response}") + assert response.success is False + + # Add more stake on own hotkey so we have enough to lock + assert subtensor.staking.add_stake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ).success + + # Record locked_mass before top-up + lock_before = subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + locked_mass_before = lock_before["locked_mass"].rao + logging.console.info(f"Locked mass before top-up: {locked_mass_before}") + + # Top-up lock on own hotkey + top_up_amount = Balance.from_tao(2).set_unit(alice_sn.netuid) + response = subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=top_up_amount, + ) + logging.console.info(f"Top-up lock response: {response}") + assert response.success + + # Verify locked_mass increased + lock_after = subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + logging.console.info(f"Locked mass after top-up: {lock_after['locked_mass'].rao}") + assert lock_after["locked_mass"].rao > locked_mass_before + + # Unstaking within available limit should succeed without affecting the lock + small_amount = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = subtensor.staking.unstake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=small_amount, + ) + logging.console.info(f"Unstake small amount response: {response}") + assert response.success + + # Unstake must not reduce locked_mass (may grow due to auto_lock_owner_cut between blocks) + lock_unchanged = subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + assert lock_unchanged["locked_mass"].rao >= lock_after["locked_mass"].rao + + +@pytest.mark.asyncio +async def test_owner_lock_lifecycle_async(async_subtensor, alice_wallet, bob_wallet): + """ + Async version: Tests owner lock lifecycle including auto-lock from emission. + + 1. After emission, owner already has an auto-lock on their own hotkey + 2. Query lock state and verify conviction > 0 (owner gets instant conviction) + 3. Verify get_most_convicted_hotkey_on_subnet returns owner hotkey + 4. Fail to lock on a different hotkey (LockHotkeyMismatch) + 5. Top-up the existing lock on the same hotkey + 6. Unstake within available limit succeeds, lock remains unchanged + """ + 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) + + # Wait for at least one epoch so that emission fires auto_lock_owner_cut + next_epoch = await async_subtensor.subnets.get_next_epoch_start_block( + alice_sn.netuid + ) + await async_subtensor.wait_for_block(next_epoch + 1) + + # Owner should already have an auto-lock on their own hotkey + locks = await async_subtensor.staking.get_stake_locks( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Owner locks after emission: {locks}") + assert len(locks) == 1 + hotkey, lock_state = locks[0] + assert hotkey == alice_wallet.hotkey.ss58_address + assert lock_state["locked_mass"].rao > 0 + + # Owner conviction should be > 0 (owner gets conviction = locked_mass instantly) + conviction = await async_subtensor.staking.get_hotkey_conviction( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Owner hotkey conviction: {conviction}") + assert conviction > 0.0 + + # Subnet king should be the owner hotkey + king = await async_subtensor.staking.get_most_convicted_hotkey_on_subnet( + netuid=alice_sn.netuid, + ) + logging.console.info(f"Subnet king: {king}") + assert king == alice_wallet.hotkey.ss58_address + + # Trying to lock on a different hotkey should fail (LockHotkeyMismatch) + response = await async_subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(1).set_unit(alice_sn.netuid), + ) + logging.console.info(f"Lock on different hotkey response: {response}") + assert response.success is False + + # Add more stake on own hotkey so we have enough to lock + assert ( + await async_subtensor.staking.add_stake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ) + ).success + + # Record locked_mass before top-up + lock_before = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + locked_mass_before = lock_before["locked_mass"].rao + logging.console.info(f"Locked mass before top-up: {locked_mass_before}") + + # Top-up lock on own hotkey + top_up_amount = Balance.from_tao(2).set_unit(alice_sn.netuid) + response = await async_subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=top_up_amount, + ) + logging.console.info(f"Top-up lock response: {response}") + assert response.success + + # Verify locked_mass increased + lock_after = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + logging.console.info(f"Locked mass after top-up: {lock_after['locked_mass'].rao}") + assert lock_after["locked_mass"].rao > locked_mass_before + + # Unstaking within available limit should succeed without affecting the lock + small_amount = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = await async_subtensor.staking.unstake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=small_amount, + ) + logging.console.info(f"Unstake small amount response: {response}") + assert response.success + + # Unstake must not reduce locked_mass (may grow due to auto_lock_owner_cut between blocks) + lock_unchanged = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + assert lock_unchanged["locked_mass"].rao >= lock_after["locked_mass"].rao + + +def test_non_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet, charlie_wallet): + """ + Tests non-owner lock lifecycle from scratch. + + 1. No locks exist initially for non-owner + 2. Fail to lock without sufficient stake (InsufficientStakeForLock) + 3. Add stake and lock successfully + 4. Verify lock state (locked_mass, conviction near zero) + 5. Fail to lock on a different hotkey (LockHotkeyMismatch) + 6. Top-up lock on same hotkey + 7. Fail to unstake more than available (total - locked_mass) + 8. Successfully unstake within available limit + """ + alice_sn = TestSubnet(subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + REGISTER_NEURON(charlie_wallet), + ] + alice_sn.execute_steps(steps) + + # Bob has no locks initially + locks = subtensor.staking.get_stake_locks( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert len(locks) == 0 + + # Locking without stake should fail + lock_amount = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=lock_amount, + ) + logging.console.info(f"Lock without stake response: {response}") + assert response.success is False + + # Add stake for Bob + assert subtensor.staking.add_stake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ).success + + # Now lock should succeed + first_lock = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=first_lock, + ) + logging.console.info(f"First lock response: {response}") + assert response.success + + # Verify lock state + lock_info = subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + logging.console.info(f"Bob lock info: {lock_info}") + assert lock_info is not None + assert lock_info["locked_mass"].rao == first_lock.rao + assert lock_info["conviction"] < 1.0 + assert lock_info["last_update"] > 0 + + # Hotkey conviction should be near zero for a fresh non-owner lock + conviction = subtensor.staking.get_hotkey_conviction( + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Bob hotkey conviction: {conviction}") + assert isinstance(conviction, float) + + # Locking on a different hotkey should fail (LockHotkeyMismatch) + response = subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=charlie_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(1).set_unit(alice_sn.netuid), + ) + logging.console.info(f"Lock on different hotkey response: {response}") + assert response.success is False + + # Top-up on same hotkey should succeed + second_lock = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=second_lock, + ) + assert response.success + + # Verify locked_mass increased (not exact due to decay between blocks) + lock_info = subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + logging.console.info( + f"Bob locked_mass after top-up: {lock_info['locked_mass'].rao}, " + f"first_lock: {first_lock.rao}" + ) + assert lock_info["locked_mass"].rao > first_lock.rao + + # Unstaking more than available should fail + total_stake = subtensor.staking.get_stake( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + response = subtensor.staking.unstake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=total_stake, + ) + logging.console.info(f"Unstake all response: {response}") + assert response.success is False + + # Unstaking a small amount within available limit should succeed + small_amount = Balance.from_tao(1).set_unit(alice_sn.netuid) + available = total_stake.rao - lock_info["locked_mass"].rao + if available > small_amount.rao: + response = subtensor.staking.unstake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=small_amount, + ) + logging.console.info(f"Unstake small amount response: {response}") + assert response.success + + +@pytest.mark.asyncio +async def test_non_owner_lock_lifecycle_async( + async_subtensor, alice_wallet, bob_wallet, charlie_wallet +): + """ + Async version: Tests non-owner lock lifecycle from scratch. + + 1. No locks exist initially for non-owner + 2. Fail to lock without sufficient stake (InsufficientStakeForLock) + 3. Add stake and lock successfully + 4. Verify lock state (locked_mass, conviction near zero) + 5. Fail to lock on a different hotkey (LockHotkeyMismatch) + 6. Top-up lock on same hotkey + 7. Fail to unstake more than available (total - locked_mass) + 8. Successfully unstake within available limit + """ + alice_sn = TestSubnet(async_subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + REGISTER_NEURON(charlie_wallet), + ] + await alice_sn.async_execute_steps(steps) + + # Bob has no locks initially + locks = await async_subtensor.staking.get_stake_locks( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert len(locks) == 0 + + # Locking without stake should fail + lock_amount = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = await async_subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=lock_amount, + ) + logging.console.info(f"Lock without stake response: {response}") + assert response.success is False + + # Add stake for Bob + assert ( + await async_subtensor.staking.add_stake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ) + ).success + + # Now lock should succeed + first_lock = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = await async_subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=first_lock, + ) + logging.console.info(f"First lock response: {response}") + assert response.success + + # Verify lock state + lock_info = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + logging.console.info(f"Bob lock info: {lock_info}") + assert lock_info is not None + assert lock_info["locked_mass"].rao == first_lock.rao + assert lock_info["conviction"] < 1.0 + assert lock_info["last_update"] > 0 + + # Hotkey conviction should be near zero for a fresh non-owner lock + conviction = await async_subtensor.staking.get_hotkey_conviction( + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Bob hotkey conviction: {conviction}") + assert isinstance(conviction, float) + + # Locking on a different hotkey should fail (LockHotkeyMismatch) + response = await async_subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=charlie_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(1).set_unit(alice_sn.netuid), + ) + logging.console.info(f"Lock on different hotkey response: {response}") + assert response.success is False + + # Top-up on same hotkey should succeed + second_lock = Balance.from_tao(1).set_unit(alice_sn.netuid) + response = await async_subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=second_lock, + ) + assert response.success + + # Verify locked_mass increased (not exact due to decay between blocks) + lock_info = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + logging.console.info( + f"Bob locked_mass after top-up: {lock_info['locked_mass'].rao}, " + f"first_lock: {first_lock.rao}" + ) + assert lock_info["locked_mass"].rao > first_lock.rao + + # Unstaking more than available should fail + total_stake = await async_subtensor.staking.get_stake( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + response = await async_subtensor.staking.unstake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=total_stake, + ) + logging.console.info(f"Unstake all response: {response}") + assert response.success is False + + # Unstaking a small amount within available limit should succeed + small_amount = Balance.from_tao(1).set_unit(alice_sn.netuid) + available = total_stake.rao - lock_info["locked_mass"].rao + if available > small_amount.rao: + response = await async_subtensor.staking.unstake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=small_amount, + ) + logging.console.info(f"Unstake small amount response: {response}") + assert response.success + + +def test_move_lock(subtensor, alice_wallet, bob_wallet, charlie_wallet): + """ + Tests move_lock functionality. + + 1. Fail to move when no lock exists (NoExistingLock) + 2. Create a lock for Bob, then move it to Charlie's hotkey + 3. Verify lock moved: old hotkey has None, new hotkey has lock + 4. Owner moves their auto-lock to a different hotkey + 5. Verify owner lock moved successfully + """ + alice_sn = TestSubnet(subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + REGISTER_NEURON(charlie_wallet), + ] + alice_sn.execute_steps(steps) + + # Moving without a lock should fail + response = subtensor.staking.move_lock( + wallet=bob_wallet, + destination_hotkey_ss58=charlie_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Move lock without existing lock: {response}") + assert response.success is False + + # Create a lock for Bob + assert subtensor.staking.add_stake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ).success + + lock_amount = Balance.from_tao(2).set_unit(alice_sn.netuid) + assert subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=lock_amount, + ).success + + # Verify lock exists on Bob's hotkey + lock_before = subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + assert lock_before is not None + logging.console.info(f"Bob lock before move: {lock_before}") + + # Move lock from Bob's hotkey to Charlie's hotkey + response = subtensor.staking.move_lock( + wallet=bob_wallet, + destination_hotkey_ss58=charlie_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Move lock response: {response}") + assert response.success + + # Old hotkey should have no lock + old_lock = subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + assert old_lock is None + + # New hotkey should have the lock + new_lock = subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=charlie_wallet.hotkey.ss58_address, + ) + assert new_lock is not None + assert new_lock["locked_mass"].rao <= lock_amount.rao + assert new_lock["locked_mass"].rao > lock_amount.rao * 0.99 + logging.console.info(f"Bob lock after move: {new_lock}") + + # Owner move: wait for emission so owner has auto-lock + next_epoch = subtensor.subnets.get_next_epoch_start_block(alice_sn.netuid) + subtensor.wait_for_block(next_epoch + 1) + + owner_lock = subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + logging.console.info(f"Owner lock before move: {owner_lock}") + assert owner_lock is not None + + # Owner moves lock to Bob's hotkey + response = subtensor.staking.move_lock( + wallet=alice_wallet, + destination_hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Owner move lock response: {response}") + assert response.success + + # Owner lock should no longer be on alice's hotkey + old_owner_lock = subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + assert old_owner_lock is None + + # Owner lock should now be on Bob's hotkey + new_owner_lock = subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + assert new_owner_lock is not None + logging.console.info(f"Owner lock after move: {new_owner_lock}") + + +@pytest.mark.asyncio +async def test_move_lock_async( + async_subtensor, alice_wallet, bob_wallet, charlie_wallet +): + """ + Async version: Tests move_lock functionality. + + 1. Fail to move when no lock exists (NoExistingLock) + 2. Create a lock for Bob, then move it to Charlie's hotkey + 3. Verify lock moved: old hotkey has None, new hotkey has lock + 4. Owner moves their auto-lock to a different hotkey + 5. Verify owner lock moved successfully + """ + alice_sn = TestSubnet(async_subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + REGISTER_NEURON(charlie_wallet), + ] + await alice_sn.async_execute_steps(steps) + + # Moving without a lock should fail + response = await async_subtensor.staking.move_lock( + wallet=bob_wallet, + destination_hotkey_ss58=charlie_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Move lock without existing lock: {response}") + assert response.success is False + + # Create a lock for Bob + assert ( + await async_subtensor.staking.add_stake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ) + ).success + + lock_amount = Balance.from_tao(2).set_unit(alice_sn.netuid) + assert ( + await async_subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=lock_amount, + ) + ).success + + # Verify lock exists on Bob's hotkey + lock_before = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + assert lock_before is not None + logging.console.info(f"Bob lock before move: {lock_before}") + + # Move lock from Bob's hotkey to Charlie's hotkey + response = await async_subtensor.staking.move_lock( + wallet=bob_wallet, + destination_hotkey_ss58=charlie_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Move lock response: {response}") + assert response.success + + # Old hotkey should have no lock + old_lock = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + assert old_lock is None + + # New hotkey should have the lock + new_lock = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=charlie_wallet.hotkey.ss58_address, + ) + assert new_lock is not None + assert new_lock["locked_mass"].rao <= lock_amount.rao + assert new_lock["locked_mass"].rao > lock_amount.rao * 0.99 + logging.console.info(f"Bob lock after move: {new_lock}") + + # Owner move: wait for emission so owner has auto-lock + next_epoch = await async_subtensor.subnets.get_next_epoch_start_block( + alice_sn.netuid + ) + await async_subtensor.wait_for_block(next_epoch + 1) + + owner_lock = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + logging.console.info(f"Owner lock before move: {owner_lock}") + assert owner_lock is not None + + # Owner moves lock to Bob's hotkey + response = await async_subtensor.staking.move_lock( + wallet=alice_wallet, + destination_hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + ) + logging.console.info(f"Owner move lock response: {response}") + assert response.success + + # Owner lock should no longer be on alice's hotkey + old_owner_lock = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + ) + assert old_owner_lock is None + + # Owner lock should now be on Bob's hotkey + new_owner_lock = await async_subtensor.staking.get_stake_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + ) + assert new_owner_lock is not None + logging.console.info(f"Owner lock after move: {new_owner_lock}") + + +def test_perpetual_lock_toggle(subtensor, alice_wallet, bob_wallet): + """ + Tests set_perpetual_lock toggle for both owner and non-owner. + + 1. Locks are decaying by default (is_perpetual_lock returns False) + 2. Toggle to perpetual via set_perpetual_lock(enabled=True), verify + 3. Toggle back to decaying via set_perpetual_lock(enabled=False), verify + 4. Non-owner creates a lock, toggles to perpetual, verify + """ + alice_sn = TestSubnet(subtensor) + steps = [ + REGISTER_SUBNET(alice_wallet), + ACTIVATE_SUBNET(alice_wallet), + REGISTER_NEURON(bob_wallet), + ] + alice_sn.execute_steps(steps) + + # Owner lock is decaying by default + is_perpetual = subtensor.staking.is_perpetual_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is False + + # Set to perpetual + response = subtensor.staking.set_perpetual_lock( + wallet=alice_wallet, + netuid=alice_sn.netuid, + enabled=True, + ) + logging.console.info(f"Set perpetual=True response: {response}") + assert response.success + + is_perpetual = subtensor.staking.is_perpetual_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is True + + # Set back to decaying + response = subtensor.staking.set_perpetual_lock( + wallet=alice_wallet, + netuid=alice_sn.netuid, + enabled=False, + ) + logging.console.info(f"Set perpetual=False response: {response}") + assert response.success + + is_perpetual = subtensor.staking.is_perpetual_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is False + + # Non-owner: Bob is also decaying by default + is_perpetual = subtensor.staking.is_perpetual_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is False + + # Bob creates a lock and sets it to perpetual + assert subtensor.staking.add_stake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(3), + ).success + + assert subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(1).set_unit(alice_sn.netuid), + ).success + + response = subtensor.staking.set_perpetual_lock( + wallet=bob_wallet, + netuid=alice_sn.netuid, + enabled=True, + ) + assert response.success + + is_perpetual = subtensor.staking.is_perpetual_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is True + + +@pytest.mark.asyncio +async def test_perpetual_lock_toggle_async(async_subtensor, alice_wallet, bob_wallet): + """ + Async version: Tests set_perpetual_lock toggle for both owner and non-owner. + + 1. Locks are decaying by default (is_perpetual_lock returns False) + 2. Toggle to perpetual via set_perpetual_lock(enabled=True), verify + 3. Toggle back to decaying via set_perpetual_lock(enabled=False), verify + 4. Non-owner creates a lock, toggles to perpetual, verify + """ + 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) + + # Owner lock is decaying by default + is_perpetual = await async_subtensor.staking.is_perpetual_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is False + + # Set to perpetual + response = await async_subtensor.staking.set_perpetual_lock( + wallet=alice_wallet, + netuid=alice_sn.netuid, + enabled=True, + ) + logging.console.info(f"Set perpetual=True response: {response}") + assert response.success + + is_perpetual = await async_subtensor.staking.is_perpetual_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is True + + # Set back to decaying + response = await async_subtensor.staking.set_perpetual_lock( + wallet=alice_wallet, + netuid=alice_sn.netuid, + enabled=False, + ) + logging.console.info(f"Set perpetual=False response: {response}") + assert response.success + + is_perpetual = await async_subtensor.staking.is_perpetual_lock( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is False + + # Non-owner: Bob is also decaying by default + is_perpetual = await async_subtensor.staking.is_perpetual_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is False + + # Bob creates a lock and sets it to perpetual + assert ( + await async_subtensor.staking.add_stake( + wallet=bob_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + amount=Balance.from_tao(3), + ) + ).success + + assert ( + await async_subtensor.staking.lock_stake( + wallet=bob_wallet, + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(1).set_unit(alice_sn.netuid), + ) + ).success + + response = await async_subtensor.staking.set_perpetual_lock( + wallet=bob_wallet, + netuid=alice_sn.netuid, + enabled=True, + ) + assert response.success + + is_perpetual = await async_subtensor.staking.is_perpetual_lock( + coldkey_ss58=bob_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert is_perpetual is True From ee72535efbea61d83b7950119c27cf6d162405e7 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 11:37:59 -0700 Subject: [PATCH 13/18] alphabetical order --- bittensor/extras/subtensor_api/extrinsics.py | 16 +++++++++++----- bittensor/extras/subtensor_api/staking.py | 6 +++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/bittensor/extras/subtensor_api/extrinsics.py b/bittensor/extras/subtensor_api/extrinsics.py index 878a638ec9..438ae94978 100644 --- a/bittensor/extras/subtensor_api/extrinsics.py +++ b/bittensor/extras/subtensor_api/extrinsics.py @@ -13,17 +13,19 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): 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.clear_coldkey_swap_announcement = subtensor.clear_coldkey_swap_announcement - self.dispute_coldkey_swap = subtensor.dispute_coldkey_swap self.burned_register = subtensor.burned_register self.claim_root = subtensor.claim_root + self.clear_coldkey_swap_announcement = subtensor.clear_coldkey_swap_announcement self.commit_weights = subtensor.commit_weights self.contribute_crowdloan = subtensor.contribute_crowdloan self.create_crowdloan = subtensor.create_crowdloan + self.dispute_coldkey_swap = subtensor.dispute_coldkey_swap self.dissolve_crowdloan = subtensor.dissolve_crowdloan self.finalize_crowdloan = subtensor.finalize_crowdloan self.get_extrinsic_fee = subtensor.get_extrinsic_fee + self.lock_stake = subtensor.lock_stake self.modify_liquidity = subtensor.modify_liquidity + self.move_lock = subtensor.move_lock self.move_stake = subtensor.move_stake self.refund_crowdloan = subtensor.refund_crowdloan self.register = subtensor.register @@ -35,12 +37,16 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.root_set_pending_childkey_cooldown = ( subtensor.root_set_pending_childkey_cooldown ) - self.set_children = subtensor.set_children - self.set_subnet_identity = subtensor.set_subnet_identity - self.set_weights = subtensor.set_weights self.serve_axon = subtensor.serve_axon + self.set_auto_stake = subtensor.set_auto_stake + self.set_children = subtensor.set_children self.set_commitment = subtensor.set_commitment + self.set_delegate_take = subtensor.set_delegate_take + self.set_perpetual_lock = subtensor.set_perpetual_lock + self.set_reveal_commitment = subtensor.set_reveal_commitment self.set_root_claim_type = subtensor.set_root_claim_type + self.set_subnet_identity = subtensor.set_subnet_identity + self.set_weights = subtensor.set_weights self.start_call = subtensor.start_call self.swap_coldkey_announced = subtensor.swap_coldkey_announced self.swap_stake = subtensor.swap_stake diff --git a/bittensor/extras/subtensor_api/staking.py b/bittensor/extras/subtensor_api/staking.py index 23ee27e6a4..d488f694f0 100644 --- a/bittensor/extras/subtensor_api/staking.py +++ b/bittensor/extras/subtensor_api/staking.py @@ -12,6 +12,7 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.add_stake_multiple = subtensor.add_stake_multiple self.claim_root = subtensor.claim_root self.get_auto_stakes = subtensor.get_auto_stakes + self.get_coldkey_lock = subtensor.get_coldkey_lock self.get_hotkey_conviction = subtensor.get_hotkey_conviction self.get_hotkey_stake = subtensor.get_hotkey_stake self.get_minimum_required_stake = subtensor.get_minimum_required_stake @@ -25,17 +26,16 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_root_claimable_all_rates = subtensor.get_root_claimable_all_rates self.get_root_claimable_rate = subtensor.get_root_claimable_rate self.get_root_claimable_stake = subtensor.get_root_claimable_stake - self.get_coldkey_lock = subtensor.get_coldkey_lock self.get_root_claimed = subtensor.get_root_claimed self.get_stake = subtensor.get_stake self.get_stake_add_fee = subtensor.get_stake_add_fee - self.get_stake_lock = subtensor.get_stake_lock - self.get_stake_locks = subtensor.get_stake_locks self.get_stake_for_coldkey_and_hotkey = ( subtensor.get_stake_for_coldkey_and_hotkey ) self.get_stake_info_for_coldkey = subtensor.get_stake_info_for_coldkey self.get_stake_info_for_coldkeys = subtensor.get_stake_info_for_coldkeys + self.get_stake_lock = subtensor.get_stake_lock + self.get_stake_locks = subtensor.get_stake_locks self.get_stake_movement_fee = subtensor.get_stake_movement_fee self.get_stake_weight = subtensor.get_stake_weight self.get_staking_hotkeys = subtensor.get_staking_hotkeys From 10e6723305dc8beb35e2f7cc9983930415a4dc17 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 12:32:55 -0700 Subject: [PATCH 14/18] fix after default is changed --- bittensor/core/async_subtensor.py | 7 ++++--- bittensor/core/subtensor.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 4ba2459d87..c83cd30a80 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -5482,7 +5482,8 @@ async def is_perpetual_lock( Checks whether a coldkey's lock on a subnet is perpetual (non-decaying). Locks decay by default. A lock becomes perpetual only when the coldkey explicitly opts in via - set_perpetual_lock(enabled=True), which inserts a DecayingLock entry. + set_perpetual_lock(enabled=True), which inserts a ``DecayingLock`` entry with value ``false``. When the entry is + absent, the lock decays normally. Parameters: coldkey_ss58: The SS58 address of the coldkey to check. @@ -5492,7 +5493,7 @@ async def is_perpetual_lock( reuse_block: Whether to reuse the last-used block hash. Returns: - True if the lock is perpetual (does not decay), False if it decays. + True if the lock is perpetual (does not decay), False if it decays or no lock exists. """ query = await self.query_subtensor( name="DecayingLock", @@ -5501,7 +5502,7 @@ async def is_perpetual_lock( block_hash=block_hash, reuse_block=reuse_block, ) - return query.value is not None + return query.value is False async def is_subnet_active( self, diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index d878ce8e0a..59f6207cd5 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -4486,7 +4486,8 @@ def is_perpetual_lock( Checks whether a coldkey's lock on a subnet is perpetual (non-decaying). Locks decay by default. A lock becomes perpetual only when the coldkey explicitly opts in via - set_perpetual_lock(enabled=True), which inserts a DecayingLock entry. + set_perpetual_lock(enabled=True), which inserts a ``DecayingLock`` entry with value ``false``. When the entry is + absent, the lock decays normally. Parameters: coldkey_ss58: The SS58 address of the coldkey to check. @@ -4494,12 +4495,12 @@ def is_perpetual_lock( block: The block number to query. If None, queries the current block. Returns: - True if the lock is perpetual (does not decay), False if it decays. + True if the lock is perpetual (does not decay), False if it decays or no lock exists. """ query = self.query_subtensor( name="DecayingLock", params=[coldkey_ss58, netuid], block=block ) - return query.value is not None + return query.value is False def is_subnet_active(self, netuid: int, block: Optional[int] = None) -> bool: """Verifies if a subnet with the provided netuid is active. From 3e87c5b2902cbd45284b9eb2cb09da5da0073890 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 12:33:10 -0700 Subject: [PATCH 15/18] update e2e tests --- tests/e2e_tests/test_lock_stake.py | 166 +++++++++++++++++++---------- 1 file changed, 109 insertions(+), 57 deletions(-) diff --git a/tests/e2e_tests/test_lock_stake.py b/tests/e2e_tests/test_lock_stake.py index b430e00697..c26df229d9 100644 --- a/tests/e2e_tests/test_lock_stake.py +++ b/tests/e2e_tests/test_lock_stake.py @@ -12,14 +12,15 @@ def test_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet): """ - Tests owner lock lifecycle including auto-lock from emission. - - 1. After emission, owner already has an auto-lock on their own hotkey - 2. Query lock state and verify conviction > 0 (owner gets instant conviction) - 3. Verify get_most_convicted_hotkey_on_subnet returns owner hotkey - 4. Fail to lock on a different hotkey (LockHotkeyMismatch) - 5. Top-up the existing lock on the same hotkey - 6. Unstake within available limit succeeds, lock remains unchanged + Tests owner lock lifecycle. + + 1. Owner has no locks initially + 2. Owner adds stake and creates a lock on their own hotkey + 3. Query lock state and verify conviction > 0 (owner gets instant conviction) + 4. Verify get_most_convicted_hotkey_on_subnet returns owner hotkey + 5. Fail to lock on a different hotkey (LockHotkeyMismatch) + 6. Top-up the existing lock on the same hotkey + 7. Unstake within available limit succeeds, lock remains unchanged """ alice_sn = TestSubnet(subtensor) steps = [ @@ -29,16 +30,38 @@ def test_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet): ] alice_sn.execute_steps(steps) - # Wait for at least one epoch so that emission fires auto_lock_owner_cut - next_epoch = subtensor.subnets.get_next_epoch_start_block(alice_sn.netuid) - subtensor.wait_for_block(next_epoch + 1) + # Owner has no locks initially (auto_lock_owner_cut is disabled by default) + locks = subtensor.staking.get_stake_locks( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert len(locks) == 0 + + # Add stake so owner can create a lock + assert subtensor.staking.add_stake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=Balance.from_tao(10), + ).success + + # Owner creates a lock on their own hotkey + lock_amount = Balance.from_tao(3).set_unit(alice_sn.netuid) + response = subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=lock_amount, + ) + logging.console.info(f"Owner lock response: {response}") + assert response.success - # Owner should already have an auto-lock on their own hotkey + # Verify lock exists locks = subtensor.staking.get_stake_locks( coldkey_ss58=alice_wallet.coldkey.ss58_address, netuid=alice_sn.netuid, ) - logging.console.info(f"Owner locks after emission: {locks}") + logging.console.info(f"Owner locks: {locks}") assert len(locks) == 1 hotkey, lock_state = locks[0] assert hotkey == alice_wallet.hotkey.ss58_address @@ -69,14 +92,6 @@ def test_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet): logging.console.info(f"Lock on different hotkey response: {response}") assert response.success is False - # Add more stake on own hotkey so we have enough to lock - assert subtensor.staking.add_stake( - wallet=alice_wallet, - netuid=alice_sn.netuid, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - amount=Balance.from_tao(5), - ).success - # Record locked_mass before top-up lock_before = subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, @@ -117,7 +132,7 @@ def test_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet): logging.console.info(f"Unstake small amount response: {response}") assert response.success - # Unstake must not reduce locked_mass (may grow due to auto_lock_owner_cut between blocks) + # Unstake must not reduce locked_mass lock_unchanged = subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, netuid=alice_sn.netuid, @@ -129,14 +144,15 @@ def test_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet): @pytest.mark.asyncio async def test_owner_lock_lifecycle_async(async_subtensor, alice_wallet, bob_wallet): """ - Async version: Tests owner lock lifecycle including auto-lock from emission. - - 1. After emission, owner already has an auto-lock on their own hotkey - 2. Query lock state and verify conviction > 0 (owner gets instant conviction) - 3. Verify get_most_convicted_hotkey_on_subnet returns owner hotkey - 4. Fail to lock on a different hotkey (LockHotkeyMismatch) - 5. Top-up the existing lock on the same hotkey - 6. Unstake within available limit succeeds, lock remains unchanged + Async version: Tests owner lock lifecycle. + + 1. Owner has no locks initially + 2. Owner adds stake and creates a lock on their own hotkey + 3. Query lock state and verify conviction > 0 (owner gets instant conviction) + 4. Verify get_most_convicted_hotkey_on_subnet returns owner hotkey + 5. Fail to lock on a different hotkey (LockHotkeyMismatch) + 6. Top-up the existing lock on the same hotkey + 7. Unstake within available limit succeeds, lock remains unchanged """ alice_sn = TestSubnet(async_subtensor) steps = [ @@ -146,18 +162,40 @@ async def test_owner_lock_lifecycle_async(async_subtensor, alice_wallet, bob_wal ] await alice_sn.async_execute_steps(steps) - # Wait for at least one epoch so that emission fires auto_lock_owner_cut - next_epoch = await async_subtensor.subnets.get_next_epoch_start_block( - alice_sn.netuid + # Owner has no locks initially (auto_lock_owner_cut is disabled by default) + locks = await async_subtensor.staking.get_stake_locks( + coldkey_ss58=alice_wallet.coldkey.ss58_address, + netuid=alice_sn.netuid, + ) + assert len(locks) == 0 + + # Add stake so owner can create a lock + assert ( + await async_subtensor.staking.add_stake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=Balance.from_tao(10), + ) + ).success + + # Owner creates a lock on their own hotkey + lock_amount = Balance.from_tao(3).set_unit(alice_sn.netuid) + response = await async_subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=lock_amount, ) - await async_subtensor.wait_for_block(next_epoch + 1) + logging.console.info(f"Owner lock response: {response}") + assert response.success - # Owner should already have an auto-lock on their own hotkey + # Verify lock exists locks = await async_subtensor.staking.get_stake_locks( coldkey_ss58=alice_wallet.coldkey.ss58_address, netuid=alice_sn.netuid, ) - logging.console.info(f"Owner locks after emission: {locks}") + logging.console.info(f"Owner locks: {locks}") assert len(locks) == 1 hotkey, lock_state = locks[0] assert hotkey == alice_wallet.hotkey.ss58_address @@ -188,16 +226,6 @@ async def test_owner_lock_lifecycle_async(async_subtensor, alice_wallet, bob_wal logging.console.info(f"Lock on different hotkey response: {response}") assert response.success is False - # Add more stake on own hotkey so we have enough to lock - assert ( - await async_subtensor.staking.add_stake( - wallet=alice_wallet, - netuid=alice_sn.netuid, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - amount=Balance.from_tao(5), - ) - ).success - # Record locked_mass before top-up lock_before = await async_subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, @@ -238,7 +266,7 @@ async def test_owner_lock_lifecycle_async(async_subtensor, alice_wallet, bob_wal logging.console.info(f"Unstake small amount response: {response}") assert response.success - # Unstake must not reduce locked_mass (may grow due to auto_lock_owner_cut between blocks) + # Unstake must not reduce locked_mass lock_unchanged = await async_subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, netuid=alice_sn.netuid, @@ -539,7 +567,7 @@ def test_move_lock(subtensor, alice_wallet, bob_wallet, charlie_wallet): 1. Fail to move when no lock exists (NoExistingLock) 2. Create a lock for Bob, then move it to Charlie's hotkey 3. Verify lock moved: old hotkey has None, new hotkey has lock - 4. Owner moves their auto-lock to a different hotkey + 4. Owner creates a lock and moves it to a different hotkey 5. Verify owner lock moved successfully """ alice_sn = TestSubnet(subtensor) @@ -613,9 +641,20 @@ def test_move_lock(subtensor, alice_wallet, bob_wallet, charlie_wallet): assert new_lock["locked_mass"].rao > lock_amount.rao * 0.99 logging.console.info(f"Bob lock after move: {new_lock}") - # Owner move: wait for emission so owner has auto-lock - next_epoch = subtensor.subnets.get_next_epoch_start_block(alice_sn.netuid) - subtensor.wait_for_block(next_epoch + 1) + # Owner creates a lock manually (auto_lock_owner_cut is disabled by default) + assert subtensor.staking.add_stake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ).success + + assert subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(2).set_unit(alice_sn.netuid), + ).success owner_lock = subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, @@ -662,7 +701,7 @@ async def test_move_lock_async( 1. Fail to move when no lock exists (NoExistingLock) 2. Create a lock for Bob, then move it to Charlie's hotkey 3. Verify lock moved: old hotkey has None, new hotkey has lock - 4. Owner moves their auto-lock to a different hotkey + 4. Owner creates a lock and moves it to a different hotkey 5. Verify owner lock moved successfully """ alice_sn = TestSubnet(async_subtensor) @@ -740,11 +779,24 @@ async def test_move_lock_async( assert new_lock["locked_mass"].rao > lock_amount.rao * 0.99 logging.console.info(f"Bob lock after move: {new_lock}") - # Owner move: wait for emission so owner has auto-lock - next_epoch = await async_subtensor.subnets.get_next_epoch_start_block( - alice_sn.netuid - ) - await async_subtensor.wait_for_block(next_epoch + 1) + # Owner creates a lock manually (auto_lock_owner_cut is disabled by default) + assert ( + await async_subtensor.staking.add_stake( + wallet=alice_wallet, + netuid=alice_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=Balance.from_tao(5), + ) + ).success + + assert ( + await async_subtensor.staking.lock_stake( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=alice_sn.netuid, + amount=Balance.from_tao(2).set_unit(alice_sn.netuid), + ) + ).success owner_lock = await async_subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, From 7cae101b7fd966d2832865c08946e457aa0948c6 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 15:03:17 -0700 Subject: [PATCH 16/18] CHANGELOG.md update --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f88b8ddb94..c49a34c9e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 10.4.0 /2026-05-28 + +## What's Changed +* Fixes tests in subtensor 2569 by @thewhaleking in https://github.com/latent-to/bittensor/pull/3355 +* Improves monitor_requirements_size_master workflow by @thewhaleking in https://github.com/latent-to/bittensor/pull/3357 +* PR guard workflow by @thewhaleking in https://github.com/latent-to/bittensor/pull/3358 +* Support `Conviction v2` by @basfroman in https://github.com/latent-to/bittensor/pull/3361 + +**Full Changelog**: https://github.com/latent-to/bittensor/compare/v10.3.2...v10.4.0 + ## 10.3.2 /2026-05-15 ## What's Changed From 702ed972cd8bffc309a4e12978d32101ed2931ca Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 15:03:25 -0700 Subject: [PATCH 17/18] bumping version --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb67624b5a..efee739a24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.3.2" +version = "10.4.0" description = "Bittensor SDK" readme = "README.md" authors = [ @@ -32,7 +32,7 @@ dependencies = [ "cyscale>=0.3.3,<1.0.0", "uvicorn", "bittensor-drand>=1.3.0,<2.0.0", - "bittensor-wallet==4.0.1", + "bittensor-wallet>=4.1.0", "async-substrate-interface>=2.0.4,<3.0.0", ] From 1818377b638721dbb626f60397522d069013bcfc Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 15:13:48 -0700 Subject: [PATCH 18/18] bumping btcli version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index efee739a24..bd054e8ce2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ torch = [ "torch>=1.13.1,<3.0" ] cli = [ - "bittensor-cli>=9.21.1" + "bittensor-cli>=9.22.0" ]