diff --git a/.github/workflows/e2e-subtensor-tests.yml b/.github/workflows/e2e-subtensor-tests.yml index d84156e44..2c0679a3a 100644 --- a/.github/workflows/e2e-subtensor-tests.yml +++ b/.github/workflows/e2e-subtensor-tests.yml @@ -104,6 +104,20 @@ jobs: fi fi + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + echo "Reading PR description for image tag override ..." + pr_body=$(gh pr view ${{ github.event.pull_request.number }} -R ${{ github.repository }} --json body --jq '.body' || echo "") + # Look for a line like: E2E_IMAGE_TAG=pr-2638 + desc_tag=$(printf '%s\n' "$pr_body" | grep -oE 'E2E_IMAGE_TAG=[A-Za-z0-9._-]+' | head -n1 | cut -d= -f2) + if [[ -n "$desc_tag" ]]; then + image="ghcr.io/opentensor/subtensor-localnet:${desc_tag}" + echo "Using image tag from PR description: ${desc_tag}" + echo "✅ Final selected image: $image" + echo "image=$image" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "Reading labels ..." if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then # Use GitHub CLI to read labels (works for forks too) diff --git a/.github/workflows/pr-guard.yml b/.github/workflows/pr-guard.yml index e2e4e2267..1693e2ad5 100644 --- a/.github/workflows/pr-guard.yml +++ b/.github/workflows/pr-guard.yml @@ -5,15 +5,15 @@ permissions: pull-requests: write on: - pull_request: + pull_request_target: types: [ opened, edited, synchronize, reopened ] jobs: target-branch: - if: github.base_ref == 'main' && !startsWith(github.head_ref, 'release') runs-on: ubuntu-latest steps: - name: Comment and fail when targeting main from a non-release branch + if: github.base_ref == 'main' && !startsWith(github.head_ref, 'release') uses: actions/github-script@v9 with: script: | @@ -23,7 +23,7 @@ jobs: issue_number: context.issue.number, body: 'PRs need to be open against staging.', }); - core.setFailed('PRs need to be open against the 'staging' branch.'); + core.setFailed("PRs need to be open against the 'staging' branch."); signed-commits: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 8db2c170a..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,332 +0,0 @@ -# Contributing to BTCLI (Bittensor CLI) - -The following is a set of guidelines for contributing to btcli, which is hosted in the [Opentensor Organization](https://github.com/opentensor) on GitHub. -These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. - -## Table Of Contents -1. [I don't want to read this whole thing, I just have a question!!!](#i-dont-want-to-read-this-whole-thing-i-just-have-a-question) -2. [What should I know before I get started?](#what-should-i-know-before-i-get-started) -3. [Getting Started](#getting-started) - 1. [Good First Issue Label](#good-first-issue-label) - 2. [Beginner and Help-wanted Issues Label](#beginner-and-help-wanted-issues-label) -4. [How Can I Contribute?](#how-can-i-contribute) - 1. [Code Contribution General Guideline](#code-contribution-general-guidelines) - 2. [Pull Request Philosophy](#pull-request-philosophy) - 3. [Pull Request Process](#pull-request-process) - 4. [Testing](#testing) - 5. [Addressing Feedback](#addressing-feedback) - 6. [Squashing Commits](#squashing-commits) - 7. [Refactoring](#refactoring) - 8. [Peer Review](#peer-review) -5. [Reporting Bugs](#reporting-bugs) -6. [Suggesting Features](#suggesting-enhancements-and-features) - - -## I don't want to read this whole thing I just have a question! - -> **Note:** Please don't file an issue to ask a question. You'll get faster results by using the resources below. - -We have an official Discord server where the community chimes in with helpful advice if you have questions. -This is the fastest way to get an answer and the core development team is active on Discord. Also linked is the -more community-oriented Church of Rao Discord, which has channels that focus more on development than specific subnets -or generalities. - -* [Official Bittensor Discord](https://discord.gg/bittensor) -* [Church of Rao Discord](https://discord.gg/brRAeVCmzM) - -## What should I know before I get started? -Bittensor is constantly growing with new features, and as such you will potentially run into some problems running btcli. -If you run into an issue or end up resolving an issue yourself, -feel free to create a pull request with a fix or with a fix to the documentation. The documentation repository -can be found [here](https://github.com/latent-to/developer-docs). - -Additionally, note that the core implementation of Bittensor consists of three separate repositories: -[The core Bittensor code](https://github.com/opentensor/bittensor), the [btcli](https://github.com/opentensor/btcli), -and the Bittensor Blockchain [subtensor](https://github.com/opentensor/subtensor). - -See the [Tao.app](https://www.tao.app/explorer) explorer for a list of all the repositories for the active registered subnets. - -## Getting Started -New contributors are very welcome and needed. -Reviewing and testing is highly valued and the most effective way you can contribute as a new contributor. -It also will teach you much more about the code and process than opening pull requests. - -There are frequently open issues of varying difficulty waiting to be fixed. -If you're looking for somewhere to start contributing, check out the [good first issue](https://github.com/opentensor/btcli/labels/good%20first%20issue) -list or changes that are up for grabs. Some of them might no longer be applicable. -So if you are interested, but unsure, you might want to leave a comment on the issue first. -Also peruse the [issues](https://github.com/opentensor/btcli/issues) tab for all open issues. - -### Good First Issue Label -The purpose of the good first issue label is to highlight which issues are suitable for a new contributor without a deep understanding of the codebase. - -However, good first issues can be solved by anyone. If they remain unsolved for a longer time, a frequent contributor might address them. - -You do not need to request permission to start working on an issue. However, you are encouraged to leave a comment -if you are planning to work on it. This will help other contributors monitor which issues are actively being -addressed and is also an effective way to request assistance if and when you need it. - -### Beginner and Help-wanted Issues Label -You can start by looking through these `beginner` and `help-wanted` issues: - -* [Beginner issues](https://github.com/opentensor/btcli/labels/beginner) - issues which should only require a few lines of code, and a test or two. -* [Help wanted issues](https://github.com/opentensor/btcli/labels/help%20wanted) - issues which should be a bit more involved than `beginner` issues. - -## Communication Channels -Most communication about Bittensor/btcli development happens on [Discord](https://discord.gg/bittensor). - -You can engage with the community in the [general](https://discord.com/channels/799672011265015819/799672011814862902) channel and follow the release announcements posted [here](https://discord.com/channels/799672011265015819/1359587876563718144). - -## How Can I Contribute? - -You can contribute to btcli in one of two main ways (as well as many others): -1. [Bug](#reporting-bugs) reporting and fixes -2. New features [enhancements](#suggesting-enhancements-and-features) - -### Style Guide -Here is a high-level summary of the bittensor style guide: -- Code consistency is crucial; adhere to established programming language conventions. -- Use `ruff format .` to format your Python code; it ensures readability and consistency. - - Verify you are using the same version of `ruff` as is declared in the [pyproject.toml](./pyproject.toml) -- Write concise Git commit messages; summarize changes in ~50 characters. -- Follow these six commit rules: - - Atomic Commits: Focus on one task or fix per commit. - - Subject and Body Separation: Use a blank line to separate the subject from the body. - - Subject Line Length: Keep it under 50 characters for readability. - - Imperative Mood: Write subject line as if giving a command or instruction. - - Body Text Width: Wrap text manually at 72 characters. - - Body Content: Explain what changed and why, not how. -- Make use of your commit messages to simplify project understanding and maintenance. - -### Code Contribution General Guidelines - -If you're looking to contribute to btcli but unsure where to start, -please join our community [discord](https://discord.gg/bittensor), a developer-friendly Bittensor town square. -You can also browse through the GitHub [issues](https://github.com/opentensor/btcli/issues) to see where help might be needed. -For a greater understanding of btcli's usage and development, check the [Bittensor Documentation](https://docs.learnbittensor.org). - -All PRs must be opened against the `staging` branch. Use appropriate labels and provide an in-depth description of what your PR does, -what changes it makes, which issues it resolves, etc. - -#### Pull Request Philosophy - -Patchsets and enhancements should always be focused. A pull request could add a feature, fix a bug, or refactor code, -but it should not contain a mixture of these. Please also avoid 'super' pull requests which attempt to do too much, -are overly large, or overly complex as this makes review difficult. - -Specifically, pull requests **must** adhere to the following criteria: -- **Must** branch off from `staging`. Make sure that all your PRs are using `staging` branch as a base or they **will** be closed. -- Contain a reasonable number of changes for the stated purpose of the PR. PRs that contain an excessive number of lines of code or files without a valid rationale may be closed. -- If a PR introduces a new feature, it **must** include corresponding tests. -- Other PRs (bug fixes, refactoring, etc.) should ideally also have tests, as they provide proof of concept and prevent regression. -- Categorize your PR properly by using GitHub labels. This aids in the review process by informing reviewers about the type of change at a glance. -- Make sure your code includes adequate, but not unnecessary comments. These should explain why certain decisions were made and how your changes work. -- If your changes are extensive, consider breaking your PR into smaller, related PRs. This makes your contributions easier to understand and review. -- Be active in the discussion about your PR. Respond promptly to comments and questions to help reviewers understand your changes and speed up the acceptance process. - -Generally, all pull requests must: - - - Have a clear use case, fix a demonstrable bug or serve the greater good of the project (e.g. refactoring for modularisation). - - Be well peer-reviewed. - - Follow code style guidelines. - - Not break the existing test suite. - - Where bugs are fixed, where possible, there should be unit tests demonstrating the bug and also proving the fix. - - Change relevant comments and documentation when behaviour of code changes. - -#### Pull Request Process - -Please follow these steps to have your contribution considered by the maintainers: - -*Before* creating the PR: -1. Ensure your PR meets the criteria stated in the [Pull Request Philosophy](#pull-request-philosophy) section. -2. Include relevant tests for any fixed bugs or new features as stated in the [testing guide](./TESTING.md). -3. Ensure your commit messages are clear and concise. Include the issue number if applicable. -4. Explain what your changes do and why you think they should be merged in the PR description -5. Ensure your code is formatted correctly with [`ruff`](#style-guide) -6. Ensure [all tests pass](#testing): run `pytest tests/` - -*After* creating the PR: -1. Verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing after you submit your pull request. -2. Label your PR using GitHub's labeling feature. The labels help categorize the PR and streamline the review process. - -Please be responsive and participate in the discussion on your PR! This aids in clarifying any confusion or concerns and -leads to quicker resolution and merging of your PR. - -> Note: If your changes are not ready for merge but you want feedback, create a draft pull request. - -Following these criteria will aid in quicker review and potential merging of your PR. -While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted. - -When you are ready to submit your changes, create a pull request. - -After you submit a pull request, it will be reviewed by the maintainers. -They may ask you to make changes. Please respond to any comments and push your changes as a new commit. - -> Note: Be sure to merge the latest from "upstream" before making a pull request: - -```bash -git remote add upstream https://github.com/opentensor/btcli.git -git fetch upstream -git merge upstream/ -git push origin -``` - -#### Testing -Before making a PR for any code changes, please write adequate testing with pytest if it is warranted. -This is **mandatory** for new features and enhancements. - -You may also like to view the [/tests](https://github.com/opentensor/btcli/tree/master/tests) for starter examples. - -Here is a quick summary: -- **Running Tests**: Use `pytest` from the root directory of the btcli repository to run all tests. To run a specific test file or a specific test within a file, specify it directly (e.g., `pytest tests/e2e_tests/test_wallet_interactions.py::test_wallet_overview_inspect`). - - Before submitting your PR, ensure that all tests pass by running `pytest tests/` -- **Writing Tests**: When writing tests, cover both the "happy path" and any potential error conditions. Use the `assert` statement to verify the expected behavior of a function. -- **Mocking**: Use the `pytest` library to mock certain functions (with monkeypatch) or objects when you need to isolate the functionality you're testing. This allows you to control the behavior of these functions or objects during testing. -- **Test Coverage**: Use the `pytest-cov` plugin to measure your test coverage. Aim for high coverage but also ensure your tests are meaningful and accurately represent the conditions under which your code will run. -- **Continuous Integration**: btcli uses GitHub Actions for continuous integration. Tests are automatically run every time you push changes to the repository. Check the "Actions" tab of the btcli GitHub repository to view the results. - -Remember, testing is crucial for maintaining code health, catching issues early, and facilitating the addition of new features or refactoring of existing code. - -#### Addressing Feedback - -After submitting your pull request, expect comments and reviews from other contributors. -You can add more commits to your pull request by committing them locally and pushing to your fork. - -You are expected to reply to any review comments before your pull request is merged. -You may update the code or reject the feedback if you do not agree with it, but you should express so in a reply. -If there is outstanding feedback and you are not actively working on it, your pull request may be closed. - -#### Squashing Commits - -If your pull request contains fixup commits (commits that change the same line of code repeatedly) or too fine-grained commits, you may be asked to [squash](https://git-scm.com/docs/git-rebase#_interactive_mode) your commits before it will be reviewed. The basic squashing workflow is shown below. - - git checkout your_branch_name - git rebase -i HEAD~n - # n is normally the number of commits in the pull request. - # Set commits (except the one in the first line) from 'pick' to 'squash', save and quit. - # On the next screen, edit/refine commit messages. - # Save and quit. - git push -f # (force push to GitHub) - -Please update the resulting commit message, if needed. It should read as a coherent message. In most cases, -this means not just listing the interim commits. - -If your change contains a merge commit, the above workflow may not work and you will need to remove the merge -commit first. See the next section for details on how to rebase. - -Please refrain from creating several pull requests for the same change. Use the pull request that is already open -(or was created earlier) to amend changes. This preserves the discussion and review that happened earlier for the respective change set. - -The length of time required for peer review is unpredictable and will vary from pull request to pull request. - -#### Refactoring - -Refactoring is a necessary part of any software project's evolution. -The following guidelines cover refactoring pull requests for the btcli project. - -There are three categories of refactoring: code-only moves, code style fixes, and code refactoring. In general, -refactoring pull requests should not mix these three kinds of activities in order to make refactoring pull requests -easy to review and uncontroversial. In all cases, refactoring PRs must not change the behaviour of code within the -pull request (bugs must be preserved as is). - -Project maintainers aim for a quick turnaround on refactoring pull requests, so where possible keep them short, -uncomplex and easy to verify. - -Pull requests that refactor the code should not be made by new contributors. -It requires a certain level of experience to know where the code belongs to and to understand the full -ramification (including rebase effort of open pull requests). Trivial pull requests or pull requests that -refactor the code with no clear benefits may be immediately closed by the maintainers to reduce -unnecessary workload on reviewing. - -#### Peer Review - -Anyone may participate in peer review which is expressed by comments in the pull request. -Typically, reviewers will review the code for obvious errors, as well as test out the patch set and -opine on the technical merits of the patch. Project maintainers take into account the peer review when -determining if there is consensus to merge a pull request (remember that discussions may have taken -place elsewhere, not just on GitHub). - -A pull request that changes consensus-critical code is considerably more involved than a pull request that adds a -feature to the logger output, for example. Such patches must be reviewed and thoroughly tested by several -reviewers who are knowledgeable about the changed subsystems. Where new features are proposed, -it is helpful for reviewers to try out the patch set on a test network and indicate that they have done -so in their review. Project maintainers will take this into consideration when merging changes. - -### Reporting Bugs - -This section guides you through submitting a bug report for btcli. -Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find -related reports. - -When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). - -> **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, -> open a new issue and include a link to the original issue in the body of your new one. - -#### Before Submitting A Bug Report - -* **Check the [Discord Server](https://discord.gg/bittensor)** and ask in [#general](https://discord.com/channels/799672011265015819/799672011814862902). -* **Determine which repository the problem should be reported in**: if it has to do with incorrect client-side behavior, -then it's likely [btcli](https://github.com/opentensor/btcli). -If you are having problems with your emissions or Blockchain, then it is in [subtensor](https://github.com/opentensor/subtensor). - -#### How Do I Submit A (Good) Bug Report? - -Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). You can find btcli's issues [here](https://github.com/opentensor/btcli/issues). -After you've determined which repository ([btcli](https://github.com/opentensor/btcli) or [subtensor](https://github.com/opentensor/subtensor)) your bug is related to, create an issue on that repository. - -Explain the problem and include additional details to help maintainers reproduce the problem: - -* **Use a clear and descriptive title** for the issue to identify the problem. -* **Describe the exact steps which reproduce the problem** in as many details as possible. -For example, start by explaining how you started btcli, e.g. which command exactly you used in the terminal, -When listing steps, **don't just say what you did, but explain how you did it**. -* **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, -which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks). -* **Explain which behavior you expected to see instead and why.** -* **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. -On macOS, the crash report will be available in `Console.app` under "Diagnostic and usage information" > "User diagnostic reports". -Include the crash report in the issue in a [code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks), a [file attachment](https://docs.github.com/articles/file-attachments-on-issues-and-pull-requests/), or put it in a [gist](https://gist.github.com/) and provide link to that gist. -* **If the problem wasn't triggered by a specific action**, describe what you were doing before the problem happened and share more information using the guidelines below. -* **Use `--debug`**, by running `btcli --debug` after a failing command, a debug report will be generated that removes all sensitive information. Include this debug report in your Issue. - -Provide more context by answering these questions: - -* **Did the problem start happening recently** (e.g. after updating to a new version of btcli) or was this always a problem? -* If the problem started happening recently, **can you reproduce the problem in an older version of btcli?** -* **Can you reliably reproduce the issue?** If not, provide details about how often the problem happens and under which conditions it normally happens. - -Include details about your configuration and environment: - -* **Which version of btcli are you using?** You can get the version of btcli by executing the `btcli --version` command. -* **What's the name and version of the OS you're using**? -* **Are you running btcli in a virtual machine?** If so, which VM software are you using and which operating systems and versions are used for the host and the guest? - -### Suggesting Enhancements and Features - -This section guides you through submitting an enhancement suggestion for btcli, -including completely new features and minor improvements to existing functionality. Following these guidelines helps -maintainers and the community understand your suggestion and find related suggestions. - -When you are creating an enhancement suggestion, please include as many details as possible. - -#### Before Submitting An Enhancement Suggestion - -* **Determine which repository the problem should be reported in**: if it has to do with unexpected client-side behavior, then it's likely [btcli](https://github.com/opentensor/btcli). -If you are having problems with your emissions or Blockchain, then it is in [subtensor](https://github.com/opentensor/subtensor) - -#### How To Submit A (Good) Feature Suggestion - -Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). After you've determined which repository ([btcli](https://github.com/opentensor/btcli) or [subtensor](https://github.com/opentensor/subtensor)) your enhancement suggestion is related to, create an issue on that repository and provide the following information: - -* **Use a clear and descriptive title** for the issue to identify the problem. -* **Provide a step-by-step description of the suggested enhancement** in as many details as possible. -* **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks). -* **Describe the current behavior** and **explain which behavior you expected to see instead** and why. -* **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. -* **Explain why this enhancement would be useful** to most btcli users. -* **List some other text editors or applications where this enhancement exists.** -* **Specify which version of btcli are you using?** You can get the version of the btcli by executing the `btcli --version` command. -* **Specify the name and version of the OS you're using.** - -Thank you for considering contributing to btcli! Any help is greatly appreciated along this journey to incentivize open and permissionless intelligence. diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 86ff00b15..5a81b224b 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -77,7 +77,6 @@ ) from bittensor_cli.src.commands import sudo, wallets, view from bittensor_cli.src.commands import weights as weights_cmds -from bittensor_cli.src.commands.liquidity import liquidity from bittensor_cli.src.commands.crowd import ( contribute as crowd_contribute, create as create_crowdloan, @@ -87,10 +86,6 @@ refund as crowd_refund, contributors as crowd_contributors, ) -from bittensor_cli.src.commands.liquidity.utils import ( - prompt_liquidity, - prompt_position_id, -) from bittensor_cli.src.commands import proxy as proxy_commands from bittensor_cli.src.commands.proxy import ProxyType from bittensor_cli.src.commands.stake import ( @@ -884,7 +879,6 @@ def __init__(self): self.subnet_mechanisms_app = typer.Typer(epilog=_epilog) self.weights_app = typer.Typer(epilog=_epilog) self.view_app = typer.Typer(epilog=_epilog) - self.liquidity_app = typer.Typer(epilog=_epilog) self.crowd_app = typer.Typer(epilog=_epilog) self.utils_app = typer.Typer(epilog=_epilog) self.axon_app = typer.Typer(epilog=_epilog) @@ -1204,6 +1198,9 @@ def __init__(self): self.sudo_app.command("trim", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"])( self.sudo_trim ) + self.sudo_app.command( + "trigger-epoch", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"] + )(self.sudo_trigger_epoch) self.sudo_app.command( "stake-burn", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"] )(self.sudo_stake_burn) @@ -1394,30 +1391,6 @@ def __init__(self): "dissolve", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] )(self.crowd_dissolve) - # Liquidity - self.app.add_typer( - self.liquidity_app, - name="liquidity", - short_help="liquidity commands, aliases: `l`", - no_args_is_help=True, - ) - self.app.add_typer( - self.liquidity_app, name="l", hidden=True, no_args_is_help=True - ) - # liquidity commands - self.liquidity_app.command( - "add", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] - )(self.liquidity_add) - self.liquidity_app.command( - "list", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] - )(self.liquidity_list) - self.liquidity_app.command( - "modify", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] - )(self.liquidity_modify) - self.liquidity_app.command( - "remove", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] - )(self.liquidity_remove) - # utils app self.utils_app.command("convert")(self.convert) self.utils_app.command("latency")(self.best_connection) @@ -2616,6 +2589,11 @@ def wallet_list( self, wallet_name: Optional[str] = Options.wallet_name, wallet_path: str = Options.wallet_path, + coldkeys_only: bool = typer.Option( + False, + "--coldkeys-only", + help="List coldkeys only; omit hotkeys from the output.", + ), quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -2626,11 +2604,13 @@ def wallet_list( The output display shows each wallet and its associated `ss58` addresses for the coldkey public key and any hotkeys. The output is presented in a hierarchical tree format, with each wallet as a root node and any associated hotkeys as child nodes. The `ss58` address (or an `` marker, for encrypted hotkeys) is displayed for each coldkey and hotkey that exists on the device. Upon invocation, the command scans the wallet directory and prints a list of all the wallets, indicating whether the - public keys are available (`?` denotes unavailable or encrypted keys). + public keys are available (`?` denotes unavailable or encrypted keys). Coldkeys and hotkeys are listed in natural + sort order (e.g. coldkey2 before coldkey10). # EXAMPLE [green]$[/green] btcli wallet list --path ~/.bittensor + [green]$[/green] btcli w list --coldkeys-only [bold]NOTE[/bold]: This command is read-only and does not modify the filesystem or the blockchain state. It is intended for use with the Bittensor CLI to provide a quick overview of the user's wallets. """ @@ -2643,6 +2623,7 @@ def wallet_list( wallet.path, json_output, wallet_name=wallet_name, + coldkeys_only=coldkeys_only, ) ) @@ -7351,7 +7332,6 @@ def sudo_set( console.print("Available hyperparameters:\n") # Create a table to show hyperparameters with descriptions - param_table = Table( Column("[white]#", style="dim", width=4), Column("[white]HYPERPARAMETER", style=COLORS.SU.HYPERPARAMETER), @@ -7399,19 +7379,13 @@ def sudo_set( description = metadata.get("description", "No description available.") docs_link = metadata.get("docs_link", "") if docs_link: - # Show description text followed by clickable blue [link] at the end console.print( f"{description} [bright_blue underline link=https://{docs_link}]link[/]" ) else: console.print(f"{description}") - side_effects = metadata.get("side_effects", "") - if side_effects: + if side_effects := metadata.get("side_effects", ""): console.print(f"[dim]Side Effects:[/dim] {side_effects}") - if docs_link: - console.print( - f"[dim]📚 Docs:[/dim] [link]https://{docs_link}[/link]\n" - ) if param_name in ["alpha_high", "alpha_low"]: if not prompt: @@ -7420,16 +7394,13 @@ def sudo_set( "They must be set together via the alpha_values parameter." ) if json_output: - json_str = json.dumps( - { + json_console.print_json( + data={ "success": False, "err_msg": err_msg, "extrinsic_identifier": None, - }, - ensure_ascii=True, + } ) - sys.stdout.write(json_str + "\n") - sys.stdout.flush() else: print_error( f"[{COLORS.SU.HYPERPARAM}]alpha_high[/{COLORS.SU.HYPERPARAM}] and " @@ -7441,23 +7412,20 @@ def sudo_set( low_val = FloatPrompt.ask(f"Enter the new value for {arg__('alpha_low')}") high_val = FloatPrompt.ask(f"Enter the new value for {arg__('alpha_high')}") param_value = f"{low_val},{high_val}" - if param_name == "yuma_version": + elif param_name == "yuma_version": if not prompt: err_msg = ( "yuma_version is set using a different hyperparameter (yuma3_enabled), " "and thus cannot be set with `--no-prompt`" ) if json_output: - json_str = json.dumps( - { + json_console.print_json( + data={ "success": False, "err_msg": err_msg, "extrinsic_identifier": None, - }, - ensure_ascii=True, + } ) - sys.stdout.write(json_str + "\n") - sys.stdout.flush() else: print_error( f"[{COLORS.SU.HYPERPARAM}]yuma_version[/{COLORS.SU.HYPERPARAM}]" @@ -7478,22 +7446,45 @@ def sudo_set( param_value = "true" if question == "enable" else "false" else: return False + elif param_name == "activity_cutoff": + err_msg = ( + "activity_cutoff is now derived from activity_cutoff_factor " + "(cutoff blocks = factor × tempo ÷ 1000) and can no longer be set " + "directly. Set activity_cutoff_factor instead (per-mille units; " + "1000 = one full tempo)." + ) + if json_output: + json_console.print_json( + data={ + "success": False, + "err_msg": err_msg, + "extrinsic_identifier": None, + } + ) + else: + print_error( + f"[{COLORS.SU.HYPERPARAM}]activity_cutoff[/{COLORS.SU.HYPERPARAM}] " + f"is now derived from " + f"[{COLORS.SU.HYPERPARAM}]activity_cutoff_factor[/{COLORS.SU.HYPERPARAM}] " + f"(cutoff blocks = factor × tempo ÷ 1000). Set " + f"[{COLORS.SU.HYPERPARAM}]activity_cutoff_factor[/{COLORS.SU.HYPERPARAM}] " + f"instead (per-mille units; 1000 = one full tempo)." + ) + return False + if param_name == "subnet_is_active": err_msg = ( "subnet_is_active is set by using the 'btcli subnets start' command, " "not via sudo set" ) if json_output: - json_str = json.dumps( - { + json_console.print_json( + data={ "success": False, "err_msg": err_msg, "extrinsic_identifier": None, - }, - ensure_ascii=True, + } ) - sys.stdout.write(json_str + "\n") - sys.stdout.flush() else: print_error( f"[{COLORS.SU.HYPERPARAM}]subnet_is_active[/{COLORS.SU.HYPERPARAM}] " @@ -7528,60 +7519,29 @@ def sudo_set( f"param_name: {param_name}\n" f"param_value: {param_value}" ) + + result, err_msg, ext_id = self._run_command( + sudo.sudo_set_hyperparameter( + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + proxy=proxy, + param_name=param_name, + param_value=param_value, + normalize=normalize_value, + prompt=prompt, + json_output=json_output, + ) + ) if json_output: - try: - result, err_msg, ext_id = self._run_command( - sudo.sudo_set_hyperparameter( - wallet=wallet, - subtensor=self.initialize_chain(network), - netuid=netuid, - proxy=proxy, - param_name=param_name, - param_value=param_value, - normalize=normalize_value, - prompt=prompt, - json_output=json_output, - ) - ) - json_str = json.dumps( - { - "success": result, - "err_msg": err_msg, - "extrinsic_identifier": ext_id, - }, - ensure_ascii=True, - ) - sys.stdout.write(json_str + "\n") - sys.stdout.flush() - return result - except Exception as e: - # Ensure JSON output even on exceptions - json_str = json.dumps( - { - "success": False, - "err_msg": str(e), - "extrinsic_identifier": None, - }, - ensure_ascii=True, - ) - sys.stdout.write(json_str + "\n") - sys.stdout.flush() - raise - else: - result, err_msg, ext_id = self._run_command( - sudo.sudo_set_hyperparameter( - wallet=wallet, - subtensor=self.initialize_chain(network), - netuid=netuid, - proxy=proxy, - param_name=param_name, - param_value=param_value, - normalize=normalize_value, - prompt=prompt, - json_output=json_output, - ) + json_console.print_json( + data={ + "success": result, + "err_msg": err_msg, + "extrinsic_identifier": ext_id, + } ) - return result + return result def sudo_get( self, @@ -7860,6 +7820,57 @@ def sudo_trim( ) ) + def sudo_trigger_epoch( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + netuid: int = Options.netuid, + proxy: Optional[str] = Options.proxy, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + prompt: bool = Options.prompt, + decline: bool = Options.decline, + period: int = Options.period, + ): + """ + Manually triggers an epoch for a subnet you own. + + The epoch fires after the chain's admin freeze window has elapsed, during which + admin operations on the subnet are locked. This is rate-limited on-chain and + fails if a trigger is already pending, the next automatic epoch is imminent, or + commit-reveal is enabled on the subnet (disable it first via + 'btcli sudo set --param commit_reveal_weights_enabled --value false'). + + EXAMPLE + [green]$[/green] btcli sudo trigger-epoch --netuid 95 --wallet-name my_wallet --wallet-hotkey my_hotkey + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, False) + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + self._run_command( + sudo.trigger_epoch( + subtensor=self.initialize_chain(network), + wallet=wallet, + netuid=netuid, + period=period, + proxy=proxy, + json_output=json_output, + prompt=prompt, + decline=decline, + quiet=quiet, + ) + ) + def sudo_stake_burn( self, network: Optional[list[str]] = Options.network, @@ -9060,301 +9071,6 @@ def view_dashboard( ) ) - # Liquidity - - def liquidity_add( - self, - network: Optional[list[str]] = Options.network, - wallet_name: str = Options.wallet_name, - wallet_path: str = Options.wallet_path, - wallet_hotkey: str = Options.wallet_hotkey, - netuid: Optional[int] = Options.netuid, - proxy: Optional[str] = Options.proxy, - liquidity_: Optional[float] = typer.Option( - None, - "--liquidity", - help="Amount of liquidity to add to the subnet.", - ), - price_low: Optional[float] = typer.Option( - None, - "--price-low", - "--price_low", - "--liquidity-price-low", - "--liquidity_price_low", - help="Low price for the adding liquidity position.", - ), - price_high: Optional[float] = typer.Option( - None, - "--price-high", - "--price_high", - "--liquidity-price-high", - "--liquidity_price_high", - help="High price for the adding liquidity position.", - ), - prompt: bool = Options.prompt, - decline: bool = Options.decline, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - json_output: bool = Options.json_output, - ): - """Add liquidity to the swap (as a combination of TAO + Alpha).""" - self.verbosity_handler(quiet, verbose, json_output, prompt, decline) - proxy = self.is_valid_proxy_name_or_ss58(proxy, False) - if not netuid: - netuid = Prompt.ask( - f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", - default=None, - show_default=False, - ) - - wallet, hotkey = self.wallet_ask( - wallet_name=wallet_name, - wallet_path=wallet_path, - wallet_hotkey=wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], - validate=WV.WALLET, - return_wallet_and_hotkey=True, - ) - # Determine the liquidity amount. - if liquidity_: - liquidity_ = Balance.from_tao(liquidity_) - else: - liquidity_ = prompt_liquidity("Enter the amount of liquidity") - - # Determine price range - if price_low: - price_low = Balance.from_tao(price_low) - else: - price_low = prompt_liquidity("Enter liquidity position low price") - - if price_high: - price_high = Balance.from_tao(price_high) - else: - price_high = prompt_liquidity( - "Enter liquidity position high price (must be greater than low price)" - ) - - if price_low >= price_high: - print_error("The low price must be lower than the high price.") - return False - logger.debug( - f"args:\n" - f"hotkey: {type(hotkey)}\n" - f"netuid: {netuid}\n" - f"liquidity: {liquidity_}\n" - f"price_low: {price_low}\n" - f"price_high: {price_high}\n" - f"proxy: {type(proxy)}\n" - ) - return self._run_command( - liquidity.add_liquidity( - subtensor=self.initialize_chain(network), - wallet=wallet, - hotkey_ss58=hotkey, - netuid=netuid, - proxy=proxy, - liquidity=liquidity_, - price_low=price_low, - price_high=price_high, - prompt=prompt, - decline=decline, - quiet=quiet, - json_output=json_output, - ) - ) - - def liquidity_list( - self, - network: Optional[list[str]] = Options.network, - wallet_name: str = Options.wallet_name, - wallet_path: str = Options.wallet_path, - wallet_hotkey: str = Options.wallet_hotkey, - netuid: Optional[int] = Options.netuid, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - json_output: bool = Options.json_output, - ): - """Displays liquidity positions in given subnet.""" - self.verbosity_handler(quiet, verbose, json_output, prompt=False) - if not netuid: - netuid = IntPrompt.ask( - f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", - default=None, - show_default=False, - ) - - wallet = self.wallet_ask( - wallet_name=wallet_name, - wallet_path=wallet_path, - wallet_hotkey=wallet_hotkey, - ask_for=[WO.NAME, WO.PATH], - validate=WV.WALLET, - ) - self._run_command( - liquidity.show_liquidity_list( - subtensor=self.initialize_chain(network), - wallet=wallet, - netuid=netuid, - json_output=json_output, - ) - ) - - def liquidity_remove( - self, - network: Optional[list[str]] = Options.network, - wallet_name: str = Options.wallet_name, - wallet_path: str = Options.wallet_path, - wallet_hotkey: str = Options.wallet_hotkey, - netuid: Optional[int] = Options.netuid, - proxy: Optional[str] = Options.proxy, - position_id: Optional[int] = typer.Option( - None, - "--position-id", - "--position_id", - help="Position ID for modification or removal.", - ), - all_liquidity_ids: Optional[bool] = typer.Option( - False, - "--all", - "--a", - help="Whether to remove all liquidity positions for given subnet.", - ), - prompt: bool = Options.prompt, - decline: bool = Options.decline, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - json_output: bool = Options.json_output, - ): - """Remove liquidity from the swap (as a combination of TAO + Alpha).""" - - self.verbosity_handler(quiet, verbose, json_output, prompt, decline) - proxy = self.is_valid_proxy_name_or_ss58(proxy, False) - if all_liquidity_ids and position_id: - print_error("Cannot specify both --all and --position-id.") - return - - if not position_id and not all_liquidity_ids: - position_id = prompt_position_id() - - if not netuid: - netuid = IntPrompt.ask( - f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", - default=None, - show_default=False, - ) - - wallet, hotkey = self.wallet_ask( - wallet_name=wallet_name, - wallet_path=wallet_path, - wallet_hotkey=wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], - validate=WV.WALLET, - return_wallet_and_hotkey=True, - ) - logger.debug( - f"args:\n" - f"network: {network}\n" - f"hotkey: {type(hotkey)}\n" - f"netuid: {netuid}\n" - f"position_id: {position_id}\n" - f"all_liquidity_ids: {all_liquidity_ids}\n" - ) - return self._run_command( - liquidity.remove_liquidity( - subtensor=self.initialize_chain(network), - wallet=wallet, - hotkey_ss58=hotkey, - netuid=netuid, - proxy=proxy, - position_id=position_id, - prompt=prompt, - decline=decline, - quiet=quiet, - all_liquidity_ids=all_liquidity_ids, - json_output=json_output, - ) - ) - - def liquidity_modify( - self, - network: Optional[list[str]] = Options.network, - wallet_name: str = Options.wallet_name, - wallet_path: str = Options.wallet_path, - wallet_hotkey: str = Options.wallet_hotkey, - netuid: Optional[int] = Options.netuid, - proxy: Optional[str] = Options.proxy, - position_id: Optional[int] = typer.Option( - None, - "--position-id", - "--position_id", - help="Position ID for modification or removing.", - ), - liquidity_delta: Optional[float] = typer.Option( - None, - "--liquidity-delta", - "--liquidity_delta", - help="Liquidity amount for modification.", - ), - prompt: bool = Options.prompt, - decline: bool = Options.decline, - quiet: bool = Options.quiet, - verbose: bool = Options.verbose, - json_output: bool = Options.json_output, - ): - """Modifies the liquidity position for the given subnet.""" - self.verbosity_handler(quiet, verbose, json_output, prompt, decline) - proxy = self.is_valid_proxy_name_or_ss58(proxy, False) - if not netuid: - netuid = IntPrompt.ask( - f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", - ) - - wallet, hotkey = self.wallet_ask( - wallet_name=wallet_name, - wallet_path=wallet_path, - wallet_hotkey=wallet_hotkey, - ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], - validate=WV.WALLET, - return_wallet_and_hotkey=True, - ) - - if not position_id: - position_id = prompt_position_id() - - if liquidity_delta: - liquidity_delta = Balance.from_tao(liquidity_delta) - else: - liquidity_delta = prompt_liquidity( - f"Enter the [blue]liquidity delta[/blue] to modify position with id " - f"[blue]{position_id}[/blue] (can be positive or negative)", - negative_allowed=True, - ) - logger.debug( - f"args:\n" - f"network: {network}\n" - f"hotkey: {type(hotkey)}\n" - f"netuid: {netuid}\n" - f"position_id: {position_id}\n" - f"liquidity_delta: {liquidity_delta}\n" - f"proxy: {type(proxy)}\n" - ) - - return self._run_command( - liquidity.modify_liquidity( - subtensor=self.initialize_chain(network), - wallet=wallet, - hotkey_ss58=hotkey, - netuid=netuid, - proxy=proxy, - position_id=position_id, - liquidity_delta=liquidity_delta, - prompt=prompt, - decline=decline, - quiet=quiet, - json_output=json_output, - ) - ) - # Crowd def crowd_list( diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 0b2da85a9..390a1329c 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -584,10 +584,10 @@ class RootSudoOnly(Enum): "kappa": ("sudo_set_kappa", RootSudoOnly.TRUE), "immunity_period": ("sudo_set_immunity_period", RootSudoOnly.FALSE), "min_allowed_weights": ("sudo_set_min_allowed_weights", RootSudoOnly.FALSE), - "tempo": ("sudo_set_tempo", RootSudoOnly.TRUE), + "tempo": ("set_tempo", RootSudoOnly.COMPLICATED), "weights_version": ("sudo_set_weights_version_key", RootSudoOnly.FALSE), "weights_rate_limit": ("sudo_set_weights_set_rate_limit", RootSudoOnly.TRUE), - "activity_cutoff": ("sudo_set_activity_cutoff", RootSudoOnly.FALSE), + "activity_cutoff_factor": ("set_activity_cutoff_factor", RootSudoOnly.COMPLICATED), "target_regs_per_interval": ( "sudo_set_target_registrations_per_interval", RootSudoOnly.TRUE, @@ -618,7 +618,6 @@ class RootSudoOnly(Enum): ), "yuma3_enabled": ("sudo_set_yuma3_enabled", RootSudoOnly.FALSE), "alpha_sigmoid_steepness": ("sudo_set_alpha_sigmoid_steepness", RootSudoOnly.TRUE), - "user_liquidity_enabled": ("toggle_user_liquidity", RootSudoOnly.COMPLICATED), "bonds_reset_enabled": ("sudo_set_bonds_reset_enabled", RootSudoOnly.FALSE), "transfers_enabled": ("sudo_set_toggle_transfer", RootSudoOnly.FALSE), "min_allowed_uids": ("sudo_set_min_allowed_uids", RootSudoOnly.TRUE), @@ -636,14 +635,33 @@ class RootSudoOnly(Enum): "alpha_low": ("", RootSudoOnly.FALSE), # Derived from alpha_values # "subnet_is_active": ("", RootSudoOnly.FALSE), # Set via btcli subnets start + "activity_cutoff": ( + "", + RootSudoOnly.FALSE, + ), # Derived: activity_cutoff_factor × tempo / 1000 "yuma_version": ("", RootSudoOnly.FALSE), # Related to yuma3_enabled "max_allowed_uids": ("sudo_set_max_allowed_uids", RootSudoOnly.FALSE), "burn_increase_mult": ("sudo_set_burn_increase_mult", RootSudoOnly.FALSE), "burn_half_life": ("sudo_set_burn_half_life", RootSudoOnly.FALSE), + "min_childkey_take": ( + "sudo_set_min_childkey_take_per_subnet", + RootSudoOnly.FALSE, + ), } -HYPERPARAMS_MODULE = { - "user_liquidity_enabled": "Swap", +# Maps a hyperparameter to a non-default pallet for sudo set calls. Hyperparameters +# not listed here live in the default pallet (AdminUtils). +HYPERPARAMS_MODULE: dict[str, str] = { + "tempo": "SubtensorModule", + "activity_cutoff_factor": "SubtensorModule", +} + +# Hyperparameters whose root-sudo path uses a different extrinsic than the owner path. +# Maps btcli name -> (pallet, extrinsic) for the call wrapped in Sudo. Owner-side +# set_tempo is bounded to [360, 50400] and rate-limited; root's sudo_set_tempo accepts +# any u16 value. +HYPERPARAMS_ROOT_EXTRINSIC: dict[str, tuple[str, str]] = { + "tempo": ("AdminUtils", "sudo_set_tempo"), } # Hyperparameter metadata: descriptions, side-effects, ownership, and documentation links @@ -667,9 +685,9 @@ class RootSudoOnly(Enum): "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#minallowedweights", }, "tempo": { - "description": "Number of blocks between epoch transitions", - "side_effects": "Lower tempo means more frequent updates but higher chain load. Higher tempo reduces frequency but may slow responsiveness.", - "owner_settable": False, + "description": "Number of blocks between automatic epoch transitions. Owner-settable between 360 and 50400 blocks (rate-limited to one change per 360 blocks); root can set any value via sudo.", + "side_effects": "Lower tempo means more frequent updates but higher chain load. Higher tempo reduces frequency but may slow responsiveness. Changing tempo resets the epoch cycle, so the next epoch fires a full tempo after the change.", + "owner_settable": True, "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#tempo", }, "weights_version": { @@ -685,8 +703,14 @@ class RootSudoOnly(Enum): "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#weightsratelimit--commitmentratelimit", }, "activity_cutoff": { - "description": "Minimum activity level required for neurons to remain active.", - "side_effects": "Lower values keep more neurons active; higher values prune inactive neurons more aggressively.", + "description": "Effective validator inactivity cutoff in blocks, computed as activity_cutoff_factor × tempo ÷ 1000. Read-only; set activity_cutoff_factor instead.", + "side_effects": "Lower values prune inactive validators more aggressively; higher values keep more validators active.", + "owner_settable": False, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#activitycutoff", + }, + "activity_cutoff_factor": { + "description": "Tolerated validator inactivity as per-mille of tempo (1000 = one full tempo). Effective cutoff in blocks = factor × tempo ÷ 1000. Allowed range: 1000 to 50000.", + "side_effects": "Lower factors prune inactive validators more aggressively; higher factors tolerate longer inactivity. The effective cutoff scales with tempo.", "owner_settable": True, "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#activitycutoff", }, @@ -780,12 +804,6 @@ class RootSudoOnly(Enum): "owner_settable": False, "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#alphasigmoidsteepness", }, - "user_liquidity_enabled": { - "description": "Enable or disable user liquidity features.", - "side_effects": "Enabling allows liquidity provision and swaps. Disabling restricts liquidity operations.", - "owner_settable": True, # COMPLICATED - can be set by owner or sudo - "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#userliquidityenabled", - }, "bonds_reset_enabled": { "description": "Enable or disable periodic bond resets.", "side_effects": "Enabling provides periodic bond resets, preventing bond accumulation. Disabling allows bonds to accumulate.", @@ -877,6 +895,12 @@ class RootSudoOnly(Enum): "owner_settable": True, "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#burnincreasemult", }, + "min_childkey_take": { + "description": "Minimum childkey take (%) required on this subnet. Settable by the subnet owner. Cannot be set below the global protocol minimum.", + "side_effects": "Child hotkeys on this subnet must set take at or above this value.", + "owner_settable": True, + "docs_link": "docs.learnbittensor.org/subnets/subnet-hyperparameters#minchildkeytake", + }, } # Help Panels for cli help @@ -921,9 +945,6 @@ class RootSudoOnly(Enum): "VIEW": { "DASHBOARD": "Network Dashboard", }, - "LIQUIDITY": { - "LIQUIDITY_MGMT": "Liquidity Management", - }, "CROWD": { "INITIATOR": "Crowdloan Creation & Management", "PARTICIPANT": "Crowdloan Participation", diff --git a/bittensor_cli/src/bittensor/extrinsics/registration.py b/bittensor_cli/src/bittensor/extrinsics/registration.py index 688578ce0..e499c2447 100644 --- a/bittensor_cli/src/bittensor/extrinsics/registration.py +++ b/bittensor_cli/src/bittensor/extrinsics/registration.py @@ -97,7 +97,7 @@ async def burned_register_extrinsic( block_hash=block_hash, ), ) - validity_period = tempo - blocks_since_last_step + validity_period = max(tempo - blocks_since_last_step, 8) era_ = { "period": validity_period, "current": current_block, diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 9e69ba9a8..214f31c30 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -48,7 +48,7 @@ ProxyAnnouncements, ) from scalecodec.base import ScaleType -from scalecodec.utils.math import fixed_to_decimal +from scalecodec.utils.math import fixed_to_decimal, FixedPoint GENESIS_ADDRESS = "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" @@ -113,12 +113,14 @@ def __init__(self, network, use_disk_cache: bool = True): if (use_disk_cache or os.getenv("DISK_CACHE", "1") == "1") else AsyncSubstrateInterface ) - self.substrate = substrate_class( - url=self.chain_endpoint, - ss58_format=SS58_FORMAT, - type_registry=TYPE_REGISTRY, - chain_name="Bittensor", - ws_shutdown_timer=None, + self.substrate: AsyncSubstrateInterface | DiskCachedAsyncSubstrateInterface = ( + substrate_class( + url=self.chain_endpoint, + ss58_format=SS58_FORMAT, + type_registry=TYPE_REGISTRY, + chain_name="Bittensor", + ws_shutdown_timer=None, + ) ) def __str__(self): @@ -1653,6 +1655,29 @@ async def get_subnet_hyperparameters( return SubnetHyperparameters.from_any(result) + async def get_next_epoch_start_block( + self, netuid: int, block_hash: Optional[str] = None + ) -> Optional[int]: + """ + Returns the block number at which the subnet's next epoch is expected to fire, + considering both the automatic schedule and any pending owner-triggered epoch. + It does not account for per-block epoch-cap deferrals, which can push the + actual firing block slightly later. + + Returns `None` if the subnet does not run epochs (tempo == 0) or if the chain + does not expose the runtime API (runtimes without dynamic tempo). + """ + try: + result = await self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], + block_hash=block_hash, + ) + except (ValueError, SubstrateRequestException): + return None + return None if result is None else int(result) + async def get_subnet_mechanisms( self, netuid: int, block_hash: Optional[str] = None ) -> int: @@ -2523,7 +2548,7 @@ async def get_claimable_rate_all_netuids( Returns: dict[int, float]: Dictionary mapping netuid to claimable rate. """ - query = await self.query( + query: dict[int, FixedPoint] = await self.query( module="SubtensorModule", storage_function="RootClaimable", params=[hotkey_ss58], @@ -2533,8 +2558,10 @@ async def get_claimable_rate_all_netuids( if not query: return {} - bits_list = next(iter(query)) - return {bits[0]: fixed_to_float(bits[1], frac_bits=32) for bits in bits_list} + return { + netuid: fixed_to_float(bits, frac_bits=32) + for (netuid, bits) in query.items() + } async def get_claimable_rate_netuid( self, @@ -2678,7 +2705,7 @@ async def get_claimable_stakes_for_coldkey( for idx, (_, result) in enumerate(batch_claimable): hotkey = unique_hotkeys[idx] if result: - for netuid, rate in result: + for netuid, rate in result.items(): if hotkey not in claimable_rates: claimable_rates[hotkey] = {} claimable_rates[hotkey][netuid] = fixed_to_float(rate, frac_bits=32) @@ -2706,6 +2733,39 @@ async def get_claimable_stakes_for_coldkey( results[hotkey][netuid] = net_claimable.set_unit(netuid) return results + async def _runtime_method_exists( + self, api: str, method: str, block_hash: Optional[str] = None + ) -> bool: + """ + Checks whether a runtime call method exists at the given block. + + :param api: The runtime API name (e.g. `"SwapRuntimeApi"`). + :param method: The method within the runtime API to check for. + :param block_hash: The hash of the block at which to check. + + :return: `True` if the runtime call method exists, `False` otherwise. + """ + runtime = await self.substrate.init_runtime(block_hash=block_hash) + try: + _ = runtime.runtime_api_map[api][method] + return True + except KeyError: + return False + + async def _get_subnet_price_from_storage( + self, netuid: int, block_hash: Optional[str] = None + ) -> Balance: + """Legacy Alpha-price lookup for pre-Balancer chains via ``Swap::AlphaSqrtPrice``.""" + current_sqrt_price = await self.query( + module="Swap", + storage_function="AlphaSqrtPrice", + params=[netuid], + block_hash=block_hash, + ) + current_sqrt_price = fixed_to_float(current_sqrt_price) + current_price = current_sqrt_price * current_sqrt_price + return Balance.from_rao(int(current_price * 1e9)) + async def get_subnet_price( self, netuid: int = None, @@ -2714,22 +2774,34 @@ async def get_subnet_price( """ Gets the current Alpha price in TAO for a specific subnet. + Uses the `SwapRuntimeApi::current_alpha_price` runtime call (Balancer swap). If the connected + chain does not expose that runtime method, falls back to the legacy `Swap::AlphaSqrtPrice` + storage query so the CLI keeps working against pre-Balancer chains. Note this is only necessary to exist + until mainnet release, as it allows for working with the staging branch on live data until then. + :param netuid: The unique identifier of the subnet. :param block_hash: The hash of the block to retrieve the price from. :return: The current Alpha price in TAO units for the specified subnet. """ - # TODO update this to use the runtime call SwapRuntimeAPI.current_alpha_price - current_sqrt_price = await self.query( - module="Swap", - storage_function="AlphaSqrtPrice", - params=[netuid], - block_hash=block_hash, - ) + # SN0 (root) uses TAO directly and always has a price of 1 TAO. + if netuid == 0: + return Balance.from_tao(1) - current_sqrt_price = fixed_to_float(current_sqrt_price) - current_price = current_sqrt_price * current_sqrt_price - return Balance.from_rao(int(current_price * 1e9)) + if await self._runtime_method_exists( + "SwapRuntimeApi", "current_alpha_price", block_hash=block_hash + ): + price_rao = await self.query_runtime_api( + "SwapRuntimeApi", + "current_alpha_price", + params=[netuid], + block_hash=block_hash, + ) + return Balance.from_rao(int(price_rao)) + else: + return await self._get_subnet_price_from_storage( + netuid, block_hash=block_hash + ) async def get_subnet_prices( self, block_hash: Optional[str] = None, page_size: int = 200 @@ -2737,11 +2809,43 @@ async def get_subnet_prices( """ Gets the current Alpha prices in TAO for all subnets. + Prefers the `SwapRuntimeApi::current_alpha_price_all` runtime call (Balancer swap), falling + back to per-subnet `current_alpha_price` calls, and finally to the legacy + `Swap::AlphaSqrtPrice` storage map for pre-Balancer chains. + :param block_hash: The hash of the block to retrieve prices from. - :param page_size: The page size for batch queries (default: 100). + :param page_size: The page size for the legacy storage fallback query. :return: A dictionary mapping netuid to the current Alpha price in TAO units. """ + if block_hash is None: + block_hash = await self.substrate.get_chain_head() + + if await self._runtime_method_exists( + "SwapRuntimeApi", "current_alpha_price_all", block_hash=block_hash + ): + prices_rao = await self.query_runtime_api( + "SwapRuntimeApi", + "current_alpha_price_all", + block_hash=block_hash, + ) + return { + int(p["netuid"]): Balance.from_rao(int(p["price"])) for p in prices_rao + } + + if await self._runtime_method_exists( + "SwapRuntimeApi", "current_alpha_price", block_hash=block_hash + ): + netuids = await self.get_all_subnet_netuids(block_hash=block_hash) + prices_list = await asyncio.gather( + *[ + self.get_subnet_price(netuid, block_hash=block_hash) + for netuid in netuids + ] + ) + return dict(zip(netuids, prices_list)) + + # Legacy fallback for pre-Balancer chains. query = await self.substrate.query_map( module="Swap", storage_function="AlphaSqrtPrice", diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 3b5ecd19a..8717df340 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -852,8 +852,11 @@ def normalize_hyperparameters( "alpha_high": u16_normalized_float, "alpha_low": u16_normalized_float, "alpha_sigmoid_steepness": u16_normalized_float, + "min_childkey_take": u16_normalized_float, "min_burn": Balance.from_rao, "max_burn": Balance.from_rao, + # Per-mille of tempo; normalized to the factor itself (1.0 = one full tempo). + "activity_cutoff_factor": lambda value: value / 1000, } normalized_values: list[tuple[str, str, str]] = [] diff --git a/bittensor_cli/src/commands/liquidity/__init__.py b/bittensor_cli/src/commands/liquidity/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bittensor_cli/src/commands/liquidity/liquidity.py b/bittensor_cli/src/commands/liquidity/liquidity.py deleted file mode 100644 index 32a3844ca..000000000 --- a/bittensor_cli/src/commands/liquidity/liquidity.py +++ /dev/null @@ -1,696 +0,0 @@ -import asyncio -import json -from typing import TYPE_CHECKING, Optional - -from async_substrate_interface import AsyncExtrinsicReceipt -from rich.table import Column, Table - -from bittensor_cli.src import COLORS -from bittensor_cli.src.bittensor.utils import ( - confirm_action, - unlock_key, - console, - print_error, - json_console, - print_extrinsic_id, -) -from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float -from bittensor_cli.src.commands.liquidity.utils import ( - LiquidityPosition, - calculate_fees, - get_fees, - price_to_tick, - tick_to_price, -) - -if TYPE_CHECKING: - from bittensor_wallet import Wallet - from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface - - -async def add_liquidity_extrinsic( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: int, - proxy: Optional[str], - liquidity: Balance, - price_low: Balance, - price_high: Balance, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - """ - Adds liquidity to the specified price range. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - hotkey_ss58: the SS58 of the hotkey to use for this transaction. - netuid: The UID of the target subnet for which the call is being initiated. - proxy: Optional proxy to use for this extrinsic submission. - liquidity: The amount of liquidity to be added. - price_low: The lower bound of the price tick range. - price_high: The upper bound of the price tick range. - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. - - Returns: - tuple: - bool: True if successful, False otherwise. - str: success message if successful, error message otherwise. - AsyncExtrinsicReceipt: extrinsic receipt if successful, None otherwise. - - Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call - `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None - - tick_low = price_to_tick(price_low.tao) - tick_high = price_to_tick(price_high.tao) - - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="add_liquidity", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "tick_low": tick_low, - "tick_high": tick_high, - "liquidity": liquidity.rao, - }, - ) - - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - proxy=proxy, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) - - -async def modify_liquidity_extrinsic( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: int, - proxy: Optional[str], - position_id: int, - liquidity_delta: Balance, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - """Modifies liquidity in liquidity position by adding or removing liquidity from it. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - hotkey_ss58: the SS58 of the hotkey to use for this transaction. - netuid: The UID of the target subnet for which the call is being initiated. - proxy: Optional proxy to use for this extrinsic submission. - position_id: The id of the position record in the pool. - liquidity_delta: The amount of liquidity to be added or removed (add if positive or remove if negative). - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. - - Returns: - tuple: - bool: True if successful, False otherwise. - str: success message if successful, error message otherwise. - AsyncExtrinsicReceipt: extrinsic receipt if successful, None otherwise. - - Note: Modifying is allowed even when user liquidity is enabled in specified subnet. - Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None - - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="modify_position", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "position_id": position_id, - "liquidity_delta": liquidity_delta.rao, - }, - ) - - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - proxy=proxy, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) - - -async def remove_liquidity_extrinsic( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - proxy: Optional[str], - netuid: int, - position_id: int, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - """Remove liquidity and credit balances back to wallet's hotkey stake. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - hotkey_ss58: the SS58 of the hotkey to use for this transaction. - proxy: Optional proxy to use for this extrinsic submission. - netuid: The UID of the target subnet for which the call is being initiated. - position_id: The id of the position record in the pool. - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. - - Returns: - tuple: - bool: True if successful, False otherwise. - str: success message if successful, error message otherwise. - AsyncExtrinsicReceipt: extrinsic receipt if successful, None otherwise. - - Note: Adding is allowed even when user liquidity is enabled in specified subnet. - Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None - - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="remove_liquidity", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "position_id": position_id, - }, - ) - - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - proxy=proxy, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) - - -async def toggle_user_liquidity_extrinsic( - subtensor: "SubtensorInterface", - wallet: "Wallet", - netuid: int, - enable: bool, - wait_for_inclusion: bool = True, - wait_for_finalization: bool = False, -) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: - """Allow to toggle user liquidity for specified subnet. - - Arguments: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - enable: Boolean indicating whether to enable user liquidity. - wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. - wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. - - Returns: - Tuple[bool, str]: - - True and a success message if the extrinsic is successfully submitted or processed. - - False and an error message if the submission fails or the wallet cannot be unlocked. - """ - if not (unlock := unlock_key(wallet)).success: - return False, unlock.message, None - - call = await subtensor.substrate.compose_call( - call_module="Swap", - call_function="toggle_user_liquidity", - call_params={"netuid": netuid, "enable": enable}, - ) - - return await subtensor.sign_and_send_extrinsic( - call=call, - wallet=wallet, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - ) - - -# Command -async def add_liquidity( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: Optional[int], - proxy: Optional[str], - liquidity: Balance, - price_low: Balance, - price_high: Balance, - prompt: bool, - decline: bool, - quiet: bool, - json_output: bool, -) -> tuple[bool, str]: - """Add liquidity position to provided subnet.""" - # Check wallet access - if not (ulw := unlock_key(wallet)).success: - return False, ulw.message - - # Check that the subnet exists. - if not await subtensor.subnet_exists(netuid=netuid): - return False, f"Subnet with netuid: {netuid} does not exist in {subtensor}." - - if prompt: - console.print( - "You are about to add a LiquidityPosition with:\n" - f"\tliquidity: {liquidity}\n" - f"\tprice low: {price_low}\n" - f"\tprice high: {price_high}\n" - f"\tto SN: {netuid}\n" - f"\tusing wallet with name: {wallet.name}" - ) - - if not confirm_action( - "Would you like to continue?", decline=decline, quiet=quiet - ): - return False, "User cancelled operation." - - success, message, ext_receipt = await add_liquidity_extrinsic( - subtensor=subtensor, - wallet=wallet, - hotkey_ss58=hotkey_ss58, - netuid=netuid, - proxy=proxy, - liquidity=liquidity, - price_low=price_low, - price_high=price_high, - ) - if success: - await print_extrinsic_id(ext_receipt) - ext_id = await ext_receipt.get_extrinsic_identifier() - else: - ext_id = None - if json_output: - json_console.print_json( - data={ - "success": success, - "message": message, - "extrinsic_identifier": ext_id, - } - ) - else: - if success: - console.print( - "[green]LiquidityPosition has been successfully added.[/green]" - ) - else: - print_error(f"Error: {message}") - return success, message - - -async def get_liquidity_list( - subtensor: "SubtensorInterface", - wallet: "Wallet", - netuid: Optional[int], -) -> tuple[bool, str, list]: - """ - Args: - wallet: wallet object - subtensor: SubtensorInterface object - netuid: the netuid to stake to (None indicates all subnets) - - Returns: - Tuple of (success, error message, liquidity list) - """ - - if not await subtensor.subnet_exists(netuid=netuid): - return False, f"Subnet with netuid: {netuid} does not exist in {subtensor}.", [] - - if not await subtensor.is_subnet_active(netuid=netuid): - return False, f"Subnet with netuid: {netuid} is not active in {subtensor}.", [] - - block_hash = await subtensor.substrate.get_chain_head() - ( - positions_response, - fee_global_tao, - fee_global_alpha, - current_sqrt_price, - ) = await asyncio.gather( - subtensor.substrate.query_map( - module="Swap", - storage_function="Positions", - params=[netuid, wallet.coldkeypub.ss58_address], - block_hash=block_hash, - ), - subtensor.query( - module="Swap", - storage_function="FeeGlobalTao", - params=[netuid], - block_hash=block_hash, - ), - subtensor.query( - module="Swap", - storage_function="FeeGlobalAlpha", - params=[netuid], - block_hash=block_hash, - ), - subtensor.query( - module="Swap", - storage_function="AlphaSqrtPrice", - params=[netuid], - block_hash=block_hash, - ), - ) - if len(positions_response.records) == 0: - return False, "No liquidity positions found.", [] - - current_sqrt_price = fixed_to_float(current_sqrt_price) - fee_global_tao = fixed_to_float(fee_global_tao) - fee_global_alpha = fixed_to_float(fee_global_alpha) - - current_price = current_sqrt_price * current_sqrt_price - current_tick = price_to_tick(current_price) - - preprocessed_positions = [] - positions_futures = [] - - async for _, position in positions_response: - tick_index_low = position.get("tick_low") - tick_index_high = position.get("tick_high") - preprocessed_positions.append((position, tick_index_low, tick_index_high)) - - # Get ticks for the position (for below/above fees) - positions_futures.append( - asyncio.gather( - subtensor.query( - module="Swap", - storage_function="Ticks", - params=[netuid, tick_index_low], - block_hash=block_hash, - ), - subtensor.query( - module="Swap", - storage_function="Ticks", - params=[netuid, tick_index_high], - block_hash=block_hash, - ), - ) - ) - - awaited_futures = await asyncio.gather(*positions_futures) - - positions = [] - - for (position, tick_index_low, tick_index_high), (tick_low, tick_high) in zip( - preprocessed_positions, awaited_futures - ): - tao_fees_below_low = get_fees( - current_tick=current_tick, - tick=tick_low, - tick_index=tick_index_low, - quote=True, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=False, - ) - tao_fees_above_high = get_fees( - current_tick=current_tick, - tick=tick_high, - tick_index=tick_index_high, - quote=True, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=True, - ) - alpha_fees_below_low = get_fees( - current_tick=current_tick, - tick=tick_low, - tick_index=tick_index_low, - quote=False, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=False, - ) - alpha_fees_above_high = get_fees( - current_tick=current_tick, - tick=tick_high, - tick_index=tick_index_high, - quote=False, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=True, - ) - - # Get position accrued fees - fees_tao, fees_alpha = calculate_fees( - position=position, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - tao_fees_below_low=tao_fees_below_low, - tao_fees_above_high=tao_fees_above_high, - alpha_fees_below_low=alpha_fees_below_low, - alpha_fees_above_high=alpha_fees_above_high, - netuid=netuid, - ) - - lp = LiquidityPosition( - **{ - "id": position.get("id"), - "price_low": Balance.from_tao(tick_to_price(position.get("tick_low"))), - "price_high": Balance.from_tao( - tick_to_price(position.get("tick_high")) - ), - "liquidity": Balance.from_rao(position.get("liquidity")), - "fees_tao": fees_tao, - "fees_alpha": fees_alpha, - "netuid": position.get("netuid"), - } - ) - positions.append(lp) - - return True, "", positions - - -async def show_liquidity_list( - subtensor: "SubtensorInterface", - wallet: "Wallet", - netuid: int, - json_output: bool = False, -) -> None: - current_price_, liquidity_list_ = await asyncio.gather( - subtensor.subnet(netuid=netuid), - get_liquidity_list(subtensor, wallet, netuid), - return_exceptions=True, - ) - if isinstance(current_price_, Exception): - success = False - err_msg = str(current_price_) - positions = [] - elif isinstance(liquidity_list_, Exception): - success = False - err_msg = str(liquidity_list_) - positions = [] - else: - (success, err_msg, positions) = liquidity_list_ - if not success: - if json_output: - json_console.print( - json.dumps({"success": success, "err_msg": err_msg, "positions": []}) - ) - return - else: - print_error(f"Error: {err_msg}") - return - liquidity_table = Table( - Column("ID", justify="center"), - Column("Liquidity", justify="center"), - Column("Alpha", justify="center"), - Column("Tao", justify="center"), - Column("Price low", justify="center"), - Column("Price high", justify="center"), - Column("Fee TAO", justify="center"), - Column("Fee Alpha", justify="center"), - title=f"\n[{COLORS.G.HEADER}]{'Liquidity Positions of '}{wallet.name} wallet in SN #{netuid}\n" - "Alpha and Tao columns are respective portions of liquidity.", - show_footer=False, - show_edge=True, - header_style="bold white", - border_style="bright_black", - style="bold", - title_justify="center", - show_lines=False, - pad_edge=True, - ) - json_table = [] - current_price = current_price_.price - lp: LiquidityPosition - for lp in positions: - alpha, tao = lp.to_token_amounts(current_price) - liquidity_table.add_row( - str(lp.id), - str(lp.liquidity.tao), - str(alpha), - str(tao), - str(lp.price_low), - str(lp.price_high), - str(lp.fees_tao), - str(lp.fees_alpha), - ) - json_table.append( - { - "id": lp.id, - "liquidity": lp.liquidity.tao, - "token_amounts": {"alpha": alpha.tao, "tao": tao.tao}, - "price_low": lp.price_low.tao, - "price_high": lp.price_high.tao, - "fees_tao": lp.fees_tao.tao, - "fees_alpha": lp.fees_alpha.tao, - "netuid": lp.netuid, - } - ) - if not json_output: - console.print(liquidity_table) - else: - json_console.print( - json.dumps({"success": True, "err_msg": "", "positions": json_table}) - ) - - -async def remove_liquidity( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: int, - proxy: Optional[str], - position_id: Optional[int] = None, - prompt: Optional[bool] = None, - decline: bool = False, - quiet: bool = False, - all_liquidity_ids: Optional[bool] = None, - json_output: bool = False, -) -> None: - """Remove liquidity position from provided subnet.""" - if not await subtensor.subnet_exists(netuid=netuid): - return None - - if all_liquidity_ids: - success, msg, positions = await get_liquidity_list(subtensor, wallet, netuid) - if not success: - if json_output: - json_console.print_json( - data={"success": False, "err_msg": msg, "positions": positions} - ) - else: - return print_error(f"Error: {msg}") - return None - else: - position_ids = [p.id for p in positions] - else: - position_ids = [position_id] - - if prompt: - console.print("You are about to remove LiquidityPositions with:") - console.print(f"\tSubnet: {netuid}") - console.print(f"\tWallet name: {wallet.name}") - for pos in position_ids: - console.print(f"\tPosition id: {pos}") - - if not confirm_action( - "Would you like to continue?", decline=decline, quiet=quiet - ): - return None - - # TODO does this never break because of the nonce? - results = await asyncio.gather( - *[ - remove_liquidity_extrinsic( - subtensor=subtensor, - wallet=wallet, - hotkey_ss58=hotkey_ss58, - proxy=proxy, - netuid=netuid, - position_id=pos_id, - ) - for pos_id in position_ids - ] - ) - if not json_output: - for (success, msg, ext_receipt), posid in zip(results, position_ids): - if success: - await print_extrinsic_id(ext_receipt) - console.print(f"[green] Position {posid} has been removed.") - else: - print_error(f"Error removing {posid}: {msg}") - else: - json_table = {} - for (success, msg, ext_receipt), posid in zip(results, position_ids): - json_table[posid] = { - "success": success, - "err_msg": msg, - "extrinsic_identifier": await ext_receipt.get_extrinsic_identifier(), - } - json_console.print_json(data=json_table) - return None - - -async def modify_liquidity( - subtensor: "SubtensorInterface", - wallet: "Wallet", - hotkey_ss58: str, - netuid: int, - proxy: Optional[str], - position_id: int, - liquidity_delta: Balance, - prompt: Optional[bool] = None, - decline: bool = False, - quiet: bool = False, - json_output: bool = False, -) -> bool: - """Modify liquidity position in provided subnet.""" - if not await subtensor.subnet_exists(netuid=netuid): - err_msg = f"Subnet with netuid: {netuid} does not exist in {subtensor}." - if json_output: - json_console.print(json.dumps({"success": False, "err_msg": err_msg})) - else: - print_error(err_msg) - return False - - if prompt: - console.print( - "You are about to modify a LiquidityPosition with:" - f"\tSubnet: {netuid}\n" - f"\tPosition id: {position_id}\n" - f"\tWallet name: {wallet.name}\n" - f"\tLiquidity delta: {liquidity_delta}" - ) - - if not confirm_action( - "Would you like to continue?", decline=decline, quiet=quiet - ): - return False - - success, msg, ext_receipt = await modify_liquidity_extrinsic( - subtensor=subtensor, - wallet=wallet, - hotkey_ss58=hotkey_ss58, - netuid=netuid, - proxy=proxy, - position_id=position_id, - liquidity_delta=liquidity_delta, - ) - if json_output: - ext_id = await ext_receipt.get_extrinsic_identifier() if success else None - json_console.print_json( - data={"success": success, "err_msg": msg, "extrinsic_identifier": ext_id} - ) - else: - if success: - await print_extrinsic_id(ext_receipt) - console.print(f"[green] Position {position_id} has been modified.") - else: - print_error(f"Error modifying {position_id}: {msg}") - return success diff --git a/bittensor_cli/src/commands/liquidity/utils.py b/bittensor_cli/src/commands/liquidity/utils.py deleted file mode 100644 index f364a64e4..000000000 --- a/bittensor_cli/src/commands/liquidity/utils.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -This module provides utilities for managing liquidity positions and price conversions in the Bittensor network. The -module handles conversions between TAO and Alpha tokens while maintaining precise calculations for liquidity -provisioning and fee distribution. -""" - -import math -from dataclasses import dataclass -from typing import Any - -from rich.prompt import IntPrompt, FloatPrompt - -from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float -from bittensor_cli.src.bittensor.utils import ( - console, -) - -# These three constants are unchangeable at the level of Uniswap math -MIN_TICK = -887272 -MAX_TICK = 887272 -PRICE_STEP = 1.0001 - - -@dataclass -class LiquidityPosition: - id: int - price_low: Balance # RAO - price_high: Balance # RAO - liquidity: Balance # TAO + ALPHA (sqrt by TAO balance * Alpha Balance -> math under the hood) - fees_tao: Balance # RAO - fees_alpha: Balance # RAO - netuid: int - - def to_token_amounts( - self, current_subnet_price: Balance - ) -> tuple[Balance, Balance]: - """Convert a position to token amounts. - - Arguments: - current_subnet_price: current subnet price in Alpha. - - Returns: - tuple[int, int]: - Amount of Alpha in liquidity - Amount of TAO in liquidity - - Liquidity is a combination of TAO and Alpha depending on the price of the subnet at the moment. - """ - sqrt_price_low = math.sqrt(self.price_low) - sqrt_price_high = math.sqrt(self.price_high) - sqrt_current_subnet_price = math.sqrt(current_subnet_price) - - if sqrt_current_subnet_price < sqrt_price_low: - amount_alpha = self.liquidity * (1 / sqrt_price_low - 1 / sqrt_price_high) - amount_tao = 0 - elif sqrt_current_subnet_price > sqrt_price_high: - amount_alpha = 0 - amount_tao = self.liquidity * (sqrt_price_high - sqrt_price_low) - else: - amount_alpha = self.liquidity * ( - 1 / sqrt_current_subnet_price - 1 / sqrt_price_high - ) - amount_tao = self.liquidity * (sqrt_current_subnet_price - sqrt_price_low) - return Balance.from_rao(int(amount_alpha)).set_unit( - self.netuid - ), Balance.from_rao(int(amount_tao)) - - -def price_to_tick(price: float) -> int: - """Converts a float price to the nearest Uniswap V3 tick index.""" - if price <= 0: - raise ValueError(f"Price must be positive, got `{price}`.") - - tick = int(math.log(price) / math.log(PRICE_STEP)) - - if not (MIN_TICK <= tick <= MAX_TICK): - raise ValueError( - f"Resulting tick {tick} is out of allowed range ({MIN_TICK} to {MAX_TICK})" - ) - return tick - - -def tick_to_price(tick: int) -> float: - """Convert an integer Uniswap V3 tick index to float price.""" - if not (MIN_TICK <= tick <= MAX_TICK): - raise ValueError("Tick is out of allowed range") - return PRICE_STEP**tick - - -def get_fees( - current_tick: int, - tick: dict, - tick_index: int, - quote: bool, - global_fees_tao: float, - global_fees_alpha: float, - above: bool, -) -> float: - """Returns the liquidity fee.""" - tick_fee_key = "fees_out_tao" if quote else "fees_out_alpha" - tick_fee_value = fixed_to_float(tick.get(tick_fee_key)) - global_fee_value = global_fees_tao if quote else global_fees_alpha - - if above: - return ( - global_fee_value - tick_fee_value - if tick_index <= current_tick - else tick_fee_value - ) - return ( - tick_fee_value - if tick_index <= current_tick - else global_fee_value - tick_fee_value - ) - - -def get_fees_in_range( - quote: bool, - global_fees_tao: float, - global_fees_alpha: float, - fees_below_low: float, - fees_above_high: float, -) -> float: - """Returns the liquidity fee value in a range.""" - global_fees = global_fees_tao if quote else global_fees_alpha - return global_fees - fees_below_low - fees_above_high - - -# Calculate fees for a position -def calculate_fees( - position: dict[str, Any], - global_fees_tao: float, - global_fees_alpha: float, - tao_fees_below_low: float, - tao_fees_above_high: float, - alpha_fees_below_low: float, - alpha_fees_above_high: float, - netuid: int, -) -> tuple[Balance, Balance]: - fee_tao_agg = get_fees_in_range( - quote=True, - global_fees_tao=global_fees_tao, - global_fees_alpha=global_fees_alpha, - fees_below_low=tao_fees_below_low, - fees_above_high=tao_fees_above_high, - ) - - fee_alpha_agg = get_fees_in_range( - quote=False, - global_fees_tao=global_fees_tao, - global_fees_alpha=global_fees_alpha, - fees_below_low=alpha_fees_below_low, - fees_above_high=alpha_fees_above_high, - ) - - fee_tao = fee_tao_agg - fixed_to_float(position["fees_tao"]) - fee_alpha = fee_alpha_agg - fixed_to_float(position["fees_alpha"]) - liquidity_frac = position["liquidity"] - - fee_tao = liquidity_frac * fee_tao - fee_alpha = liquidity_frac * fee_alpha - - return Balance.from_rao(int(fee_tao)), Balance.from_rao(int(fee_alpha)).set_unit( - netuid - ) - - -def prompt_liquidity(prompt: str, negative_allowed: bool = False) -> Balance: - """Prompt the user for the amount of liquidity. - - Arguments: - prompt: Prompt to display to the user. - negative_allowed: Whether negative amounts are allowed. - - Returns: - Balance converted from input to TAO. - """ - while True: - amount = FloatPrompt.ask(prompt) - try: - if amount <= 0 and not negative_allowed: - console.print("[red]Amount must be greater than 0[/red].") - continue - return Balance.from_tao(amount) - except ValueError: - console.print("[red]Please enter a valid number[/red].") - - -def prompt_position_id() -> int: - """Ask the user for the ID of the liquidity position to remove.""" - while True: - position_id = IntPrompt.ask("Enter the [blue]liquidity position ID[/blue]") - - try: - if position_id <= 1: - console.print("[red]Position ID must be greater than 1[/red].") - continue - return position_id - except ValueError: - console.print("[red]Please enter a valid number[/red].") - # will never return this, but fixes the type checker - return 0 diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index 11115cf39..31bd65fb8 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -42,15 +42,25 @@ async def get_childkey_completion_block( tempo_query = subtensor.get_hyperparameter( param_name="Tempo", netuid=netuid, block_hash=bh ) - block_number, blocks_since_last_step, tempo = await asyncio.gather( + block_number, blocks_since_last_step, tempo, next_epoch = await asyncio.gather( subtensor.substrate.get_block_number(block_hash=bh), blocks_since_last_step_query, tempo_query, + subtensor.get_next_epoch_start_block(netuid, block_hash=bh), ) cooldown = block_number + 7200 - blocks_left_in_tempo = tempo - blocks_since_last_step - next_tempo = block_number + blocks_left_in_tempo - next_epoch_after_cooldown = (cooldown - next_tempo) % (tempo + 1) + cooldown + if next_epoch is not None and tempo: + # Dynamic-tempo scheduler: epochs fire at next_epoch, then every tempo blocks. + if next_epoch >= cooldown: + next_epoch_after_cooldown = next_epoch + else: + epochs_until_cooldown = (cooldown - next_epoch + tempo - 1) // tempo + next_epoch_after_cooldown = next_epoch + epochs_until_cooldown * tempo + else: + # Legacy modulo scheduler (chains without the dynamic-tempo runtime). + blocks_left_in_tempo = tempo - blocks_since_last_step + next_tempo = block_number + blocks_left_in_tempo + next_epoch_after_cooldown = (cooldown - next_tempo) % (tempo + 1) + cooldown return block_number, next_epoch_after_cooldown diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 65404e6da..b60aa1bc6 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -14,7 +14,7 @@ from rich.table import Column, Table from rich import box -from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src import COLOR_PALETTE as COLOR from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.locks import ( hotkey_aggregate_conviction, @@ -265,19 +265,17 @@ async def _find_event_attributes_in_extrinsic_receipt( sn_burn_cost = await burn_cost(subtensor) if sn_burn_cost > your_balance: print_error( - f"Your balance of: [{COLOR_PALETTE.POOLS.TAO}]{your_balance}[{COLOR_PALETTE.POOLS.TAO}]" + f"Your balance of: [{COLOR.POOLS.TAO}]{your_balance}[{COLOR.POOLS.TAO}]" f" is not enough to burn " - f"[{COLOR_PALETTE.POOLS.TAO}]{sn_burn_cost}[{COLOR_PALETTE.POOLS.TAO}] " + f"[{COLOR.POOLS.TAO}]{sn_burn_cost}[{COLOR.POOLS.TAO}] " f"to register a subnet." ) return False, None, None if prompt: - console.print( - f"Your balance is: [{COLOR_PALETTE['POOLS']['TAO']}]{your_balance}" - ) + console.print(f"Your balance is: [{COLOR.P.TAO}]{your_balance}") if not confirm_action( - f"Do you want to burn [{COLOR_PALETTE['POOLS']['TAO']}]{sn_burn_cost} to register a subnet?", + f"Do you want to burn [{COLOR.P.TAO}]{sn_burn_cost}[/{COLOR.P.TAO}] to register a subnet?", decline=decline, quiet=quiet, ): @@ -456,8 +454,8 @@ def define_table( total_tao_flow_ema: float, ): defined_table = create_table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnets" - f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}\n\n", + title=f"\n[{COLOR.G.HEADER}]Subnets[/{COLOR.G.HEADER}]" + f"\nNetwork: [{COLOR.G.SUBHEADING}]{subtensor.network}\n\n", ) defined_table.add_column( @@ -480,42 +478,42 @@ def define_table( ) defined_table.add_column( f"[bold white]Emission ({Balance.get_unit(0)})", - style=COLOR_PALETTE["POOLS"]["EMISSION"], + style=COLOR.P.EMISSION, justify="left", footer=f"τ {total_emissions}", ) defined_table.add_column( f"[bold white]Net Inflow EMA ({Balance.get_unit(0)})", - style=COLOR_PALETTE["POOLS"]["ALPHA_OUT"], + style=COLOR.P.ALPHA_OUT, justify="left", footer=f"τ {total_tao_flow_ema}", ) defined_table.add_column( f"[bold white]P ({Balance.get_unit(0)}_in, {Balance.get_unit(1)}_in)", - style=COLOR_PALETTE["STAKE"]["TAO"], + style=COLOR.S.TAO, justify="left", footer=f"{tao_emission_percentage}", ) defined_table.add_column( f"[bold white]Stake ({Balance.get_unit(1)}_out)", - style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + style=COLOR.S.ALPHA, justify="left", ) defined_table.add_column( f"[bold white]Supply ({Balance.get_unit(1)})", - style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], + style=COLOR.P.ALPHA_IN, justify="left", ) defined_table.add_column( "[bold white]Tempo (k/n)", - style=COLOR_PALETTE["GENERAL"]["TEMPO"], + style=COLOR.G.TEMPO, justify="left", overflow="fold", ) defined_table.add_column( "[bold white]Mechanisms", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING_EXTRA_1"], + style=COLOR.G.SUBHEAD_EX_1, justify="center", ) return defined_table @@ -583,7 +581,7 @@ def _create_table(subnets_, block_number_, mechanisms, ema_tao_inflow): # Prepare cells netuid_cell = str(netuid) subnet_name_cell = ( - f"[{COLOR_PALETTE.G.SYM}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE.G.SYM}]" + f"[{COLOR.G.SYM}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR.G.SYM}]" f" {get_subnet_name(subnet)}" ) emission_cell = f"τ {emission_tao:,.4f}" @@ -857,7 +855,7 @@ def format_liquidity_cell( netuid_cell = str(netuid) subnet_name_cell = ( - f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f"[{COLOR.G.SYM}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR.G.SYM}]" f" {get_subnet_name(subnet)}" ) emission_cell = format_cell( @@ -934,9 +932,10 @@ def format_liquidity_cell( subnet.blocks_since_last_step - prev_blocks_since_last_step ) else: - # Tempo restarted + # Tempo restarted; the epoch period is exactly tempo blocks + # under the dynamic-tempo scheduler. block_change = ( - subnet.blocks_since_last_step + subnet.tempo + 1 + subnet.blocks_since_last_step + subnet.tempo ) - prev_blocks_since_last_step if block_change > 0: block_change_text = f" [pale_green3](+{block_change})[/pale_green3]" @@ -947,7 +946,7 @@ def format_liquidity_cell( else: block_change_text = "" tempo_cell = ( - (f"{subnet.blocks_since_last_step}/{subnet.tempo}{block_change_text}") + f"{subnet.blocks_since_last_step}/{subnet.tempo}{block_change_text}" if netuid != 0 else "-/-" ) @@ -1083,9 +1082,7 @@ def format_liquidity_cell( ).lower() if display_table == "q": - console.print( - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]Column descriptions skipped." - ) + console.print(f"[{COLOR.G.SUBHEAD_EX_1}]Column descriptions skipped.") else: header = """ [bold white]Description[/bold white]: The table displays information about each subnet. The columns are as follows: @@ -1177,41 +1174,41 @@ async def show_root(): tao_sum = sum(root_state.tao_stake).tao table = create_table( - title=f"[{COLOR_PALETTE.G.HEADER}]Root Network\n[{COLOR_PALETTE.G.SUBHEAD}]" - f"Network: {subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n", + title=f"[{COLOR.G.HEADER}]Root Network\n[{COLOR.G.SUBHEAD}]" + f"Network: {subtensor.network}[/{COLOR.G.SUBHEAD}]\n", ) table.add_column("[bold white]Position", style="white", justify="center") table.add_column( "Tao (τ)", - style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + style=COLOR.P.EXTRA_2, no_wrap=True, justify="right", footer=f"{tao_sum:.4f} τ" if verbose else f"{millify_tao(tao_sum)} τ", ) table.add_column( f"[bold white]Emission ({Balance.get_unit(0)}/block)", - style=COLOR_PALETTE["POOLS"]["EMISSION"], + style=COLOR.P.EMISSION, justify="center", ) table.add_column( "[bold white]Hotkey", - style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + style=COLOR.G.HK, justify="center", ) table.add_column( "[bold white]Coldkey", - style=COLOR_PALETTE["GENERAL"]["COLDKEY"], + style=COLOR.G.CK, justify="center", ) table.add_column( "[bold white]Identity", - style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + style=COLOR.G.SYM, justify="left", ) table.add_column( "[bold white]Claim Type", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], + style=COLOR.G.SUBHEADING, justify="center", ) @@ -1298,12 +1295,12 @@ async def show_root(): else f"{root_info.price.tao:,.4f}" ) console.print( - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Root Network (Subnet 0)[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" - f"\n Rate: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{rate} τ/τ[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"\n Emission: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]τ 0[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"\n TAO Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]τ {tao_pool}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" - f"\n Stake: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]τ {stake}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" - f"\n Tempo: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]{root_info.blocks_since_last_step}/{root_info.tempo}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + f"[{COLOR.G.SUBHEADING}]Root Network (Subnet 0)[/{COLOR.G.SUBHEADING}]" + f"\n Rate: [{COLOR.G.HK}]{rate} τ/τ[/{COLOR.G.HK}]" + f"\n Emission: [{COLOR.G.HK}]τ 0[/{COLOR.G.HK}]" + f"\n TAO Pool: [{COLOR.P.ALPHA_IN}]τ {tao_pool}[/{COLOR.P.ALPHA_IN}]" + f"\n Stake: [{COLOR.S.ALPHA}]τ {stake}[/{COLOR.S.ALPHA}]" + f"\n Tempo: [{COLOR.S.ALPHA}]{root_info.blocks_since_last_step}/{root_info.tempo}[/{COLOR.S.ALPHA}]" ) console.print( """ @@ -1346,7 +1343,7 @@ async def show_root(): identity_str = f" ({validator_identity})" if validator_identity else "" console.print( - f"\nSelected delegate: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{selected_hotkey}{identity_str}" + f"\nSelected delegate: [{COLOR.G.SUBHEADING}]{selected_hotkey}{identity_str}" ) return selected_hotkey @@ -1412,9 +1409,9 @@ async def show_subnet( mechanism_label = f"Mechanism {selected_mechanism_id}" table = create_table( - title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnet [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid_}" + title=f"[{COLOR.G.HEADER}]Subnet [{COLOR.G.SUBHEADING}]{netuid_}" f"{': ' + get_subnet_name(subnet_info)}" - f"\n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Network: {subtensor.network} • {mechanism_label}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + f"\n[{COLOR.G.SUBHEADING}]Network: {subtensor.network} • {mechanism_label}[/{COLOR.G.SUBHEADING}]\n", ) # For table footers @@ -1460,6 +1457,11 @@ async def show_subnet( ), ) + # Append a LEFT-TO-RIGHT MARK to the symbol so RTL symbols + # don't get BiDi-reordered by terminals like + # Terminal.app, which misaligns the table columns. + symbol = f"{subnet_info.symbol}‎" + rows = [] json_out_rows = [] for idx in sorted_indices: @@ -1500,25 +1502,25 @@ async def show_subnet( conv_cell = _format_conviction_cell( conviction=conv_value, netuid=netuid_, - symbol=subnet_info.symbol, + symbol=symbol, verbose=verbose, ) rows.append( ( str(idx), # UID - f"{metagraph_info.total_stake[idx].tao:.4f} {subnet_info.symbol}" + f"{metagraph_info.total_stake[idx].tao:.4f} {symbol}" if verbose - else f"{millify_tao(metagraph_info.total_stake[idx])} {subnet_info.symbol}", # Stake - f"{metagraph_info.alpha_stake[idx].tao:.4f} {subnet_info.symbol}" + else f"{millify_tao(metagraph_info.total_stake[idx])} {symbol}", # Stake + f"{metagraph_info.alpha_stake[idx].tao:.4f} {symbol}" if verbose - else f"{millify_tao(metagraph_info.alpha_stake[idx])} {subnet_info.symbol}", # Alpha Stake + else f"{millify_tao(metagraph_info.alpha_stake[idx])} {symbol}", # Alpha Stake f"τ {tao_stake.tao:.4f}" if verbose else f"τ {millify_tao(tao_stake)}", # Tao Stake f"{metagraph_info.dividends[idx]:.6f}", # Dividends f"{metagraph_info.incentives[idx]:.6f}", # Incentive - f"{Balance.from_tao(metagraph_info.emission[idx].tao).set_unit(netuid_).tao:.6f} {subnet_info.symbol}", # Emissions + f"{Balance.from_tao(metagraph_info.emission[idx].tao).set_unit(netuid_).tao:.6f} {symbol}", # Emissions conv_cell, # Conv. (α-eq) f"{metagraph_info.hotkeys[idx][:6]}" if not verbose @@ -1565,74 +1567,74 @@ async def show_subnet( # Add columns to the table table.add_column("UID", style="grey89", no_wrap=True, justify="center") table.add_column( - f"Stake ({subnet_info.symbol})", - style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], + f"Stake ({symbol})", + style=COLOR.P.ALPHA_IN, no_wrap=True, justify="right", - footer=f"{stake_sum:.4f} {subnet_info.symbol}" + footer=f"{stake_sum:.4f} {symbol}" if verbose - else f"{millify_tao(stake_sum)} {subnet_info.symbol}", + else f"{millify_tao(stake_sum)} {symbol}", ) table.add_column( - f"Alpha ({subnet_info.symbol})", - style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + f"Alpha ({symbol})", + style=COLOR.P.EXTRA_2, no_wrap=True, justify="right", - footer=f"{alpha_sum:.4f} {subnet_info.symbol}" + footer=f"{alpha_sum:.4f} {symbol}" if verbose - else f"{millify_tao(alpha_sum)} {subnet_info.symbol}", + else f"{millify_tao(alpha_sum)} {symbol}", ) table.add_column( "Tao (τ)", - style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + style=COLOR.P.EXTRA_2, no_wrap=True, justify="right", - footer=f"{tao_sum:.4f} {subnet_info.symbol}" + footer=f"{tao_sum:.4f} {symbol}" if verbose - else f"{millify_tao(tao_sum)} {subnet_info.symbol}", + else f"{millify_tao(tao_sum)} {symbol}", ) table.add_column( "Dividends", - style=COLOR_PALETTE["POOLS"]["EMISSION"], + style=COLOR.P.EMISSION, no_wrap=True, justify="center", footer=f"{dividends_sum:.3f}", ) table.add_column("Incentive", style="#5fd7ff", no_wrap=True, justify="center") table.add_column( - f"Emissions ({subnet_info.symbol})", - style=COLOR_PALETTE["POOLS"]["EMISSION"], + f"Emissions ({symbol})", + style=COLOR.P.EMISSION, no_wrap=True, justify="center", - footer=f"{emission_sum:.4f} {subnet_info.symbol}", + footer=f"{emission_sum:.4f} {symbol}", ) table.add_column( - f"Conviction ({subnet_info.symbol}-eq)", - style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + f"Conviction ({symbol}-eq)", + style=COLOR.P.EXTRA_2, no_wrap=True, justify="center", ) table.add_column( "Hotkey", - style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + style=COLOR.G.HK, no_wrap=True, justify="center", ) table.add_column( "Coldkey", - style=COLOR_PALETTE["GENERAL"]["COLDKEY"], + style=COLOR.G.CK, no_wrap=True, justify="center", ) table.add_column( "Identity", - style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + style=COLOR.G.SYM, no_wrap=True, justify="left", ) table.add_column( "Claim Type", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], + style=COLOR.G.SUBHEADING, no_wrap=True, justify="center", ) @@ -1668,9 +1670,9 @@ async def show_subnet( total_mechanisms = mechanism_count if mechanism_count is not None else 1 _total_locked = Balance.from_rao(total_locked_rao).set_unit(netuid_) total_locked = ( - f"{millify_tao(_total_locked.tao)} {subnet_info.symbol}" + f"{millify_tao(_total_locked.tao)} {symbol}" if not verbose - else f"{_total_locked.tao:.4f} {subnet_info.symbol}" + else f"{_total_locked.tao:.4f} {symbol}" ) output_dict = { @@ -1699,30 +1701,30 @@ async def show_subnet( json_console.print(json.dumps(output_dict)) mech_line = ( - f"\n Mechanism ID: [{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]#{selected_mechanism_id}" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]" + f"\n Mechanism ID: [{COLOR.G.SUBHEAD_EX_1}]#{selected_mechanism_id}" + f"[/{COLOR.G.SUBHEAD_EX_1}]" if total_mechanisms > 1 else "" ) total_mech_line = ( - f"\n Total mechanisms: [{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_2']}]" - f"{total_mechanisms}[/{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_2']}]" + f"\n Total mechanisms: [{COLOR.G.SUBHEAD_EX_2}]" + f"{total_mechanisms}[/{COLOR.G.SUBHEAD_EX_2}]" ) console.print( - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Subnet {netuid_}{subnet_name_display}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"[{COLOR.G.SUBHEADING}]Subnet {netuid_}{subnet_name_display}[/{COLOR.G.SUBHEADING}]" f"{mech_line}" f"{total_mech_line}" - f"\n Owner: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{subnet_info.owner_coldkey}{' (' + owner_identity + ')' if owner_identity else ''}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]" - f"\n Rate: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{subnet_info.price.tao:.4f} τ/{subnet_info.symbol}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"\n EMA TAO Inflow: [{COLOR_PALETTE['STAKE']['TAO']}]τ {ema_tao_inflow.tao:.4f}[/{COLOR_PALETTE['STAKE']['TAO']}]" - f"\n Emission: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]τ {subnet_info.tao_in_emission.tao:,.4f}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"\n TAO Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]τ {tao_pool}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" - f"\n Alpha Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]{alpha_pool} {subnet_info.symbol}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" - f"\n Total locked: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}] {total_locked} [/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" - # f"\n Stake: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]{subnet_info.alpha_out.tao:,.5f} {subnet_info.symbol}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" - f"\n Tempo: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]{subnet_info.blocks_since_last_step}/{subnet_info.tempo}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" - f"\n Registration cost (recycled): [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]τ {current_registration_burn.tao:.4f}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + f"\n Owner: [{COLOR.G.CK}]{subnet_info.owner_coldkey}{' (' + owner_identity + ')' if owner_identity else ''}[/{COLOR.G.CK}]" + f"\n Rate: [{COLOR.G.HK}]{subnet_info.price.tao:.4f} τ/{symbol}[/{COLOR.G.HK}]" + f"\n EMA TAO Inflow: [{COLOR.S.TAO}]τ {ema_tao_inflow.tao:.4f}[/{COLOR.S.TAO}]" + f"\n Emission: [{COLOR.G.HK}]τ {subnet_info.tao_in_emission.tao:,.4f}[/{COLOR.G.HK}]" + f"\n TAO Pool: [{COLOR.P.ALPHA_IN}]τ {tao_pool}[/{COLOR.P.ALPHA_IN}]" + f"\n Alpha Pool: [{COLOR.P.ALPHA_IN}]{alpha_pool} {symbol}[/{COLOR.P.ALPHA_IN}]" + f"\n Total locked: [{COLOR.P.ALPHA_IN}] {total_locked} [/{COLOR.P.ALPHA_IN}]" + # f"\n Stake: [{COLOR.S.ALPHA}]{subnet_info.alpha_out.tao:,.5f} {subnet_info.symbol}[/{COLOR.S.ALPHA}]" + f"\n Tempo: [{COLOR.S.ALPHA}]{subnet_info.blocks_since_last_step}/{subnet_info.tempo}[/{COLOR.S.ALPHA}]" + f"\n Registration cost (recycled): [{COLOR.S.ALPHA}]τ {current_registration_burn.tao:.4f}[/{COLOR.S.ALPHA}]" ) # console.print( # """ @@ -1764,7 +1766,7 @@ async def show_subnet( identity = "" if row_data[9] == "~" else row_data[9] identity_str = f" ({identity})" if identity else "" console.print( - f"\nSelected delegate: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{hotkey}{identity_str}" + f"\nSelected delegate: [{COLOR.G.SUBHEADING}]{hotkey}{identity_str}" ) return hotkey else: @@ -1800,7 +1802,7 @@ async def burn_cost( ) else: console.print( - f"Subnet burn cost: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_burn_cost}" + f"Subnet burn cost: [{COLOR.S.AMOUNT}]{current_burn_cost}" ) return current_burn_cost else: @@ -1970,9 +1972,9 @@ async def _storage_key(storage_fn: str) -> StorageKey: # Show creation table. table = create_table( title=( - f"\n[{COLOR_PALETTE.G.HEADER}]" - f"Register to [{COLOR_PALETTE.G.SUBHEAD}]netuid: {netuid}[/{COLOR_PALETTE.G.SUBHEAD}]" - f"\nNetwork: [{COLOR_PALETTE.G.SUBHEAD}]{subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n" + f"\n[{COLOR.G.HEADER}]" + f"Register to [{COLOR.G.SUBHEAD}]netuid: {netuid}[/{COLOR.G.SUBHEAD}]" + f"\nNetwork: [{COLOR.G.SUBHEAD}]{subtensor.network}[/{COLOR.G.SUBHEAD}]\n" ), ) table.add_column( @@ -1980,32 +1982,32 @@ async def _storage_key(storage_fn: str) -> StorageKey: ) table.add_column( "Symbol", - style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + style=COLOR.G.SYM, no_wrap=True, justify="center", ) table.add_column( f"Cost ({Balance.get_unit(0)})", - style=COLOR_PALETTE["POOLS"]["TAO"], + style=COLOR["POOLS"]["TAO"], no_wrap=True, justify="center", ) if with_limit is not None and limit is not None: table.add_column( f"Limit Cost (+{limit * 100:g}%)", - style=COLOR_PALETTE["POOLS"]["TAO"], + style=COLOR["POOLS"]["TAO"], no_wrap=True, justify="center", ) table.add_column( "Hotkey", - style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + style=COLOR.G.HK, no_wrap=True, justify="center", ) table.add_column( "Coldkey", - style=COLOR_PALETTE["GENERAL"]["COLDKEY"], + style=COLOR.G.CK, no_wrap=True, justify="center", ) @@ -2026,9 +2028,9 @@ async def _storage_key(storage_fn: str) -> StorageKey: console.print(table) if not ( confirm_action( - f"Your balance is: [{COLOR_PALETTE.G.BAL}]{balance}[/{COLOR_PALETTE.G.BAL}]\n" + f"Your balance is: [{COLOR.G.BAL}]{balance}[/{COLOR.G.BAL}]\n" f"The cost to register by recycle is " - f"[{COLOR_PALETTE.G.COST}]{current_recycle}[/{COLOR_PALETTE.G.COST}].\n" + f"[{COLOR.G.COST}]{current_recycle}[/{COLOR.G.COST}].\n" f"Do you want to continue?", default=False, decline=decline, @@ -2597,11 +2599,11 @@ def create_identity_table(title: str = None): Column( "Item", justify="right", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING_MAIN"], + style=COLOR["GENERAL"]["SUBHEADING_MAIN"], no_wrap=True, ), - Column("Value", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]), - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]{title}\n", + Column("Value", style=COLOR.G.SUBHEADING), + title=f"\n[{COLOR.G.HEADER}]{title}\n", ) return table @@ -3118,38 +3120,38 @@ async def subnet_conviction( "—" if king is None else king if verbose else f"{king[:6]}...{king[-6:]}" ) table = create_table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnet Conviction" - f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}" + title=f"\n[{COLOR.G.HEADER}]Subnet Conviction" + f"\nNetwork: [{COLOR.G.SUBHEADING}]{subtensor.network}" f" • Block: {current_block:,}" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n\n", + f"[/{COLOR.G.SUBHEADING}]\n\n", show_footer=False, ) table.add_column("[bold white]Rank", style="grey89", justify="center") table.add_column( "[bold white]Conviction", - style=COLOR_PALETTE["POOLS"]["EXTRA_2"], + style=COLOR.P.EXTRA_2, justify="center", ) table.add_column("[bold white]Share", justify="center") table.add_column( f"[bold white]Locked ({subnet_info.symbol})\n[white]Perpetual | Decay[/white]", - style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], + style=COLOR.P.ALPHA_IN, justify="center", no_wrap=True, ) table.add_column( "[bold white]Hotkey", - style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + style=COLOR.G.HK, justify="center", ) table.add_column( "[bold white]Coldkey", - style=COLOR_PALETTE["GENERAL"]["COLDKEY"], + style=COLOR.G.CK, justify="center", ) table.add_column( "[bold white]Identity", - style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], + style=COLOR.G.SUBHEADING, justify="center", ) table.add_column("[bold white]Role", justify="center") @@ -3204,9 +3206,9 @@ async def subnet_conviction( identity_str = f" ({identity})" if identity else "" selected_hotkey = selected_record["hotkey"] console.print( - f"\nSelected hotkey: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"\nSelected hotkey: [{COLOR.G.SUBHEADING}]" f"{selected_hotkey}{identity_str}" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"[/{COLOR.G.SUBHEADING}]" ) return selected_hotkey @@ -3215,20 +3217,20 @@ async def subnet_conviction( console.print() console.print( - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Subnet {netuid}: " - f"{get_subnet_name(subnet_info)}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" - f"\n Total conviction: [{COLOR_PALETTE['POOLS']['EXTRA_2']}]" + f"[{COLOR.G.SUBHEADING}]Subnet {netuid}: " + f"{get_subnet_name(subnet_info)}[/{COLOR.G.SUBHEADING}]" + f"\n Total conviction: [{COLOR.P.EXTRA_2}]" f"{_format_conviction_cell(total_conviction, netuid, symbol, verbose)}" - f"[/{COLOR_PALETTE['POOLS']['EXTRA_2']}]" - f"\n 10% threshold: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" + f"[/{COLOR.P.EXTRA_2}]" + f"\n 10% threshold: [{COLOR.P.ALPHA_IN}]" f"{_format_conviction_cell(Decimal(threshold_alpha_eq), netuid, symbol, verbose)}" - f"[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" - f"\n Threshold used: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"{pct_of_threshold:.0f}%[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"\n Subnet age: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" - f"{blocks_to_duration(age_blocks)}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" - f"\n Top hotkey: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"{king_cell}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" + f"[/{COLOR.P.ALPHA_IN}]" + f"\n Threshold used: [{COLOR.G.HK}]" + f"{pct_of_threshold:.0f}%[/{COLOR.G.HK}]" + f"\n Subnet age: [{COLOR.S.ALPHA}]" + f"{blocks_to_duration(age_blocks)}[/{COLOR.S.ALPHA}]" + f"\n Top hotkey: [{COLOR.G.HK}]" + f"{king_cell}[/{COLOR.G.HK}]" ) unlock_half_life = blocks_to_duration(int(unlock_rate * math.log(2))) maturity_half_life = blocks_to_duration(int(maturity_rate * math.log(2))) diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 24bb01ca4..c7e5adb16 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -14,6 +14,7 @@ HYPERPARAMS, HYPERPARAMS_MODULE, HYPERPARAMS_METADATA, + HYPERPARAMS_ROOT_EXTRINSIC, RootSudoOnly, COLOR_PALETTE, ) @@ -67,6 +68,15 @@ def allowed_value( """ try: if not isinstance(value, bool): + if param == "activity_cutoff_factor": + # Bounds mirror MIN/MAX_ACTIVITY_CUTOFF_FACTOR_MILLI in subtensor. + factor = int(value) + if not 1_000 <= factor <= 50_000: + return ( + False, + "between 1000 and 50000 (per-mille units; 1000 = one full tempo)", + ) + return True, factor if param == "alpha_values": # Split the string into individual values alpha_low_str, alpha_high_str = value.split(",") @@ -380,6 +390,7 @@ def type_converter_with_retry(type_, val, arg_name): "immunity_period": int, "commit_reveal_period": int, "adjustment_interval": int, + "factor_milli": int, "max_validators": int, "min_allowed_weights": int, "rho": int, @@ -395,6 +406,7 @@ def type_converter_with_retry(type_, val, arg_name): "bool": string_to_bool, "u16": string_to_u16, "i16": string_to_i16, + "u32": int, "u64": string_to_u64, "MechId": int, "U64F64": string_to_u64f64, @@ -404,6 +416,7 @@ def type_converter_with_retry(type_, val, arg_name): "bool": "bool", "u16": "float", "i16": "float (signed)", + "u32": "int", "u64": "float", "U64F64": "decimal", "TaoBalance": "Tao (float)", @@ -598,13 +611,17 @@ async def set_hyperparameter_extrinsic( arbitrary_extrinsic = False extrinsic, sudo_ = HYPERPARAMS.get(parameter, ("", RootSudoOnly.FALSE)) + # Resolve pallet and root-path override before `parameter` is potentially + # reassigned to the extrinsic name in normalize mode. + pallet = HYPERPARAMS_MODULE.get(parameter) or DEFAULT_PALLET + root_override = HYPERPARAMS_ROOT_EXTRINSIC.get(parameter) call_params = {"netuid": netuid} if normalize and parameter != "alpha_values": parameter = extrinsic extrinsic = None if not extrinsic: arbitrary_extrinsic, call_params = search_metadata( - parameter, value, netuid, subtensor.substrate.metadata + parameter, value, netuid, subtensor.substrate.metadata, pallet_name=pallet ) extrinsic = parameter if not arbitrary_extrinsic: @@ -624,7 +641,6 @@ async def set_hyperparameter_extrinsic( substrate = subtensor.substrate msg_value = value if not arbitrary_extrinsic else call_params - pallet = HYPERPARAMS_MODULE.get(parameter) or DEFAULT_PALLET if not arbitrary_extrinsic: extrinsic_params = await substrate.get_metadata_call_function( @@ -665,13 +681,28 @@ async def set_hyperparameter_extrinsic( call_params=call_params, block_hash=block_hash, ) - if sudo_ is RootSudoOnly.TRUE: - call = await substrate.compose_call( + + async def sudo_wrapped() -> GenericCall: + # Root-sudo path: some hyperparams use a dedicated root extrinsic (e.g. + # AdminUtils.sudo_set_tempo) instead of Sudo-wrapping the owner call. + inner_call = call_ + if root_override: + root_pallet, root_extrinsic = root_override + inner_call = await substrate.compose_call( + call_module=root_pallet, + call_function=root_extrinsic, + call_params=call_params, + block_hash=block_hash, + ) + return await substrate.compose_call( call_module="Sudo", call_function="sudo", - call_params={"call": call_}, + call_params={"call": inner_call}, block_hash=block_hash, ) + + if sudo_ is RootSudoOnly.TRUE: + call = await sudo_wrapped() elif sudo_ is RootSudoOnly.COMPLICATED: if not prompt: # In no-prompt mode, owners should take the owner path; non-owners @@ -684,12 +715,7 @@ async def set_hyperparameter_extrinsic( quiet=quiet, ) if to_sudo_or_not_to_sudo: - call = await substrate.compose_call( - call_module="Sudo", - call_function="sudo", - call_params={"call": call_}, - block_hash=block_hash, - ) + call = await sudo_wrapped() else: if subnet_owner != coldkey_ss58: err_msg = "This wallet doesn't own the specified subnet." @@ -1600,3 +1626,102 @@ async def trim( await print_extrinsic_id(ext_receipt) print_success(f"[dark_sea_green3]{msg}[/dark_sea_green3]") return True + + +async def trigger_epoch( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + proxy: Optional[str], + period: int, + prompt: bool, + decline: bool, + quiet: bool, + json_output: bool, +) -> bool: + """ + Manually triggers an epoch for a subnet owned by the wallet. + + The epoch is scheduled to fire after the chain's admin freeze window, during + which admin operations on the subnet are locked. Rate-limited on-chain, and + rejected if a trigger is already pending, the next automatic epoch is + imminent, or commit-reveal is enabled on the subnet. + """ + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address + print_verbose("Confirming subnet owner") + block_hash = await subtensor.substrate.get_chain_head() + subnet_owner = await subtensor.query( + module="SubtensorModule", + storage_function="SubnetOwner", + params=[netuid], + block_hash=block_hash, + ) + if subnet_owner != coldkey_ss58: + err_msg = "This wallet doesn't own the specified subnet." + if json_output: + json_console.print_json(data={"success": False, "message": err_msg}) + else: + print_error(err_msg) + return False + if prompt and not json_output: + if not confirm_action( + f"You are about to manually trigger an epoch on SN{netuid}. " + f"This locks admin operations on the subnet until the epoch fires.", + default=False, + decline=decline, + quiet=quiet, + ): + print_error("User aborted.") + return False + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="trigger_epoch", + call_params={"netuid": netuid}, + block_hash=block_hash, + ) + with console.status( + f":satellite: Triggering epoch on subnet " + f"[{COLOR_PALETTE.G.SUBHEAD}]{netuid}[/{COLOR_PALETTE.G.SUBHEAD}] ...", + spinner="earth", + ): + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call=call, wallet=wallet, era={"period": period}, proxy=proxy + ) + if not success: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": err_msg, + "extrinsic_identifier": None, + } + ) + else: + print_error(err_msg) + return False + else: + ext_id = await ext_receipt.get_extrinsic_identifier() + fires_at = None + try: + for event in await ext_receipt.triggered_events: + if event["event_id"] == "EpochTriggered": + fires_at = event["attributes"]["fires_at"] + except KeyError: + # The trigger still succeeded; the fires_at block is just a nice-to-have. + pass + msg = f"Epoch triggered on SN{netuid}" + if fires_at is not None: + msg += f"; it will fire at block {fires_at} at the earliest" + if json_output: + json_console.print_json( + data={ + "success": True, + "message": msg, + "fires_at": fires_at, + "extrinsic_identifier": ext_id, + } + ) + else: + await print_extrinsic_id(ext_receipt) + print_success(f"[dark_sea_green3]{msg}[/dark_sea_green3]") + return True diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index b2bdcc8fc..a687873cb 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -2,6 +2,7 @@ import hashlib import json import os +import re from collections import defaultdict from enum import Enum from typing import Generator, Optional, Union @@ -891,8 +892,26 @@ async def wallet_history(wallet: Wallet): console.print(table) +def _natural_sort_key(name: str) -> list[Union[str, int]]: + """Sort names with numeric chunks ordered naturally (item2 before item10).""" + return [ + int(part) if part.isdigit() else part.casefold() + for part in re.split(r"(\d+)", name) + ] + + +def _hotkey_sort_key(hkey: Optional[Wallet]) -> tuple[bool, list[Union[str, int]]]: + if hkey is None: + return True, [] + name = hkey.hotkey_str or hkey.name + return False, _natural_sort_key(name) + + async def wallet_list( - wallet_path: str, json_output: bool, wallet_name: Optional[str] = None + wallet_path: str, + json_output: bool, + wallet_name: Optional[str] = None, + coldkeys_only: bool = False, ): """Lists wallets.""" wallets = utils.get_coldkey_wallets_for_path(wallet_path) @@ -905,6 +924,8 @@ async def wallet_list( if not wallets: print_error(f"Wallet '{wallet_name}' not found in dir: {wallet_path}") + wallets = sorted(wallets, key=lambda wallet: _natural_sort_key(wallet.name)) + root = Tree("Wallets") main_data_dict = {"wallets": []} for wallet in wallets: @@ -933,8 +954,13 @@ async def wallet_list( "hotkeys": wallet_hotkeys, } main_data_dict["wallets"].append(wallet_dict) - hotkeys = utils.get_hotkey_wallets_for_wallet( - wallet, show_nulls=True, show_encrypted=True + if coldkeys_only: + continue + hotkeys = sorted( + utils.get_hotkey_wallets_for_wallet( + wallet, show_nulls=True, show_encrypted=True + ), + key=_hotkey_sort_key, ) for hkey in hotkeys: data = f"[bold red]Hotkey[/bold red][green] {hkey}[/green] (?)" @@ -1213,7 +1239,9 @@ async def overview( validator_trust = nn.validator_trust incentive = nn.incentive dividends = nn.dividends - emission = int(nn.emission / (subnet_tempo + 1) * 1e9) # Per block + # Per block: the epoch period is exactly tempo blocks under the + # dynamic-tempo scheduler (guard against tempo == 0). + emission = int(nn.emission / max(subnet_tempo, 1) * 1e9) last_update = int(block - nn.last_update) validator_permit = nn.validator_permit row = [ diff --git a/bittensor_cli/src/commands/weights.py b/bittensor_cli/src/commands/weights.py index cc493c131..27e1b8acf 100644 --- a/bittensor_cli/src/commands/weights.py +++ b/bittensor_cli/src/commands/weights.py @@ -152,10 +152,38 @@ async def commit_weights( return success, message, ext_id + async def _seconds_until_reveal(self, reveal_period: int) -> int: + """ + Estimates the seconds until the reveal window opens. Commits made in epoch E + become revealable in epoch E + reveal_period, i.e. after the next epoch + boundary plus (reveal_period - 1) full tempos. Best-effort: owner tempo + changes, manually triggered epochs, or per-block epoch deferrals can shift + the actual boundary. + """ + bh = await self.subtensor.substrate.get_chain_head() + current_block, tempo, next_epoch = await asyncio.gather( + self.subtensor.substrate.get_block_number(block_hash=bh), + self.subtensor.get_hyperparameter( + param_name="Tempo", netuid=self.netuid, block_hash=bh + ), + self.subtensor.get_next_epoch_start_block(self.netuid, block_hash=bh), + ) + tempo = int(tempo) + if next_epoch is not None: + blocks_until_next_epoch = max(next_epoch - current_block, 0) + else: + # Legacy modulo scheduler (chains without the dynamic-tempo runtime). + remainder = (current_block + self.netuid + 1) % (tempo + 1) + blocks_until_next_epoch = (tempo + 1 - remainder) % (tempo + 1) + blocks_until_reveal = ( + blocks_until_next_epoch + max(reveal_period - 1, 0) * tempo + ) + return blocks_until_reveal * 12 + async def _commit_reveal( self, weight_uids: list[int], weight_vals: list[int] ) -> tuple[bool, str, Optional[str]]: - interval = int( + reveal_period = int( await self.subtensor.get_hyperparameter( param_name="get_commit_reveal_period", netuid=self.netuid, @@ -174,6 +202,7 @@ async def _commit_reveal( ) if commit_success: + interval = await self._seconds_until_reveal(reveal_period) current_time = datetime.now().astimezone().replace(microsecond=0) reveal_time = (current_time + timedelta(seconds=interval)).isoformat() cli_retry_cmd = f"--netuid {self.netuid} --uids {weight_uids} --weights {self.weights} --reveal-using-salt {self.salt}" diff --git a/contrib/CONTRIBUTING.MD b/contrib/CONTRIBUTING.MD index fe1d98098..707b45aab 100644 --- a/contrib/CONTRIBUTING.MD +++ b/contrib/CONTRIBUTING.MD @@ -1,18 +1,21 @@ # Contributing to Bittensor CLI + We want to make contributing to this project as easy and transparent as possible. -We have an official Discord server where the community chimes in with helpful advice if you have questions. +We have an official Discord server where the community chimes in with helpful advice if you have questions. This is the fastest way to get an answer and the core development team is active on Discord. * [Official Bittensor Discord](https://discord.gg/7wvFuPJZgq) * [Bittensor Developers Group (Church of Rao)](https://discord.gg/4AMrc7B4) ## Issues + We use GitHub issues to track bugs and improvements. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue. When submitting a bug report, please include the following information: + 1. Python version 2. Network or Chain which was being used to interact with Subtensor 3. Steps to reproduce the issue @@ -20,6 +23,7 @@ When submitting a bug report, please include the following information: 5. Any error messages or stack traces encountered ## Pull Requests + We welcome your pull requests. To ensure a smooth and efficient review process, please follow these guidelines: ### Bug fixes & Improvements: @@ -32,18 +36,22 @@ We welcome your pull requests. To ensure a smooth and efficient review process, 6. Provide detailed explanation what your PR fixes, alternate designs, and possible ripple effects. ### Feature Requests -We welcome feature requests and suggestions for improving the Bittensor CLI. To submit a feature request, please follow these guidelines: -1. If your feature request doesn't already exist, create a new issue on GitHub with the label "enhancement" or "feature request". +We welcome feature requests and suggestions for improving the Bittensor CLI. To submit a feature request, please follow +these guidelines: + +1. If your feature request doesn't already exist, create a new issue on GitHub with the label "enhancement" or "feature + request". 3. Provide a clear and descriptive title for the feature request. -4. Explain the feature you'd like to see added, why it would be useful, and how it could be implemented. Be as specific as possible. +4. Explain the feature you'd like to see added, why it would be useful, and how it could be implemented. Be as specific + as possible. 5. If applicable, include examples, screenshots, or mockups to help illustrate your feature request. -6. Be patient and understanding. The maintainers will review your feature request and provide feedback. - +6. Be patient and understanding. The maintainers will review your feature request and provide feedback. ### Signed Commits -All commits in pull requests must be signed. We require signed commits to verify the authenticity of contributions and ensure code integrity. +All commits in pull requests must be signed. We require signed commits to verify the authenticity of contributions and +ensure code integrity. To sign your commits, you must have GPG signing configured in Git: @@ -57,11 +65,33 @@ Or configure Git to sign all commits automatically: git config --global commit.gpgsign true ``` -For instructions on setting up GPG key signing, see [GitHub's documentation on signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). +For instructions on setting up GPG key signing, +see [GitHub's documentation on signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). > **Note:** Pull requests containing unsigned commits will not be merged. +#### Oeps, I forgot to sign! + +Let's say you've opened a PR and have added a commit that is unsigned. It's simple to squash the commits in the branch, +and then sign that single commit, pushing to your PR branch: + +```shell +# 1. Make sure your local staging is up to date +git fetch origin + +# 2. Soft-reset to the point where your branch diverged from staging +# (keeps all your changes staged, discards the commits themselves) +git reset --soft $(git merge-base origin/staging HEAD) + +# 3. Create one new signed commit with everything +git commit -S -m "Your squashed commit message" + +# 4. Force-push to update the PR +git push --force-with-lease +``` + ### Tests -Try to cover with unit/e2e tests any changes you're making. -Make strong use of the `conftest.py` file (e.g. do not recreate test fixtures unless they're not there). + +Try to cover with unit/e2e tests any changes you're making. +Make strong use of the `conftest.py` file (e.g. do not recreate test fixtures unless they're not there). If you need a test fixture that is not there, add it in a reusable way to `conftest.py`. diff --git a/pyproject.toml b/pyproject.toml index f8c90305d..a3b92111f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.22.3" +version = "9.23.0rc3" description = "Bittensor CLI" readme = "README.md" authors = [ @@ -28,7 +28,6 @@ classifiers = [ "Topic :: Utilities" ] dependencies = [ - "wheel>0.46.1", "async-substrate-interface>=2.0.4,<3.0.0", "aiohttp~=3.13", "bittensor-drand>=1.3.0,<2.0.0", @@ -37,7 +36,7 @@ dependencies = [ "Jinja2", "PyYAML~=6.0", "rich>=15.0,<16.0", - "cyscale==0.4.0", + "cyscale==0.5.0", "typer~=0.26.0", "typing_extensions>4.0.0; python_version<'3.11'", "bittensor-wallet==4.1.0", diff --git a/tests/e2e_tests/test_coldkey_swap.py b/tests/e2e_tests/test_coldkey_swap.py index fe69633fe..035b5e51d 100644 --- a/tests/e2e_tests/test_coldkey_swap.py +++ b/tests/e2e_tests/test_coldkey_swap.py @@ -407,7 +407,7 @@ def test_coldkey_swap_dispute(local_chain, wallet_setup): "--no-mev-protection", ], ) - assert "ColdkeySwapDisputed" in execute.stderr, execute.stderr + assert "Custom error: 21" in execute.stderr, execute.stderr status_after = exec_command_bob( command="wallet", diff --git a/tests/e2e_tests/test_conviction_display.py b/tests/e2e_tests/test_conviction_display.py index 08c90e71f..56044686a 100644 --- a/tests/e2e_tests/test_conviction_display.py +++ b/tests/e2e_tests/test_conviction_display.py @@ -13,7 +13,7 @@ def _close(a, b): return math.isclose(a, b, rel_tol=0.0001, abs_tol=500) -def test_lock_roll_forward_comparision(local_chain, wallet_setup): +def test_lock_roll_forward_comparison(local_chain, wallet_setup): """ Test the accuracy of the lock roll-forward math between the chain and Btcli. diff --git a/tests/e2e_tests/test_hyperparams_setting.py b/tests/e2e_tests/test_hyperparams_setting.py index 9613f911c..7e0d7017f 100644 --- a/tests/e2e_tests/test_hyperparams_setting.py +++ b/tests/e2e_tests/test_hyperparams_setting.py @@ -110,10 +110,11 @@ def test_hyperparams_setting(local_chain, wallet_setup): "burn_increase_mult", } - for key, (_, sudo_only) in HYPERPARAMS.items(): + for key, (extrinsic, sudo_only) in HYPERPARAMS.items(): print(f"key: {key}, sudo_only: {sudo_only}") if ( - key in hp.keys() + extrinsic # display-only params (e.g. activity_cutoff) are not settable + and key in hp.keys() and sudo_only == RootSudoOnly.FALSE and key not in SKIP_PARAMS ): @@ -154,6 +155,36 @@ def test_hyperparams_setting(local_chain, wallet_setup): assert cmd_json["success"] is True, (key, new_val, cmd.stdout, cmd_json) assert isinstance(cmd_json["extrinsic_identifier"], str) print(f"Successfully set hyperparameter {key} to value {new_val}") + # activity_cutoff_factor is COMPLICATED (owner or root-sudo), so the loop above + # skips it; as the subnet owner, Alice takes the owner path in no-prompt mode. + # Only present on chains with the dynamic-tempo runtime. + if "activity_cutoff_factor" in hp: + cmd = exec_command_alice( + command="sudo", + sub_command="set", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-out", + "--no-prompt", + "--param", + "activity_cutoff_factor", + "--value", + "14000", + ], + ) + cmd_json = json.loads(cmd.stdout) + assert cmd_json["success"] is True, (cmd.stdout, cmd_json) + assert isinstance(cmd_json["extrinsic_identifier"], str) + print("Successfully set hyperparameter activity_cutoff_factor to value 14000") # also test hidden hyperparam cmd = exec_command_alice( command="sudo", diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py deleted file mode 100644 index a5c38ccc8..000000000 --- a/tests/e2e_tests/test_liquidity.py +++ /dev/null @@ -1,348 +0,0 @@ -import pytest -import asyncio -import json -import time - -from .utils import turn_off_hyperparam_freeze_window - -""" -Verify commands: - -* btcli liquidity add -* btcli liquidity list -* btcli liquidity modify -* btcli liquidity remove -""" - - -@pytest.mark.skip(reason="User liquidity currently disabled on chain") -def test_liquidity(local_chain, wallet_setup): - wallet_path_alice = "//Alice" - netuid = 2 - - # Create wallet for Alice - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - try: - asyncio.run(turn_off_hyperparam_freeze_window(local_chain, wallet_alice)) - except ValueError: - print( - "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." - ) - - # Register a subnet with sudo as Alice - result = exec_command_alice( - command="subnets", - sub_command="create", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--subnet-name", - "Test Subnet", - "--repo", - "https://github.com/username/repo", - "--contact", - "alice@opentensor.dev", - "--url", - "https://testsubnet.com", - "--discord", - "alice#1234", - "--description", - "A test subnet for e2e testing", - "--additional-info", - "Created by Alice", - "--logo-url", - "https://testsubnet.com/logo.png", - "--no-prompt", - "--json-output", - "--no-mev-protection", - ], - ) - result_output = json.loads(result.stdout) - assert result_output["success"] is True - assert result_output["netuid"] == netuid - assert isinstance(result_output["extrinsic_identifier"], str) - - # verify no results for list thus far (subnet not yet started) - liquidity_list_result = exec_command_alice( - command="liquidity", - sub_command="list", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--json-output", - ], - ) - result_output = json.loads(liquidity_list_result.stdout) - assert result_output["success"] is False - assert f"Subnet with netuid: {netuid} is not active" in result_output["err_msg"] - assert result_output["positions"] == [] - time.sleep(40) - - # start emissions schedule - start_subnet_emissions = exec_command_alice( - command="subnets", - sub_command="start", - extra_args=[ - "--netuid", - netuid, - "--wallet-path", - wallet_path_alice, - "--wallet-name", - wallet_alice.name, - "--hotkey", - wallet_alice.hotkey_str, - "--network", - "ws://127.0.0.1:9945", - "--no-prompt", - ], - ) - assert ( - f"Successfully started subnet {netuid}'s emission schedule" - in start_subnet_emissions.stdout - ), start_subnet_emissions.stderr - assert "Your extrinsic has been included " in start_subnet_emissions.stdout - - stake_to_enable_v3 = exec_command_alice( - command="stake", - sub_command="add", - extra_args=[ - "--netuid", - "2", - "--wallet-path", - wallet_path_alice, - "--wallet-name", - wallet_alice.name, - "--hotkey", - wallet_alice.hotkey_str, - "--chain", - "ws://127.0.0.1:9945", - "--amount", - "1", - "--unsafe", - "--no-prompt", - "--era", - "144", - "--no-mev-protection", - ], - ) - assert "✅ Finalized" in stake_to_enable_v3.stdout, stake_to_enable_v3.stderr - time.sleep(10) - liquidity_list_result = exec_command_alice( - command="liquidity", - sub_command="list", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--json-output", - ], - ) - print(">>>", liquidity_list_result.stdout, liquidity_list_result.stderr) - result_output = json.loads(liquidity_list_result.stdout) - assert result_output["success"] is False - assert result_output["err_msg"] == "No liquidity positions found." - assert result_output["positions"] == [] - - enable_user_liquidity = exec_command_alice( - command="sudo", - sub_command="set", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--param", - "user_liquidity_enabled", - "--value", - "1", - "--json-output", - "--no-prompt", - ], - ) - enable_user_liquidity_result = json.loads(enable_user_liquidity.stdout) - assert enable_user_liquidity_result["success"] is True - assert isinstance(enable_user_liquidity_result["extrinsic_identifier"], str) - - add_liquidity = exec_command_alice( - command="liquidity", - sub_command="add", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--liquidity", - "1.0", - "--price-low", - "1.7", - "--price-high", - "1.8", - "--no-prompt", - "--json-output", - ], - ) - add_liquidity_result = json.loads(add_liquidity.stdout) - assert add_liquidity_result["success"] is True - assert add_liquidity_result["message"] == "" - assert isinstance(add_liquidity_result["extrinsic_identifier"], str) - - liquidity_list_result = exec_command_alice( - command="liquidity", - sub_command="list", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--json-output", - ], - ) - print(">>>", liquidity_list_result.stdout, liquidity_list_result.stderr) - liquidity_list_result = json.loads(liquidity_list_result.stdout) - assert liquidity_list_result["success"] is True - assert len(liquidity_list_result["positions"]) == 1 - liquidity_position = liquidity_list_result["positions"][0] - assert liquidity_position["liquidity"] == 1.0 - assert liquidity_position["fees_tao"] == 0.0 - assert liquidity_position["fees_alpha"] == 0.0 - assert liquidity_position["netuid"] == netuid - assert abs(liquidity_position["price_high"] - 1.8) < 0.0001 - assert abs(liquidity_position["price_low"] - 1.7) < 0.0001 - - modify_liquidity = exec_command_alice( - command="liquidity", - sub_command="modify", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--position-id", - str(liquidity_position["id"]), - "--liquidity-delta", - "20.0", - "--json-output", - "--no-prompt", - ], - ) - modify_liquidity_result = json.loads(modify_liquidity.stdout) - assert modify_liquidity_result["success"] is True - assert isinstance(modify_liquidity_result["extrinsic_identifier"], str) - - llr = exec_command_alice( - command="liquidity", - sub_command="list", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--json-output", - ], - ) - print(">>>", llr.stdout, llr.stderr) - liquidity_list_result = json.loads(llr.stdout) - assert len(liquidity_list_result["positions"]) == 1 - liquidity_position = liquidity_list_result["positions"][0] - assert liquidity_position["liquidity"] == 21.0 - - removal = exec_command_alice( - command="liquidity", - sub_command="remove", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--all", - "--no-prompt", - "--json-output", - ], - ) - removal_result = json.loads(removal.stdout) - assert removal_result[str(liquidity_position["id"])]["success"] is True - assert isinstance( - removal_result[str(liquidity_position["id"])]["extrinsic_identifier"], str - ) - - liquidity_list_result = exec_command_alice( - command="liquidity", - sub_command="list", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--json-output", - ], - ) - print(">>>", liquidity_list_result.stdout, liquidity_list_result.stderr) - liquidity_list_result = json.loads(liquidity_list_result.stdout) - assert liquidity_list_result["success"] is False - assert result_output["err_msg"] == "No liquidity positions found." - assert liquidity_list_result["positions"] == [] diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 68633b3dd..c12486ed0 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -296,8 +296,14 @@ def test_staking(local_chain, wallet_setup): assert str(netuid) in get_s_price_output.keys() stats = get_s_price_output[str(netuid)]["stats"] assert stats["name"] == sn_name - assert stats["current_price"] == 0.0 - assert stats["market_cap"] == 0.0 + # Under the Balancer swap, a registered-but-not-yet-started subnet is still + # non-dynamic, so the chain's current_alpha_price returns 1.0 (1:1 with TAO). + # (Pre-Balancer this read empty AlphaSqrtPrice storage and was 0.0.) + assert stats["current_price"] == 1.0 + # market_cap = price * (alpha_in + alpha_out) = supply, since price is 1.0. + # Pre-Balancer this was 0.0 because price was 0.0; now it surfaces the supply. + assert stats["market_cap"] == stats["supply"] + assert stats["market_cap"] == 1000.0 # Start emissions on SNs for netuid_ in multiple_netuids: diff --git a/tests/e2e_tests/test_trigger_epoch.py b/tests/e2e_tests/test_trigger_epoch.py new file mode 100644 index 000000000..06246347c --- /dev/null +++ b/tests/e2e_tests/test_trigger_epoch.py @@ -0,0 +1,168 @@ +import asyncio +import json + +import pytest + +from .utils import turn_off_hyperparam_freeze_window + +""" +Verify commands: + +* btcli subnets create +* btcli sudo trigger-epoch +""" + + +def test_trigger_epoch(local_chain, wallet_setup): + netuid = 2 + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( + wallet_path_bob + ) + print("Created keypairs") + + # All direct substrate work must happen in a single asyncio.run: the websocket + # connection binds to the event loop that first uses it, so a second + # asyncio.run on the same local_chain object hangs forever. + async def _supports_trigger_epoch_and_unfreeze() -> bool: + # The owner-side trigger_epoch extrinsic only exists on dynamic-tempo + # runtimes. + try: + await local_chain.compose_call( + call_module="SubtensorModule", + call_function="trigger_epoch", + call_params={"netuid": netuid}, + ) + except ValueError: + return False + # With the freeze window on, a fresh subnet's next auto epoch can be close + # enough that trigger_epoch fails with AutoEpochAlreadyImminent. + await turn_off_hyperparam_freeze_window(local_chain, wallet_alice) + return True + + if not asyncio.run(_supports_trigger_epoch_and_unfreeze()): + pytest.skip( + "Chain does not support SubtensorModule.trigger_epoch " + "(pre-dynamic-tempo runtime)." + ) + + # Register a subnet with sudo as Alice + result = exec_command_alice( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--subnet-name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", + "--no-prompt", + "--json-output", + "--no-mev-protection", + ], + ) + result_output = json.loads(result.stdout) + assert result_output["success"] is True + assert result_output["netuid"] == netuid + + # The chain rejects trigger_epoch while commit-reveal is enabled + # (DynamicTempoBlockedByCommitReveal), and localnet subnets have it enabled + # by default — disable it first. + cmd = exec_command_alice( + command="sudo", + sub_command="set", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-out", + "--no-prompt", + "--param", + "commit_reveal_weights_enabled", + "--value", + "false", + ], + ) + cmd_json = json.loads(cmd.stdout) + assert cmd_json["success"] is True, (cmd.stdout, cmd_json) + + # A non-owner cannot trigger an epoch + cmd = exec_command_bob( + command="sudo", + sub_command="trigger-epoch", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--netuid", + netuid, + "--json-out", + "--no-prompt", + ], + ) + cmd_json = json.loads(cmd.stdout) + assert cmd_json["success"] is False, (cmd.stdout, cmd_json) + assert "doesn't own" in cmd_json["message"] + + # The subnet owner triggers an epoch + cmd = exec_command_alice( + command="sudo", + sub_command="trigger-epoch", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-out", + "--no-prompt", + ], + ) + cmd_json = json.loads(cmd.stdout) + assert cmd_json["success"] is True, (cmd.stdout, cmd_json) + assert isinstance(cmd_json["extrinsic_identifier"], str) + # fires_at is read from the EpochTriggered event; it should decode on a + # dynamic-tempo chain. + assert isinstance(cmd_json["fires_at"], int), cmd_json + print(f"Successfully triggered epoch on SN{netuid}") diff --git a/tests/e2e_tests/test_wallet_interactions.py b/tests/e2e_tests/test_wallet_interactions.py index 02797b978..177c68e5e 100644 --- a/tests/e2e_tests/test_wallet_interactions.py +++ b/tests/e2e_tests/test_wallet_interactions.py @@ -85,10 +85,7 @@ def test_wallet_overview_inspect(local_chain, wallet_setup): subnets_list = exec_command( command="subnets", sub_command="list", - extra_args=[ - "--chain", - "ws://127.0.0.1:9945", - ], + extra_args=["--chain", "ws://127.0.0.1:9945"], ) sleep(3) @@ -278,7 +275,7 @@ def test_wallet_transfer(local_chain, wallet_setup): )["free_balance"] ) - tolerance = Balance.from_rao(200_000) # Tolerance for transaction fee + tolerance = Balance.from_rao(600_000) # Tolerance for transaction fee balance_difference = initial_balance - balance_remaining # Assert transfer was successful w.r.t tolerance diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index 9c77f2770..5a8e368f7 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -156,7 +156,7 @@ def find_stake_entries( return matching_stakes -def verify_subnet_entry(output_text: str, netuid: str, ss58_address: str) -> bool: +def verify_subnet_entry(output_text: str, netuid: str | int, ss58_address: str) -> bool: """ Verifies the presence of a specific subnet entry subnets list output. diff --git a/tests/unit_tests/test_balancer_price.py b/tests/unit_tests/test_balancer_price.py new file mode 100644 index 000000000..ed44e838a --- /dev/null +++ b/tests/unit_tests/test_balancer_price.py @@ -0,0 +1,176 @@ +"""Unit tests for the Balancer swap price methods on SubtensorInterface. + +These cover the migration from the old `Swap::AlphaSqrtPrice` storage to the +`SwapRuntimeApi::current_alpha_price` / `current_alpha_price_all` runtime +calls, plus the graceful fallbacks that keep the CLI working against +pre-Balancer chains. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +def _exists_for(*methods): + """Build an async side_effect for _runtime_method_exists that only reports + the given runtime method names as present.""" + + async def _exists(api, method, block_hash=None): + return method in methods + + return _exists + + +@pytest.mark.asyncio +async def test_get_subnet_price_sn0_is_one_tao(): + """SN0 (root) uses TAO directly and is always 1 TAO, no chain calls.""" + subtensor = SubtensorInterface("finney") + with patch.object( + SubtensorInterface, "_runtime_method_exists", new_callable=AsyncMock + ) as exists: + price = await subtensor.get_subnet_price(netuid=0) + assert price == Balance.from_tao(1) + exists.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_subnet_price_uses_runtime_api(): + """When current_alpha_price exists, the runtime call result (rao) is used.""" + subtensor = SubtensorInterface("finney") + with ( + patch.object( + SubtensorInterface, + "_runtime_method_exists", + side_effect=_exists_for("current_alpha_price"), + ), + patch.object( + SubtensorInterface, + "query_runtime_api", + new_callable=AsyncMock, + return_value=2_500_000_000, + ) as query_rt, + ): + price = await subtensor.get_subnet_price(netuid=1, block_hash="0xabc") + + assert price == Balance.from_rao(2_500_000_000) + query_rt.assert_awaited_once_with( + "SwapRuntimeApi", + "current_alpha_price", + params=[1], + block_hash="0xabc", + ) + + +@pytest.mark.asyncio +async def test_get_subnet_price_falls_back_to_storage(): + """On pre-Balancer chains (no runtime method) it falls back to storage.""" + subtensor = SubtensorInterface("finney") + with ( + patch.object( + SubtensorInterface, + "_runtime_method_exists", + side_effect=_exists_for(), # nothing exists + ), + patch.object( + SubtensorInterface, + "_get_subnet_price_from_storage", + new_callable=AsyncMock, + return_value=Balance.from_rao(777), + ) as fallback, + ): + price = await subtensor.get_subnet_price(netuid=3, block_hash="0xabc") + + assert price == Balance.from_rao(777) + fallback.assert_awaited_once_with(3, block_hash="0xabc") + + +@pytest.mark.asyncio +async def test_get_subnet_prices_uses_current_alpha_price_all(): + """Preferred path: a single current_alpha_price_all runtime call.""" + subtensor = SubtensorInterface("finney") + with ( + patch.object( + SubtensorInterface, + "_runtime_method_exists", + side_effect=_exists_for("current_alpha_price_all"), + ), + patch.object( + SubtensorInterface, + "query_runtime_api", + new_callable=AsyncMock, + return_value=[ + {"netuid": 1, "price": 1000}, + {"netuid": 2, "price": 2000}, + ], + ) as query_rt, + ): + prices = await subtensor.get_subnet_prices(block_hash="0xabc") + + assert prices == {1: Balance.from_rao(1000), 2: Balance.from_rao(2000)} + query_rt.assert_awaited_once_with( + "SwapRuntimeApi", "current_alpha_price_all", block_hash="0xabc" + ) + + +@pytest.mark.asyncio +async def test_get_subnet_prices_per_netuid_fallback(): + """If only current_alpha_price exists, prices are fetched per-netuid.""" + subtensor = SubtensorInterface("finney") + + async def fake_price(netuid, block_hash=None): + return Balance.from_rao(netuid * 100) + + with ( + patch.object( + SubtensorInterface, + "_runtime_method_exists", + side_effect=_exists_for("current_alpha_price"), + ), + patch.object( + SubtensorInterface, + "get_all_subnet_netuids", + new_callable=AsyncMock, + return_value=[0, 1, 2], + ), + patch.object(SubtensorInterface, "get_subnet_price", side_effect=fake_price), + ): + prices = await subtensor.get_subnet_prices(block_hash="0xabc") + + assert prices == { + 0: Balance.from_rao(0), + 1: Balance.from_rao(100), + 2: Balance.from_rao(200), + } + + +@pytest.mark.asyncio +async def test_get_subnet_prices_legacy_storage_fallback(): + """On pre-Balancer chains, fall back to the AlphaSqrtPrice storage map.""" + subtensor = SubtensorInterface("finney") + subtensor.substrate = MagicMock() + # raw sqrt-price values; fixed_to_float is patched to identity below + subtensor.substrate.query_map = AsyncMock( + return_value=MagicMock(records=[(1, 1.0), (2, 2.0)]) + ) + + with ( + patch.object( + SubtensorInterface, + "_runtime_method_exists", + side_effect=_exists_for(), # nothing exists + ), + patch( + "bittensor_cli.src.bittensor.subtensor_interface.fixed_to_float", + side_effect=lambda v: float(v), + ), + ): + prices = await subtensor.get_subnet_prices(block_hash="0xabc") + + # price = sqrt**2 * 1e9 rao + assert prices == { + 1: Balance.from_rao(int((1.0**2) * 1e9)), + 2: Balance.from_rao(int((2.0**2) * 1e9)), + } diff --git a/tests/unit_tests/test_epoch_prediction.py b/tests/unit_tests/test_epoch_prediction.py new file mode 100644 index 000000000..20212e53f --- /dev/null +++ b/tests/unit_tests/test_epoch_prediction.py @@ -0,0 +1,80 @@ +"""Unit tests for epoch prediction under the dynamic-tempo scheduler.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from bittensor_cli.src.commands.stake.children_hotkeys import ( + get_childkey_completion_block, +) + + +def _configure( + mock_subtensor: MagicMock, + block_number: int, + tempo: int, + blocks_since_last_step: int, + next_epoch: int | None, +) -> None: + mock_subtensor.substrate.get_block_number = AsyncMock(return_value=block_number) + mock_subtensor.query = AsyncMock(return_value=blocks_since_last_step) + mock_subtensor.get_hyperparameter = AsyncMock(return_value=tempo) + mock_subtensor.get_next_epoch_start_block = AsyncMock(return_value=next_epoch) + + +@pytest.mark.asyncio +async def test_completion_uses_next_epoch_when_past_cooldown(mock_subtensor): + # Cooldown ends at block 8_000; the chain already reports an epoch after it. + _configure( + mock_subtensor, + block_number=800, + tempo=360, + blocks_since_last_step=100, + next_epoch=8_100, + ) + block_number, completion = await get_childkey_completion_block( + mock_subtensor, netuid=1 + ) + assert block_number == 800 + assert completion == 8_100 + + +@pytest.mark.asyncio +async def test_completion_steps_tempo_past_cooldown(mock_subtensor): + # next_epoch is before the cooldown end (block 1_000 + 7_200 = 8_200): step + # forward in tempo increments to the first epoch at or after it. + _configure( + mock_subtensor, + block_number=1_000, + tempo=360, + blocks_since_last_step=100, + next_epoch=1_260, + ) + block_number, completion = await get_childkey_completion_block( + mock_subtensor, netuid=1 + ) + assert block_number == 1_000 + expected = 1_260 + ((8_200 - 1_260 + 360 - 1) // 360) * 360 + assert completion == expected + assert completion >= 8_200 + assert completion - 8_200 < 360 + + +@pytest.mark.asyncio +async def test_completion_falls_back_to_legacy_modulo(mock_subtensor): + # Chains without the dynamic-tempo runtime API return None: legacy math. + block_number, tempo, blocks_since_last_step = 1_000, 360, 100 + _configure( + mock_subtensor, + block_number=block_number, + tempo=tempo, + blocks_since_last_step=blocks_since_last_step, + next_epoch=None, + ) + result_block, completion = await get_childkey_completion_block( + mock_subtensor, netuid=1 + ) + cooldown = block_number + 7_200 + next_tempo = block_number + (tempo - blocks_since_last_step) + expected = (cooldown - next_tempo) % (tempo + 1) + cooldown + assert result_block == block_number + assert completion == expected diff --git a/tests/unit_tests/test_hyperparams.py b/tests/unit_tests/test_hyperparams.py index 43f23a599..5e1b1f729 100644 --- a/tests/unit_tests/test_hyperparams.py +++ b/tests/unit_tests/test_hyperparams.py @@ -47,3 +47,83 @@ def test_max_burn_is_owner_or_root_settable(): def test_max_burn_metadata_owner_settable_true(): assert HYPERPARAMS_METADATA["max_burn"]["owner_settable"] is True + + +# --- Dynamic tempo / owner-triggered epochs (subtensor issue #2633) --- + + +def test_tempo_is_owner_or_root_settable(): + extrinsic, root_only = HYPERPARAMS["tempo"] + assert extrinsic == "set_tempo" + assert root_only is RootSudoOnly.COMPLICATED + + +def test_tempo_owner_path_uses_subtensor_module(): + from bittensor_cli.src import HYPERPARAMS_MODULE + + assert HYPERPARAMS_MODULE["tempo"] == "SubtensorModule" + + +def test_tempo_root_path_uses_sudo_set_tempo(): + from bittensor_cli.src import HYPERPARAMS_ROOT_EXTRINSIC + + assert HYPERPARAMS_ROOT_EXTRINSIC["tempo"] == ("AdminUtils", "sudo_set_tempo") + + +def test_activity_cutoff_factor_settable_via_subtensor_module(): + from bittensor_cli.src import HYPERPARAMS_MODULE + + extrinsic, root_only = HYPERPARAMS["activity_cutoff_factor"] + assert extrinsic == "set_activity_cutoff_factor" + assert root_only is RootSudoOnly.COMPLICATED + assert HYPERPARAMS_MODULE["activity_cutoff_factor"] == "SubtensorModule" + + +def test_activity_cutoff_no_longer_directly_settable(): + extrinsic, _ = HYPERPARAMS["activity_cutoff"] + assert extrinsic == "" + assert HYPERPARAMS_METADATA["activity_cutoff"]["owner_settable"] is False + + +def test_dynamic_tempo_params_have_metadata(): + required = {"description", "side_effects", "owner_settable", "docs_link"} + for key in ("tempo", "activity_cutoff", "activity_cutoff_factor"): + assert key in HYPERPARAMS_METADATA, f"{key} should be in HYPERPARAMS_METADATA" + meta = HYPERPARAMS_METADATA[key] + for field in required: + assert field in meta, f"{key} metadata missing '{field}'" + assert HYPERPARAMS_METADATA["tempo"]["owner_settable"] is True + assert HYPERPARAMS_METADATA["activity_cutoff_factor"]["owner_settable"] is True + + +def test_activity_cutoff_factor_allowed_value_bounds(): + from bittensor_cli.src.commands.sudo import allowed_value + + ok, val = allowed_value("activity_cutoff_factor", "13889", normalize=False) + assert ok is True + assert val == 13889 + + ok, _ = allowed_value("activity_cutoff_factor", "999", normalize=False) + assert ok is False + + ok, _ = allowed_value("activity_cutoff_factor", "50001", normalize=False) + assert ok is False + + ok, _ = allowed_value("activity_cutoff_factor", "not_a_number", normalize=False) + assert ok is False + + +def test_min_childkey_take_in_hyperparams(): + extrinsic, root_only = HYPERPARAMS["min_childkey_take"] + assert extrinsic == "sudo_set_min_childkey_take_per_subnet" + assert root_only is RootSudoOnly.FALSE + + +def test_min_childkey_take_has_metadata(): + required = {"description", "side_effects", "owner_settable", "docs_link"} + assert "min_childkey_take" in HYPERPARAMS_METADATA + meta = HYPERPARAMS_METADATA["min_childkey_take"] + for field in required: + assert field in meta, f"min_childkey_take metadata missing '{field}'" + assert meta["owner_settable"] is True + assert "#minchildkeytake" in meta["docs_link"] diff --git a/tests/unit_tests/test_sudo_hyperparameter_permissions.py b/tests/unit_tests/test_sudo_hyperparameter_permissions.py index 03ef14bfc..8146661c8 100644 --- a/tests/unit_tests/test_sudo_hyperparameter_permissions.py +++ b/tests/unit_tests/test_sudo_hyperparameter_permissions.py @@ -225,3 +225,244 @@ async def test_max_burn_interactive_non_owner_chooses_non_sudo_errors( block_hash=mock_subtensor.substrate.last_block_hash, ) mock_subtensor.sign_and_send_extrinsic.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_tempo_no_prompt_owner_uses_subtensor_set_tempo( + mock_wallet, mock_subtensor, successful_receipt +): + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + mock_subtensor.query = AsyncMock(return_value=COLDKEY_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "tempo"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock(return_value=direct_call) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="tempo", + value="720", + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=False, + normalize=False, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + mock_subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="SubtensorModule", + call_function="set_tempo", + call_params={"netuid": 1, "tempo": "720"}, + block_hash=mock_subtensor.substrate.last_block_hash, + ) + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + direct_call, + mock_wallet, + False, + False, + proxy=None, + ) + + +@pytest.mark.asyncio +async def test_tempo_no_prompt_non_owner_uses_sudo_set_tempo( + mock_wallet, mock_subtensor, successful_receipt +): + """The root-sudo path for tempo wraps AdminUtils.sudo_set_tempo, not the owner call.""" + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + root_call = MagicMock(name="root_call") + sudo_call = MagicMock(name="sudo_call") + mock_subtensor.query = AsyncMock(return_value=NON_OWNER_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "tempo"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock( + side_effect=[direct_call, root_call, sudo_call] + ) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="tempo", + value="100000", + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=False, + normalize=False, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + assert mock_subtensor.substrate.compose_call.await_count == 3 + assert mock_subtensor.substrate.compose_call.await_args_list[0].kwargs == { + "call_module": "SubtensorModule", + "call_function": "set_tempo", + "call_params": {"netuid": 1, "tempo": "100000"}, + "block_hash": mock_subtensor.substrate.last_block_hash, + } + assert mock_subtensor.substrate.compose_call.await_args_list[1].kwargs == { + "call_module": "AdminUtils", + "call_function": "sudo_set_tempo", + "call_params": {"netuid": 1, "tempo": "100000"}, + "block_hash": mock_subtensor.substrate.last_block_hash, + } + assert mock_subtensor.substrate.compose_call.await_args_list[2].kwargs == { + "call_module": "Sudo", + "call_function": "sudo", + "call_params": {"call": root_call}, + "block_hash": mock_subtensor.substrate.last_block_hash, + } + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + sudo_call, + mock_wallet, + False, + False, + proxy=None, + ) + + +@pytest.mark.asyncio +async def test_activity_cutoff_factor_no_prompt_owner_uses_owner_path( + mock_wallet, mock_subtensor, successful_receipt +): + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + mock_subtensor.query = AsyncMock(return_value=COLDKEY_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "factor_milli"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock(return_value=direct_call) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="activity_cutoff_factor", + value=13889, + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=False, + normalize=False, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + mock_subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="SubtensorModule", + call_function="set_activity_cutoff_factor", + call_params={"netuid": 1, "factor_milli": 13889}, + block_hash=mock_subtensor.substrate.last_block_hash, + ) + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + direct_call, + mock_wallet, + False, + False, + proxy=None, + ) + + +@pytest.mark.asyncio +async def test_activity_cutoff_factor_no_prompt_non_owner_wraps_owner_call_in_sudo( + mock_wallet, mock_subtensor, successful_receipt +): + """No root override for activity_cutoff_factor: sudo wraps the same owner call.""" + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + direct_call = MagicMock(name="direct_call") + sudo_call = MagicMock(name="sudo_call") + mock_subtensor.query = AsyncMock(return_value=NON_OWNER_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "factor_milli"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock( + side_effect=[direct_call, sudo_call] + ) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=1, + proxy=None, + parameter="activity_cutoff_factor", + value=13889, + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=False, + normalize=False, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + assert mock_subtensor.substrate.compose_call.await_count == 2 + assert mock_subtensor.substrate.compose_call.await_args_list[0].kwargs == { + "call_module": "SubtensorModule", + "call_function": "set_activity_cutoff_factor", + "call_params": {"netuid": 1, "factor_milli": 13889}, + "block_hash": mock_subtensor.substrate.last_block_hash, + } + assert mock_subtensor.substrate.compose_call.await_args_list[1].kwargs == { + "call_module": "Sudo", + "call_function": "sudo", + "call_params": {"call": direct_call}, + "block_hash": mock_subtensor.substrate.last_block_hash, + } + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once_with( + sudo_call, + mock_wallet, + False, + False, + proxy=None, + ) diff --git a/tests/unit_tests/test_sudo_min_childkey_take.py b/tests/unit_tests/test_sudo_min_childkey_take.py new file mode 100644 index 000000000..3f44e73f5 --- /dev/null +++ b/tests/unit_tests/test_sudo_min_childkey_take.py @@ -0,0 +1,55 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from bittensor_cli.src.bittensor.utils import float_to_u16 + +from .conftest import COLDKEY_SS58 + +MODULE = "bittensor_cli.src.commands.sudo" + + +@pytest.mark.asyncio +async def test_min_childkey_take_owner_composes_extrinsic( + mock_wallet, mock_subtensor, successful_receipt +): + from bittensor_cli.src.commands.sudo import set_hyperparameter_extrinsic + + take_u16 = float_to_u16(0.06) + direct_call = MagicMock(name="direct_call") + mock_subtensor.query = AsyncMock(return_value=COLDKEY_SS58) + mock_subtensor.substrate.metadata = MagicMock() + mock_subtensor.substrate.get_metadata_call_function = AsyncMock( + return_value={"fields": [{"name": "netuid"}, {"name": "take"}]} + ) + mock_subtensor.substrate.compose_call = AsyncMock(return_value=direct_call) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", successful_receipt) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.requires_bool", return_value=False), + patch(f"{MODULE}.print_extrinsic_id", new_callable=AsyncMock), + ): + success, err_msg, ext_id = await set_hyperparameter_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + netuid=18, + proxy=None, + parameter="min_childkey_take", + value=take_u16, + wait_for_inclusion=False, + wait_for_finalization=False, + prompt=False, + normalize=False, + ) + + assert success is True + assert err_msg == "" + assert ext_id == "0x123-1" + mock_subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="AdminUtils", + call_function="sudo_set_min_childkey_take_per_subnet", + call_params={"netuid": 18, "take": take_u16}, + block_hash=mock_subtensor.substrate.last_block_hash, + ) diff --git a/tests/unit_tests/test_wallet_list.py b/tests/unit_tests/test_wallet_list.py new file mode 100644 index 000000000..ca246d126 --- /dev/null +++ b/tests/unit_tests/test_wallet_list.py @@ -0,0 +1,145 @@ +import json +import pytest +from unittest.mock import MagicMock, patch + +from .conftest import COLDKEY_SS58, HOTKEY_SS58 + +MODULE = "bittensor_cli.src.commands.wallets" + + +def _make_list_wallet(name: str = "coldkey1") -> MagicMock: + wallet = MagicMock() + wallet.name = name + wallet.coldkeypub_file.exists_on_device.return_value = True + wallet.coldkeypub_file.path = f"/tmp/{name}/coldkeypub.txt" + wallet.coldkeypub_file.is_encrypted.return_value = False + wallet.coldkeypub.ss58_address = COLDKEY_SS58 + wallet.coldkeypub.crypto_type = 1 + return wallet + + +def _make_hotkey_wallet(name: str = "default") -> MagicMock: + hotkey = MagicMock() + hotkey.name = name + hotkey.hotkey_str = name + hotkey.get_hotkey.return_value.ss58_address = HOTKEY_SS58 + hotkey.get_hotkey.return_value.crypto_type = 1 + return hotkey + + +def test_natural_sort_key_orders_numeric_suffixes(): + from bittensor_cli.src.commands.wallets import _natural_sort_key + + names = ["coldkey10", "coldkey2", "coldkey1", "zebra", "alice"] + assert sorted(names, key=_natural_sort_key) == [ + "alice", + "coldkey1", + "coldkey2", + "coldkey10", + "zebra", + ] + + +@pytest.mark.asyncio +async def test_wallet_list_sorts_coldkeys_naturally_in_json_output(): + from bittensor_cli.src.commands.wallets import wallet_list + + wallets = [ + _make_list_wallet("coldkey10"), + _make_list_wallet("coldkey2"), + _make_list_wallet("coldkey1"), + ] + + with ( + patch(f"{MODULE}.utils.get_coldkey_wallets_for_path", return_value=wallets), + patch(f"{MODULE}.json_console") as mock_json, + ): + await wallet_list("/tmp/wallets", json_output=True, coldkeys_only=True) + + payload = json.loads(mock_json.print.call_args[0][0]) + assert [wallet["name"] for wallet in payload["wallets"]] == [ + "coldkey1", + "coldkey2", + "coldkey10", + ] + + +@pytest.mark.asyncio +async def test_wallet_list_sorts_hotkeys_naturally_in_json_output(): + from bittensor_cli.src.commands.wallets import wallet_list + + coldkey = _make_list_wallet() + hotkeys = [ + _make_hotkey_wallet("hotkey10"), + _make_hotkey_wallet("hotkey2"), + _make_hotkey_wallet("hotkey1"), + ] + + with ( + patch(f"{MODULE}.utils.get_coldkey_wallets_for_path", return_value=[coldkey]), + patch(f"{MODULE}.utils.get_hotkey_wallets_for_wallet", return_value=hotkeys), + patch(f"{MODULE}.json_console") as mock_json, + ): + await wallet_list("/tmp/wallets", json_output=True, coldkeys_only=False) + + payload = json.loads(mock_json.print.call_args[0][0]) + assert [hk["name"] for hk in payload["wallets"][0]["hotkeys"]] == [ + "hotkey1", + "hotkey2", + "hotkey10", + ] + + +@pytest.mark.asyncio +async def test_wallet_list_coldkeys_only_skips_hotkey_lookup(): + from bittensor_cli.src.commands.wallets import wallet_list + + coldkey = _make_list_wallet() + + with ( + patch(f"{MODULE}.utils.get_coldkey_wallets_for_path", return_value=[coldkey]), + patch(f"{MODULE}.utils.get_hotkey_wallets_for_wallet") as mock_hotkeys, + patch(f"{MODULE}.console"), + ): + await wallet_list("/tmp/wallets", json_output=False, coldkeys_only=True) + + mock_hotkeys.assert_not_called() + + +@pytest.mark.asyncio +async def test_wallet_list_default_includes_hotkeys(): + from bittensor_cli.src.commands.wallets import wallet_list + + coldkey = _make_list_wallet() + hotkey = _make_hotkey_wallet() + + with ( + patch(f"{MODULE}.utils.get_coldkey_wallets_for_path", return_value=[coldkey]), + patch( + f"{MODULE}.utils.get_hotkey_wallets_for_wallet", return_value=[hotkey] + ) as mock_hotkeys, + patch(f"{MODULE}.console"), + ): + await wallet_list("/tmp/wallets", json_output=False, coldkeys_only=False) + + mock_hotkeys.assert_called_once_with(coldkey, show_nulls=True, show_encrypted=True) + + +@pytest.mark.asyncio +async def test_wallet_list_coldkeys_only_json_has_empty_hotkeys(): + from bittensor_cli.src.commands.wallets import wallet_list + + coldkey = _make_list_wallet() + + with ( + patch(f"{MODULE}.utils.get_coldkey_wallets_for_path", return_value=[coldkey]), + patch(f"{MODULE}.utils.get_hotkey_wallets_for_wallet") as mock_hotkeys, + patch(f"{MODULE}.json_console") as mock_json, + ): + await wallet_list("/tmp/wallets", json_output=True, coldkeys_only=True) + + mock_hotkeys.assert_not_called() + payload = json.loads(mock_json.print.call_args[0][0]) + assert len(payload["wallets"]) == 1 + assert payload["wallets"][0]["name"] == "coldkey1" + assert payload["wallets"][0]["hotkeys"] == []