diff --git a/.github/workflows/check-bittensor-e2e-tests.yml b/.github/workflows/check-bittensor-e2e-tests.yml index e85d992a77..a249a37333 100644 --- a/.github/workflows/check-bittensor-e2e-tests.yml +++ b/.github/workflows/check-bittensor-e2e-tests.yml @@ -50,7 +50,7 @@ on: env: CARGO_TERM_COLOR: always VERBOSE: ${{ github.event.inputs.verbose }} - # The in-repo SDK e2e harness reads LOCALNET_IMAGE from the environment. + # The Rust SDK e2e harness reads LOCALNET_IMAGE from the environment. LOCALNET_IMAGE: ghcr.io/raofoundation/subtensor-localnet:ci # Per-commit tag on GHCR: built once, pulled by every test job. Registry # pulls beat the old artifact-tarball + docker-load path (~1-2 min/job) @@ -119,59 +119,85 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.event.pull_request.head.ref || github.ref_name }} - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: "true" - cache-dependency-glob: "sdk/python/uv.lock" - - # One matrix entry per test FILE, not per test: every job pays ~2-3 - # minutes of fixed cost (checkout, uv sync, downloading + loading the - # localnet image, booting a chain), so per-test fan-out spent ~20x more - # time on setup than on tests. The e2e conftest's fixtures are already - # session-scoped — one localnet shared by all tests in a pytest run — - # so a per-file job is the granularity the suite was written for. - - name: Collect in-repo SDK e2e tests + - name: Verify the Rust e2e migration manifest id: get-tests - working-directory: sdk/python + shell: bash run: | set -euo pipefail - uv sync --locked --all-extras --dev - test_matrix=$( - uv run pytest -q --collect-only -m e2e \ - | sed -n '/^tests\/e2e\//p' \ - | jq -R -s -c ' - split("\n") - | map(select(. != "")) - | map(split("::")[0]) - | unique - | map({nodeid: ., label: (sub("^tests/e2e/"; "") | sub("\\.py$"; ""))}) - ' - ) - echo "Found test files: $test_matrix" - if [ "$test_matrix" = "[]" ] || [ -z "$test_matrix" ]; then - echo "No e2e tests found — failing." - exit 1 - fi + manifest=sdk/bittensor-core/tests/e2e-manifest.json + jq -e 'length == 113' "$manifest" >/dev/null + jq -e 'map(.test) | length == (unique | length)' "$manifest" >/dev/null + jq -e 'all(.[]; (.test | startswith("intent_")) or (.test | startswith("test_")))' "$manifest" >/dev/null + test_matrix=$(jq -c . "$manifest") + echo "Found $(jq length "$manifest") migrated Rust e2e tests" echo "test-files=$test_matrix" >> "$GITHUB_OUTPUT" + + build-rust-e2e-test-binary: + needs: [check-label, find-e2e-tests] + if: needs.check-label.outputs.skip-bittensor-e2e-tests == 'false' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + + - name: Install Rust + build dependencies + run: | + chmod +x ./scripts/install_build_env.sh + ./scripts/install_build_env.sh + + - name: Compile once and prove manifest completeness shell: bash + run: | + set -euo pipefail + source "$HOME/.cargo/env" + mkdir -p build/rust-e2e + # The workspace/localnet build also compiles the Python binding. Keep + # CoreError/API compatibility in this faster job so failures surface + # before the expensive node builds. + cargo check -p bittensor-core-py + cargo test -p bittensor-core --test e2e --no-run --message-format=json \ + > build/rust-e2e/cargo-messages.json + executable=$( + jq -r ' + select(.reason == "compiler-artifact") + | select(.target.name == "e2e") + | select(.target.kind | index("test")) + | .executable // empty + ' build/rust-e2e/cargo-messages.json | tail -n 1 + ) + test -n "$executable" + cp "$executable" build/rust-e2e/bittensor-core-e2e + chmod +x build/rust-e2e/bittensor-core-e2e + + jq -r '.[].test' sdk/bittensor-core/tests/e2e-manifest.json \ + | sort > build/rust-e2e/expected-tests.txt + build/rust-e2e/bittensor-core-e2e --list --format terse \ + | sed -n 's/: test$//p' \ + | sort > build/rust-e2e/compiled-tests.txt + diff -u build/rust-e2e/expected-tests.txt build/rust-e2e/compiled-tests.txt + + - name: Upload compiled Rust e2e binary + uses: actions/upload-artifact@v4 + with: + name: bittensor-core-e2e + path: build/rust-e2e/bittensor-core-e2e + if-no-files-found: error artifacts: - name: Node • ${{ matrix.runtime }} • ${{ matrix.platform.arch }} + name: Node • fast-runtime • ${{ matrix.platform.arch }} needs: [check-label, trusted-pr] if: needs.check-label.outputs.skip-bittensor-e2e-tests == 'false' strategy: + fail-fast: false matrix: platform: - runner: [self-hosted, fireactions-heavy] triple: x86_64-unknown-linux-gnu arch: amd64 - runtime: ["fast-runtime", "non-fast-runtime"] runs-on: ${{ matrix.platform.runner }} @@ -202,15 +228,13 @@ jobs: export PATH="$HOME/.cargo/bin:$PATH" export CARGO_BUILD_TARGET="${{ matrix.platform.triple }}" - if [ "${{ matrix.runtime }}" = "fast-runtime" ]; then - ./scripts/localnet.sh --build-only - else - ./scripts/localnet.sh False --build-only - fi + # The SDK e2e suite validates the fast-runtime localnet image used + # by developer and CI localnet runs. + ./scripts/localnet.sh --build-only - name: Prepare artifacts for upload run: | - RUNTIME="${{ matrix.runtime }}" + RUNTIME="fast-runtime" TRIPLE="${{ matrix.platform.triple }}" BINARY_PATH="target/${RUNTIME}/${TRIPLE}/release/node-subtensor" @@ -237,7 +261,7 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: binaries-${{ matrix.platform.triple }}-${{ matrix.runtime }} + name: binaries-${{ matrix.platform.triple }}-fast-runtime path: build/ if-no-files-found: error @@ -270,7 +294,7 @@ jobs: docker info | grep "Docker Root Dir" - name: Build Docker Image - run: docker build -f Dockerfile-localnet --build-arg BUILT_IN_CI="Boom shakalaka" -t "$LOCALNET_IMAGE_CI" . + run: docker build -f sdk/bittensor-core/tests/Dockerfile.localnet-fast -t "$LOCALNET_IMAGE_CI" . - name: Login to GHCR uses: docker/login-action@v3 @@ -282,10 +306,11 @@ jobs: - name: Push Docker Image to GHCR run: docker push "$LOCALNET_IMAGE_CI" - run-sdk-e2e-tests: + run-rust-e2e-tests: needs: - check-label - find-e2e-tests + - build-rust-e2e-test-binary - build-image-with-current-branch if: needs.check-label.outputs.skip-bittensor-e2e-tests == 'false' runs-on: ubuntu-latest @@ -296,24 +321,13 @@ jobs: include: ${{ fromJson(needs.find-e2e-tests.outputs.test-files) }} timeout-minutes: 60 - name: "sdk: ${{ matrix.label }}" + name: "rust-e2e: ${{ matrix.test }}" steps: - - name: Check-out repository - uses: actions/checkout@v4 - with: - repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - ref: ${{ github.event.pull_request.head.ref || github.ref_name }} - - - name: Install uv - uses: astral-sh/setup-uv@v5 + - name: Download compiled Rust e2e binary + uses: actions/download-artifact@v4 with: - enable-cache: "true" - cache-dependency-glob: "sdk/python/uv.lock" - - - name: Install in-repo SDK - working-directory: sdk/python - run: | - uv sync --locked --all-extras --dev + name: bittensor-core-e2e + path: rust-e2e - name: Login to GHCR uses: docker/login-action@v3 @@ -329,31 +343,28 @@ jobs: run: docker tag "$LOCALNET_IMAGE_CI" "$LOCALNET_IMAGE" - name: Run with retry - working-directory: sdk/python env: SKIP_PULL: 1 + RUST_BACKTRACE: 1 + TEST_NAME: ${{ matrix.test }} run: | set +e + chmod +x rust-e2e/bittensor-core-e2e for i in 1 2; do - echo "Attempt $i: Running tests" - # -m e2e overrides the default addopts (-m 'not e2e') which would - # otherwise deselect the collected tests and fail with "no tests - # ran". The retry reruns only what failed (--lf reads pytest's - # cache from attempt 1) against a fresh localnet — the session - # fixture tears the container down and boots a new one per run. - if [ "$i" = "1" ]; then - uv run pytest "${{ matrix.nodeid }}" -s -m e2e - else - uv run pytest "${{ matrix.nodeid }}" -s -m e2e --lf - fi + echo "Attempt $i: Running $TEST_NAME" + rust-e2e/bittensor-core-e2e \ + "$TEST_NAME" \ + --exact \ + --nocapture \ + --test-threads=1 status=$? if [ $status -eq 0 ]; then - echo "Tests passed on attempt $i" + echo "Test passed on attempt $i" break else - echo "Tests failed on attempt $i" + echo "Test failed on attempt $i" if [ $i -eq 2 ]; then - echo "Tests failed after 2 attempts" + echo "Test failed after 2 attempts" exit 1 fi echo "Retrying..." diff --git a/.github/workflows/mainnet-clone-preview.yml b/.github/workflows/mainnet-clone-preview.yml index 4419a47786..fdbc96403c 100644 --- a/.github/workflows/mainnet-clone-preview.yml +++ b/.github/workflows/mainnet-clone-preview.yml @@ -154,7 +154,7 @@ jobs: '', `- [Open in polkadot.js apps](${appsUrl})`, '- Sudo key: `//Alice` (the clone is a throwaway — do not use real keys)', - '- SDK e2e against it: `E2E_ENDPOINT=' + wss + ' uv run pytest -m e2e`', + '- SDK drift gate against it: `cd sdk/python && uv run python -m codegen.check --drift ' + wss + '`', '', `Stays up while the \`mainnet-clone\` label is on this PR (max ${hours}h). ` + 'Pushing a new commit replaces it with a fresh clone of the new code; ' + diff --git a/.github/workflows/runtime-checks.yml b/.github/workflows/runtime-checks.yml index d398499a35..dbebf3f8bc 100644 --- a/.github/workflows/runtime-checks.yml +++ b/.github/workflows/runtime-checks.yml @@ -44,6 +44,8 @@ jobs: outputs: runtime: ${{ steps.filter.outputs.runtime }} docs: ${{ steps.filter.outputs.docs }} + python_sdk: ${{ steps.filter.outputs.python_sdk }} + sdk_drift: ${{ steps.filter.outputs.sdk_drift }} steps: # Plain gh-api file listing instead of a marketplace action: the org's # Actions allowlist rejects unlisted third-party actions (startup_failure). @@ -56,15 +58,23 @@ jobs: run: | set -euo pipefail files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') - runtime_pattern='^(common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor|clones|sdk)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^website/apps/bittensor-website/scripts/|^\.github/(workflows/runtime-checks\.yml|actions/rust-setup/)' + # SDK-only changes are covered by sdk-checks and the Rust SDK e2e + # workflow; they should not force clone-upgrade or SDK drift. + runtime_pattern='^(common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor|clones)/|^(Cargo\.toml|build\.rs|rust-toolchain\.toml)$|^website/apps/bittensor-website/scripts/|^\.github/(workflows/runtime-checks\.yml|actions/rust-setup/)' docs_pattern='^website/|^sdk/python/|^\.github/workflows/runtime-checks\.yml$' - runtime=false; docs=false + python_sdk_pattern='^sdk/(python|bittensor-core|bittensor-core-py|bittensor-core-wasm)/|^Cargo.lock$|^\.github/workflows/runtime-checks\.yml$' + sdk_drift_pattern='^(common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.toml|build\.rs|rust-toolchain\.toml)$' + runtime=false; docs=false; python_sdk=false; sdk_drift=false grep -qE "$runtime_pattern" <<< "$files" && runtime=true grep -qE "$docs_pattern" <<< "$files" && docs=true - echo "runtime=$runtime, docs=$docs" + grep -qE "$python_sdk_pattern" <<< "$files" && python_sdk=true + grep -qE "$sdk_drift_pattern" <<< "$files" && sdk_drift=true + echo "runtime=$runtime, docs=$docs, python_sdk=$python_sdk, sdk_drift=$sdk_drift" { echo "runtime=$runtime" echo "docs=$docs" + echo "python_sdk=$python_sdk" + echo "sdk_drift=$sdk_drift" } >> "$GITHUB_OUTPUT" # The single compile of this workflow. Two artifacts: @@ -275,7 +285,7 @@ jobs: sdk-checks: name: SDK offline checks needs: [trusted-pr, changes] - if: github.event_name != 'pull_request' || needs.changes.outputs.runtime == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.runtime == 'true' || needs.changes.outputs.python_sdk == 'true' runs-on: [self-hosted, fireactions-light] timeout-minutes: 30 steps: @@ -298,8 +308,8 @@ jobs: uv run python -m codegen.check --names # For every PR: sudo-upgrade a local clone of mainnet with the proposed - # runtime, then run the clone regression suite, the SDK metadata drift gate, - # and the SDK e2e suite against the upgraded chain. Consumes the + # runtime, then run the clone regression suite and the SDK metadata drift + # gate against the upgraded chain. Consumes the # node-release artifact instead of building, and restores the nightly # mainnet-snapshot artifact instead of re-scraping mainnet state (falls back # to a live scrape when no snapshot exists or the `fresh-mainnet-clone` @@ -453,17 +463,10 @@ jobs: run: uv sync --locked --all-extras --dev - name: Metadata drift gate (committed _generated vs upgraded clone) - if: ${{ matrix.shard.sdk }} + if: ${{ matrix.shard.sdk && (github.event_name != 'pull_request' || needs.changes.outputs.sdk_drift == 'true') }} working-directory: sdk/python run: uv run python -m codegen.check --drift ${{ env.WS_ENDPOINT }} - - name: SDK e2e tests against upgraded clone - if: ${{ matrix.shard.sdk }} - working-directory: sdk/python - env: - E2E_ENDPOINT: ws://127.0.0.1:9944 - run: uv run pytest -m e2e - - name: Dump clone node and harness logs if: failure() run: | diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index e2632d0618..81cb50bb30 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -43,7 +43,9 @@ jobs: run: | set -euo pipefail files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') - pattern='^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.(toml|lock)|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' + # SDK-only lockfile movement is covered by SDK checks; do not run + # zombienet unless chain/runtime or ts-tests inputs changed. + pattern='^(ts-tests|common|node|pallets|precompiles|primitives|runtime|support|chain-extensions|src|vendor)/|^(Cargo\.toml|build\.rs|rust-toolchain\.toml)$|^\.github/workflows/typescript-e2e\.yml$' if grep -qE "$pattern" <<< "$files"; then echo "e2e=true" >> "$GITHUB_OUTPUT" else diff --git a/Cargo.lock b/Cargo.lock index 14df26e2c0..97661d59cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1657,6 +1657,7 @@ dependencies = [ "sha2 0.10.9", "sodiumoxide", "sp-core", + "subtensor-macros", "tle", "twox-hash 2.1.2", "w3f-bls 0.1.3", diff --git a/README.md b/README.md index c32a690784..8f28f98106 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ just sync # uv environment; builds bittensor-core from ../bittensor-core-py ( just check # lint, typecheck, unit tests, codegen gates (same as CI) ``` -End-to-end tests against a running localnet: `just e2e`. See +Chain-facing SDK coverage lives in the Rust core e2e suite: +`cargo test -p bittensor-core --test e2e -- --nocapture`. See [SDK tests](https://bittensor.com/docs/internals/sdk-tests). ## Chain architecture (high level) diff --git a/clones/README.md b/clones/README.md index 521f978b0f..81f45eedeb 100644 --- a/clones/README.md +++ b/clones/README.md @@ -2,7 +2,7 @@ TypeScript regression tests (run via `tsx`) against a **local clone of mainnet state**, sudo-upgraded to the runtime built from this monorepo. CI runs the smoke test plus -`test:clone-regressions` in `check-clone-upgrade.yml` after every PR runtime +`test:clone-regressions` in `runtime-checks.yml` after every PR runtime upgrade. ## What happens diff --git a/docs/internals/mainnet-clone.mdx b/docs/internals/mainnet-clone.mdx index 08435f624c..29150a0782 100644 --- a/docs/internals/mainnet-clone.mdx +++ b/docs/internals/mainnet-clone.mdx @@ -3,12 +3,12 @@ title: Mainnet clone testing description: How CI sudo-upgrades a clone of live mainnet with your runtime, and how to reproduce it locally when it fails. --- -Every PR runs the **Clone Upgrade Check** (`check-clone-upgrade.yml`): it +Every PR runs the **Clone Upgrade Check** (`runtime-checks.yml`): it builds your proposed runtime, applies it to a local copy of *real mainnet -state* via a sudo upgrade, then runs regression tests and the Python SDK e2e -suite against the upgraded chain. This catches problems unit tests can't — -migrations that break on real storage, changed RPC shapes, SDK -incompatibilities — before anything ships. +state* via a sudo upgrade, then runs regression tests and the Python SDK +metadata drift gate against the upgraded chain. This catches problems unit +tests can't - migrations that break on real storage, changed RPC shapes, SDK +incompatibilities - before anything ships. This page explains what the check does and how to reproduce each step locally. @@ -28,8 +28,8 @@ This page explains what the check does and how to reproduce each step locally. and submits `sudo(system.setCode(...))` from Alice. The clone is now running mainnet state under **your** runtime, including any migrations it triggered. -4. **`npm test`** runs the smoke test; CI then runs the SDK's offline checks - and its e2e suite (`pytest -m e2e`) against the upgraded chain. +4. **`npm test`** runs the smoke test; CI then runs the SDK metadata drift + gate (`python -m codegen.check --drift`) against the upgraded chain. ## Reproducing locally @@ -64,13 +64,13 @@ To re-sync fresh mainnet state instead of reusing the cached spec, delete every start, so each run replays your upgrade from the cached state — restart the node to retry a failed upgrade from scratch. -### Running the SDK suites against the clone +### Running the SDK drift gate against the clone ```bash cd sdk/python uv sync --locked --all-extras --dev uv run pytest # offline unit tests -E2E_ENDPOINT=ws://127.0.0.1:9944 uv run pytest -m e2e +uv run python -m codegen.check --drift ws://127.0.0.1:9944 ``` ### Targeted regression tests diff --git a/docs/internals/repo-layout.mdx b/docs/internals/repo-layout.mdx index 13c2d8e14f..2c137c9a04 100644 --- a/docs/internals/repo-layout.mdx +++ b/docs/internals/repo-layout.mdx @@ -53,4 +53,4 @@ binding crate per language, and one product directory per language surface. | `scripts/` | CI and developer scripts — each one documented in [Repository scripts](/docs/internals/scripts) | | `.github/workflows/` | CI: PR checks, the [release train](/docs/internals/release-process), Docker publishing | | `Dockerfile` / `docker-compose.yml` | Production node image and compose services for running mainnet/testnet nodes | -| `Dockerfile-localnet` / `docker-compose.localnet.yml` | The localnet image used for [local development](/docs/guides/local-development) and SDK e2e tests | +| `Dockerfile-localnet` / `docker-compose.localnet.yml` | The localnet image used for [local development](/docs/guides/local-development) and Rust SDK e2e tests | diff --git a/docs/internals/sdk-tests.mdx b/docs/internals/sdk-tests.mdx index f4a1ea6d46..66ac81cf39 100644 --- a/docs/internals/sdk-tests.mdx +++ b/docs/internals/sdk-tests.mdx @@ -4,9 +4,9 @@ description: The SDK test layers, the fake-substrate harness, and how to regener --- The Python SDK lives in `sdk/python/` and is tested on every PR by the -[Clone Upgrade Check](/docs/internals/mainnet-clone): offline gates first, then -the e2e suite against a sudo-upgraded mainnet clone. A runtime change can fail -your PR through these gates even if you never touched Python — this page +[Runtime Checks](/docs/internals/testing): offline gates first, then a metadata +drift gate against a sudo-upgraded mainnet clone. A runtime change can fail +your PR through these gates even if you never touched Python - this page explains how to run them and what to regenerate when metadata changes. Everything runs through [uv](https://docs.astral.sh/uv/) against the locked @@ -35,12 +35,9 @@ uv sync --reinstall-package bittensor-core # or: maturin develop --release -m |-------|----------------|---------| | Unit tests (`tests/unit/`) | No | `just test` (or `uv run pytest`) | | Codegen static gates | No | `just codegen-static` | -| E2E tests (`tests/e2e/`) | Yes — localnet or clone | `just e2e [endpoint]` | | Codegen drift gate | Yes | `just drift [endpoint]` | -Tests marked `e2e` are **deselected by default** (`addopts = "-m 'not e2e'"` -in `pyproject.toml`), so a bare `pytest` run is always offline-safe. Run a -single test the usual pytest way: +The Python test tree is offline-only. Run a single test the usual pytest way: ```bash uv run pytest tests/unit/test_codec_golden.py -k storage_keys -x @@ -73,19 +70,17 @@ Two mechanisms make the offline suite possible: Re-record with `scripts/record_shape_corpus.py` only when the contract itself is meant to change. -## E2E tests +## Chain-facing SDK coverage -`tests/e2e/` exercises real chain behavior (staking, governance, multisig, -proxies, subnet lifecycle). The conftest picks its chain in order: +The old Python localnet e2e tests were migrated to Rust and live in +`sdk/bittensor-core/tests/e2e.rs`. To run them against an existing node: -1. `E2E_ENDPOINT` set → attach to that node. This is the dev inner loop: run a - [localnet](/docs/guides/local-development) yourself and iterate without - docker churn. `just e2e` defaults to `ws://127.0.0.1:9944`. -2. Otherwise → start a disposable docker localnet (override the image with - `LOCALNET_IMAGE`). +```bash +E2E_ENDPOINT=ws://127.0.0.1:9944 cargo test -p bittensor-core --test e2e -- --nocapture +``` -CI runs the same suite with `E2E_ENDPOINT` pointed at the upgraded mainnet -clone. +Without `E2E_ENDPOINT`, the Rust harness starts a disposable localnet Docker +container from `LOCALNET_IMAGE`. ## After a runtime change: what to regenerate diff --git a/docs/internals/testing.mdx b/docs/internals/testing.mdx index ec3ca1e960..fca3f60035 100644 --- a/docs/internals/testing.mdx +++ b/docs/internals/testing.mdx @@ -11,8 +11,9 @@ how to reproduce each locally saves round-trips: | Rust unit tests | Pallet and runtime logic | `cargo test --workspace` (below) | `check-rust.yml` | | TypeScript / Moonwall | End-to-end chain behavior, EVM, zombienet multi-node | `ts-tests/` (below) | `typescript-e2e.yml` | | Migration checks | `on_runtime_upgrade` against live state | try-runtime CLI (below) | `try-runtime.yml` | -| [Mainnet clone](/docs/internals/mainnet-clone) | Runtime upgrade + regression tests on cloned mainnet state | `clones/` scripts | `check-clone-upgrade.yml` | -| [Python SDK](/docs/internals/sdk-tests) | SDK unit + e2e tests, codegen drift gates | `cd sdk/python && just check` | `check-clone-upgrade.yml` | +| [Mainnet clone](/docs/internals/mainnet-clone) | Runtime upgrade + regression tests on cloned mainnet state | `clones/` scripts | `runtime-checks.yml` | +| [Python SDK](/docs/internals/sdk-tests) | SDK unit tests and codegen drift gates | `cd sdk/python && just check` | `runtime-checks.yml` | +| Rust SDK e2e | Chain-facing SDK behavior against localnet | `cargo test -p bittensor-core --test e2e` | `check-bittensor-e2e-tests.yml` | | [eco-tests](/docs/internals/eco-tests) | Storage/RPC shapes the TAO.com indexer depends on | `cd eco-tests && cargo test` | `eco-tests.yml` | ## Rust tests @@ -153,8 +154,8 @@ pnpm moonwall run zombienet_staking your runtime, and runs JS regression tests against it. Full walkthrough: [Mainnet clone testing](/docs/internals/mainnet-clone). - **Python SDK** (`sdk/python/`): `just sync && just check` runs the same - offline gates as CI; e2e tests need a running localnet, and runtime metadata - changes require regenerating the codegen bindings. Full walkthrough: + offline gates as CI; runtime metadata changes require regenerating the + codegen bindings. Full walkthrough: [Python SDK tests](/docs/internals/sdk-tests). - **eco-tests** (`eco-tests/`): excluded from the cargo workspace, so run them from their own directory: `cd eco-tests && cargo test`. They pin the storage diff --git a/sdk/bittensor-core-py/src/errors.rs b/sdk/bittensor-core-py/src/errors.rs index 7f1e8b1b7b..f140f53b6b 100644 --- a/sdk/bittensor-core-py/src/errors.rs +++ b/sdk/bittensor-core-py/src/errors.rs @@ -1,7 +1,7 @@ //! The one place `CoreError` becomes a Python exception. use bittensor_core::CoreError; -use pyo3::exceptions::{PyKeyError, PyValueError}; +use pyo3::exceptions::{PyConnectionError, PyKeyError, PyPermissionError, PyValueError}; use pyo3::prelude::*; pyo3::create_exception!( @@ -33,5 +33,7 @@ pub fn to_py_err(error: CoreError) -> PyErr { CoreError::NotInRuntime(what) => PyKeyError::new_err(what), CoreError::Codec(msg) | CoreError::Crypto(msg) => PyValueError::new_err(msg), CoreError::Device(msg) => LedgerError::new_err(msg), + CoreError::Rpc(msg) => PyConnectionError::new_err(msg), + CoreError::Policy(msg) => PyPermissionError::new_err(msg), } } diff --git a/sdk/bittensor-core-wasm/src/errors.rs b/sdk/bittensor-core-wasm/src/errors.rs index dbd255fa9c..6fed1c64a4 100644 --- a/sdk/bittensor-core-wasm/src/errors.rs +++ b/sdk/bittensor-core-wasm/src/errors.rs @@ -16,6 +16,8 @@ pub fn to_js_err(err: CoreError) -> JsValue { // (no `ledger` feature here) but kept aligned for when a WebHID // signer backend lands. CoreError::Device(_) => "LedgerError", + CoreError::Rpc(_) => "RpcError", + CoreError::Policy(_) => "PolicyError", }; let error = js_sys::Error::new(&err.to_string()); error.set_name(name); diff --git a/sdk/bittensor-core/Cargo.toml b/sdk/bittensor-core/Cargo.toml index 6cb6975992..1a984794be 100644 --- a/sdk/bittensor-core/Cargo.toml +++ b/sdk/bittensor-core/Cargo.toml @@ -10,6 +10,13 @@ license = "Apache-2.0" name = "bittensor_core" crate-type = ["rlib"] +# External localnet suite. It is selected explicitly by the dedicated E2E +# workflow; `test = false` keeps ordinary workspace/nextest runs unit-only. +[[test]] +name = "e2e" +path = "tests/e2e.rs" +test = false + [dependencies] # keys / keyfiles (absorbed from py-sp-core). The optional crates below are # host-only (see the `host` feature): they either do I/O or compile C, both @@ -68,6 +75,7 @@ merkleized-metadata = "0.5.1" # workers (codec/batch.rs). rayon = { version = "1.10", optional = true } scale-info = { workspace = true, features = ["std", "serde"] } +subtensor-macros = { workspace = true } # signers (feature "ledger"): APDU types for the Polkadot generic app; the # HID transport itself lives in signers/ledger.rs on top of hidapi (below). diff --git a/sdk/bittensor-core/src/client.rs b/sdk/bittensor-core/src/client.rs new file mode 100644 index 0000000000..5919fa9c03 --- /dev/null +++ b/sdk/bittensor-core/src/client.rs @@ -0,0 +1,1659 @@ +//! Native chain access for the Rust SDK. +//! +//! The codec/runtime modules deliberately remain transport agnostic. `Client` +//! is the small blocking JSON-RPC layer that turns their metadata-driven SCALE +//! primitives into pinned reads, signed submissions, receipts, and block +//! streams. It uses the crate's existing rustls-backed `reqwest` dependency, so +//! the native SDK adds no second async runtime and works in ordinary Rust tests, +//! command-line programs, and foreign-language bindings alike. + +#![allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] + +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::{Duration, Instant}; + +use codec::Decode; +use reqwest::blocking::Client as HttpClient; +use serde_json::{json, Value as JsonValue}; + +use crate::codec::extrinsic::{era_birth, TxParams}; +use crate::codec::value::Value; +use crate::error::CoreError; +use crate::keys::Keypair; +use crate::mlkem; +use crate::runtime::type_string::TypeSpec; +use crate::runtime::{Runtime, RuntimeApiMethodInfo, StorageInfo}; + +const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_RECEIPT_TIMEOUT: Duration = Duration::from_secs(120); +const DEFAULT_ERA_PERIOD: u64 = 64; +const STORAGE_PAGE_SIZE: u64 = 1_000; +const RAO_PER_TAO: u128 = 1_000_000_000; + +/// A decoded block header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockHeader { + pub hash: String, + pub parent_hash: String, + pub number: u64, +} + +/// One subnet's small, commonly used read model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubnetInfo { + pub netuid: u16, + pub tempo: u16, + pub burn_rao: u128, + pub neuron_count: u16, +} + +/// Swap simulation returned by the runtime API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SwapQuote { + pub tao_amount: u128, + pub alpha_amount: u128, + pub tao_fee: u128, + pub alpha_fee: u128, + pub tao_slippage: u128, + pub alpha_slippage: u128, +} + +/// A normalized dispatch failure. `semantic_code` is stable across wording +/// changes and is suitable for branching in callers and e2e assertions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DispatchError { + pub pallet: Option, + pub name: String, + pub docs: Vec, + pub semantic_code: String, +} + +impl std::fmt::Display for DispatchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.pallet { + Some(pallet) => write!(f, "{pallet}.{}", self.name), + None => write!(f, "{}", self.name), + } + } +} + +/// Inclusion/finalization result for a submitted extrinsic. +#[derive(Debug, Clone)] +pub struct TxOutcome { + pub success: bool, + pub extrinsic_hash: String, + pub block_hash: Option, + pub block_number: Option, + pub extrinsic_index: Option, + pub fee_rao: Option, + pub events: Vec, + pub error: Option, + pub message: String, + pub data: BTreeMap, +} + +impl TxOutcome { + fn pool_rejection(hash: String, message: String) -> Self { + Self { + success: false, + extrinsic_hash: hash, + block_hash: None, + block_number: None, + extrinsic_index: None, + fee_rao: None, + events: Vec::new(), + error: None, + message, + data: BTreeMap::new(), + } + } +} + +// Reorg-safe receipt tracking states. An inclusion is only final when the exact +// inclusion block is still canonical after finality reaches its height. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InclusionFinalization { + Finalized, + Reorged, +} + +fn classify_inclusion_finalization( + inclusion_hash: &str, + canonical_hash: Option<&str>, + included_at: u64, + finalized_at: u64, +) -> Option { + match canonical_hash { + Some(canonical) if canonical.eq_ignore_ascii_case(inclusion_hash) => { + (finalized_at >= included_at).then_some(InclusionFinalization::Finalized) + } + _ => Some(InclusionFinalization::Reorged), + } +} + +/// The native Bittensor chain client. +pub struct Client { + endpoint: String, + http: HttpClient, + next_id: AtomicU64, + runtime: RwLock>, + genesis_hash: [u8; 32], + ss58_format: u16, +} + +impl Client { + /// Connect to a Substrate JSON-RPC endpoint. Websocket URLs are accepted for + /// compatibility and are mapped to HTTP on the same host/port. + pub fn connect(endpoint: impl Into) -> Result { + let endpoint = http_endpoint(&endpoint.into()); + let http = HttpClient::builder() + .timeout(DEFAULT_RPC_TIMEOUT) + .build() + .map_err(|error| CoreError::Rpc(format!("cannot build HTTP client: {error}")))?; + let bootstrap = RpcBootstrap { + endpoint: endpoint.clone(), + http: http.clone(), + next_id: AtomicU64::new(1), + }; + let ss58_format = bootstrap.ss58_format()?; + let version = bootstrap.runtime_version()?; + let metadata = bootstrap.metadata()?; + let genesis_hash = parse_h256(&bootstrap.block_hash(Some(0))?)?; + let runtime = Runtime::parse( + &metadata, + version.spec_version, + version.transaction_version, + ss58_format, + )?; + Ok(Self { + endpoint, + http, + next_id: AtomicU64::new(100), + runtime: RwLock::new(Arc::new(runtime)), + genesis_hash, + ss58_format, + }) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// Stable names of the typed read helpers supplied by the native SDK. + /// Generic [`Client::query`], [`Client::query_map`], and + /// [`Client::runtime_call`] remain available for the full live metadata + /// surface. + pub fn read_catalog(&self) -> &'static [&'static str] { + &[ + "balance", + "existential_deposit", + "subnets", + "metagraph", + "neurons", + "subnet_hyperparameters", + "stake", + "quote_stake", + "block_number", + "block_time", + "lease", + "leases", + "mev_shield_next_key", + "proxies", + "multisig", + "identity", + "subnet_identity", + "children", + "root_claim_type", + "auto_stake", + ] + } + + pub fn ss58_format(&self) -> u16 { + self.ss58_format + } + + pub fn genesis_hash(&self) -> [u8; 32] { + self.genesis_hash + } + + pub fn runtime(&self) -> Result, CoreError> { + self.runtime + .read() + .map_err(|_| CoreError::Rpc("runtime metadata lock is poisoned".into())) + .map(|runtime| Arc::clone(&runtime)) + } + + /// Refresh metadata after a runtime upgrade. Returns true when the cached + /// runtime changed. + pub fn refresh_runtime(&self) -> Result { + let version = self.runtime_version()?; + let current = self.runtime()?; + if current.spec_version == version.spec_version + && current.transaction_version == version.transaction_version + { + return Ok(false); + } + let metadata = self.metadata()?; + let next = Runtime::parse( + &metadata, + version.spec_version, + version.transaction_version, + self.ss58_format, + )?; + let mut guard = self + .runtime + .write() + .map_err(|_| CoreError::Rpc("runtime metadata lock is poisoned".into()))?; + *guard = Arc::new(next); + Ok(true) + } + + /// Escape hatch for node RPCs not yet wrapped by the SDK. + pub fn rpc_value(&self, method: &str, params: JsonValue) -> Result { + rpc_request( + &self.http, + &self.endpoint, + self.next_id.fetch_add(1, Ordering::Relaxed), + method, + params, + ) + } + + fn runtime_version(&self) -> Result { + parse_runtime_version(self.rpc_value("state_getRuntimeVersion", json!([]))?) + } + + fn metadata(&self) -> Result, CoreError> { + fetch_metadata(|method, params| self.rpc_value(method, params)) + } + + pub fn block_hash(&self, block: Option) -> Result { + let params = block.map_or_else(|| json!([]), |number| json!([number])); + let value = self.rpc_value("chain_getBlockHash", params)?; + Ok(json_string(&value, "chain_getBlockHash result")?.to_string()) + } + + fn canonical_block_hash(&self, block: u64) -> Result, CoreError> { + let value = self.rpc_value("chain_getBlockHash", json!([block]))?; + match value { + JsonValue::Null => Ok(None), + JsonValue::String(hash) => Ok(Some(hash)), + other => Err(CoreError::Rpc(format!( + "chain_getBlockHash returned {other}, expected a hash or null" + ))), + } + } + + pub fn finalized_head(&self) -> Result { + let value = self.rpc_value("chain_getFinalizedHead", json!([]))?; + Ok(json_string(&value, "chain_getFinalizedHead result")?.to_string()) + } + + pub fn header(&self, block_hash: Option<&str>) -> Result { + let params = block_hash.map_or_else(|| json!([]), |hash| json!([hash])); + let value = self.rpc_value("chain_getHeader", params)?; + let object = value + .as_object() + .ok_or_else(|| CoreError::Rpc("chain_getHeader returned a non-object".into()))?; + let number = json_u64( + object + .get("number") + .ok_or_else(|| CoreError::Rpc("header has no number".into()))?, + )?; + let hash = match block_hash { + Some(hash) => hash.to_string(), + None => self.block_hash(Some(number))?, + }; + let parent_hash = object + .get("parentHash") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .to_string(); + Ok(BlockHeader { + hash, + parent_hash, + number, + }) + } + + pub fn block_number(&self) -> Result { + self.header(None).map(|header| header.number) + } + + pub fn block_time(&self) -> Result { + let millis = as_u128(&self.constant("Aura", "SlotDuration")?) + .ok_or_else(|| CoreError::Codec("Aura.SlotDuration is not an integer".into()))?; + let millis = u64::try_from(millis) + .map_err(|_| CoreError::Codec("Aura.SlotDuration does not fit u64".into()))?; + Ok(Duration::from_millis(millis.max(1))) + } + + /// Polling block stream. HTTP and websocket endpoints therefore expose the + /// same API; callers do not need an async runtime merely to follow heads. + pub fn blocks(&self, finalized: bool) -> BlockStream<'_> { + BlockStream { + client: self, + finalized, + last_number: None, + poll_interval: Duration::from_millis(50), + } + } + + pub fn at(&self, block: Option) -> Result, CoreError> { + let hash = self.block_hash(block)?; + let number = self.header(Some(&hash))?.number; + Ok(Snapshot { + client: self, + block_hash: hash, + block_number: number, + }) + } + + pub fn compose_call( + &self, + pallet: &str, + function: &str, + params: &Value, + ) -> Result, CoreError> { + self.runtime()?.compose_call(pallet, function, params) + } + + pub fn decode_call(&self, data: &[u8]) -> Result { + self.runtime()?.decode_spec(&TypeSpec::Call, data, true) + } + + pub fn decode_scale(&self, type_name: &str, data: &[u8]) -> Result { + let runtime = self.runtime()?; + if type_name == "Call" { + return runtime.decode_spec(&TypeSpec::Call, data, true); + } + let type_id = runtime + .type_id_of(type_name) + .ok_or_else(|| CoreError::NotInRuntime(format!("type {type_name}")))?; + runtime.decode_spec(&TypeSpec::Id(type_id), data, true) + } + + pub fn constant(&self, pallet: &str, name: &str) -> Result { + let runtime = self.runtime()?; + let info = runtime + .constant(pallet, name) + .cloned() + .ok_or_else(|| CoreError::NotInRuntime(format!("constant {pallet}.{name}")))?; + runtime.decode_spec(&TypeSpec::Id(info.ty), &info.value, true) + } + + pub fn query( + &self, + pallet: &str, + storage: &str, + params: &[Value], + block_hash: Option<&str>, + ) -> Result { + let runtime = self.runtime()?; + let info = storage_info(&runtime, pallet, storage)?; + let key = runtime.storage_key(&info, params)?; + let raw = self.storage_raw(&key, block_hash)?; + decode_storage(&runtime, &info, raw.as_deref()) + } + + pub fn query_batch( + &self, + pallet: &str, + storage: &str, + param_sets: &[Vec], + block_hash: Option<&str>, + ) -> Result, CoreError> { + if param_sets.is_empty() { + return Ok(Vec::new()); + } + let runtime = self.runtime()?; + let info = storage_info(&runtime, pallet, storage)?; + let mut keys = Vec::with_capacity(param_sets.len()); + for params in param_sets { + keys.push(runtime.storage_key(&info, params)?); + } + let values = self.storage_batch_raw(&keys, block_hash)?; + values + .iter() + .map(|raw| decode_storage(&runtime, &info, raw.as_deref())) + .collect() + } + + pub fn query_map( + &self, + pallet: &str, + storage: &str, + fixed_params: &[Value], + block_hash: Option<&str>, + ) -> Result, CoreError> { + let runtime = self.runtime()?; + let info = storage_info(&runtime, pallet, storage)?; + if fixed_params.len() > info.key_types.len() { + return Err(CoreError::Codec(format!( + "{pallet}.{storage} has {} key parts, got {} fixed params", + info.key_types.len(), + fixed_params.len() + ))); + } + let prefix = runtime.storage_key(&info, fixed_params)?; + let keys = self.storage_keys_paged(&prefix, block_hash)?; + let raws = self.storage_batch_raw(&keys, block_hash)?; + let mut rows = Vec::with_capacity(keys.len()); + for (key, raw) in keys.iter().zip(raws.iter()) { + let decoded_key = runtime.decode_storage_key_params(&info, key, fixed_params.len())?; + let key_value = match decoded_key.as_slice() { + [] => Value::Tuple(Vec::new()), + [one] => one.clone(), + many => Value::Tuple(many.to_vec()), + }; + rows.push((key_value, decode_storage(&runtime, &info, raw.as_deref())?)); + } + Ok(rows) + } + + pub fn runtime_call( + &self, + api: &str, + method: &str, + params: &[Value], + block_hash: Option<&str>, + ) -> Result { + let runtime = self.runtime()?; + let method_info = runtime_api_method(&runtime, api, method)?.clone(); + if method_info.inputs.len() != params.len() { + return Err(CoreError::Codec(format!( + "{api}.{method} expects {} params, got {}", + method_info.inputs.len(), + params.len() + ))); + } + let mut encoded = Vec::new(); + for (input, value) in method_info.inputs.iter().zip(params.iter()) { + runtime.encode_id(input.ty, value, &mut encoded)?; + } + let mut rpc_params = vec![ + JsonValue::String(format!("{api}_{method}")), + JsonValue::String(hex_prefixed(&encoded)), + ]; + if let Some(hash) = block_hash { + rpc_params.push(JsonValue::String(hash.to_string())); + } + let result = self.rpc_value("state_call", JsonValue::Array(rpc_params))?; + let bytes = decode_hex(json_string(&result, "state_call result")?)?; + runtime.decode_spec(&TypeSpec::Id(method_info.output), &bytes, true) + } + + pub fn account_next_index(&self, address: &str) -> Result { + let value = self.rpc_value("system_accountNextIndex", json!([address]))?; + json_u64(&value) + } + + /// Sign without submitting. Returns `(encoded extrinsic, 0x hash)`. + pub fn sign_extrinsic( + &self, + call_data: &[u8], + signer: &Keypair, + nonce: u64, + period: Option, + ) -> Result<(Vec, String), CoreError> { + let runtime = self.runtime()?; + let current = self.block_number()?; + let (era, era_block_hash) = match period { + Some(period) if period > 0 => { + let birth = era_birth(period, current); + let hash = parse_h256(&self.block_hash(Some(birth))?)?; + ( + Value::record(vec![ + ("period".into(), Value::Uint(u128::from(period))), + ("current".into(), Value::Uint(u128::from(current))), + ]), + hash, + ) + } + _ => (Value::str("00"), self.genesis_hash), + }; + let params = TxParams { + era, + nonce, + tip: 0, + tip_asset_id: None, + genesis_hash: self.genesis_hash, + era_block_hash, + metadata_hash: None, + }; + let payload = runtime.signature_payload(call_data, ¶ms)?; + let signature = signer.sign(&payload)?; + let (extrinsic, hash) = runtime.encode_signed_extrinsic( + call_data, + signer.public_key_bytes(), + &signature, + signer.crypto_type(), + ¶ms, + )?; + Ok((extrinsic, hex_prefixed(&hash))) + } + + pub fn estimate_fee(&self, call_data: &[u8], signer: &Keypair) -> Result { + let nonce = self.account_next_index(&signer.ss58_address())?; + let (extrinsic, _) = + self.sign_extrinsic(call_data, signer, nonce, Some(DEFAULT_ERA_PERIOD))?; + let value = self.rpc_value("payment_queryInfo", json!([hex_prefixed(&extrinsic)]))?; + let object = value + .as_object() + .ok_or_else(|| CoreError::Rpc("payment_queryInfo returned a non-object".into()))?; + let fee = object + .get("partialFee") + .or_else(|| object.get("partial_fee")) + .ok_or_else(|| CoreError::Rpc("payment_queryInfo omitted partialFee".into()))?; + json_u128(fee) + } + + pub fn estimate_weight(&self, call_data: &[u8], signer: &Keypair) -> Result { + let nonce = self.account_next_index(&signer.ss58_address())?; + let (extrinsic, _) = + self.sign_extrinsic(call_data, signer, nonce, Some(DEFAULT_ERA_PERIOD))?; + let value = self.rpc_value("payment_queryInfo", json!([hex_prefixed(&extrinsic)]))?; + let weight = value + .as_object() + .and_then(|object| object.get("weight")) + .ok_or_else(|| CoreError::Rpc("payment_queryInfo omitted weight".into()))?; + json_to_value(weight) + } + + pub fn submit( + &self, + call_data: &[u8], + signer: &Keypair, + nonce: Option, + period: Option, + wait_for_finalization: bool, + ) -> Result { + let nonce = match nonce { + Some(nonce) => nonce, + None => self.account_next_index(&signer.ss58_address())?, + }; + let (extrinsic, hash) = self.sign_extrinsic(call_data, signer, nonce, period)?; + self.submit_encoded(&extrinsic, hash, wait_for_finalization) + } + + pub fn submit_encoded( + &self, + extrinsic: &[u8], + expected_hash: String, + wait_for_finalization: bool, + ) -> Result { + let start_block = self.block_number()?; + let xt_hex = hex_prefixed(extrinsic); + let submitted = match self.rpc_value("author_submitExtrinsic", json!([xt_hex])) { + Ok(value) => value, + Err(error) => { + return Ok(TxOutcome::pool_rejection(expected_hash, error.to_string())); + } + }; + let submitted_hash = submitted + .as_str() + .map_or(expected_hash, ToString::to_string); + self.wait_for_inclusion( + extrinsic, + submitted_hash, + start_block, + wait_for_finalization, + DEFAULT_RECEIPT_TIMEOUT, + ) + } + + /// Full MEV-shield pipeline: sign the inner call at nonce+1, seal it with + /// the current ML-KEM-768 key, then submit the carrier at nonce. + pub fn submit_shielded( + &self, + call_data: &[u8], + signer: &Keypair, + wait_for_finalization: bool, + ) -> Result { + let next_key = self.query("MevShield", "NextKey", &[], None)?; + let public_key = value_bytes(&next_key) + .ok_or_else(|| CoreError::Rpc("MevShield.NextKey is unavailable".into()))?; + let nonce = self.account_next_index(&signer.ss58_address())?; + let (inner, inner_hash) = + self.sign_extrinsic(call_data, signer, nonce + 1, Some(DEFAULT_ERA_PERIOD))?; + let ciphertext = mlkem::seal(&public_key, &inner, true)?; + let outer = self.compose_call( + "MevShield", + "submit_encrypted", + &Value::record(vec![("ciphertext".into(), Value::Bytes(ciphertext))]), + )?; + let mut result = self.submit( + &outer, + signer, + Some(nonce), + Some(DEFAULT_ERA_PERIOD), + wait_for_finalization, + )?; + if result.success { + result.data.insert("shielded".into(), Value::Bool(true)); + result + .data + .insert("inner_extrinsic_hash".into(), Value::str(inner_hash)); + } + Ok(result) + } + + pub fn balance_rao(&self, address: &str) -> Result { + let account = self.query("System", "Account", &[Value::str(address)], None)?; + field(&account, "data") + .and_then(|data| field(data, "free")) + .and_then(as_u128) + .ok_or_else(|| CoreError::Codec("System.Account.data.free is missing".into())) + } + + pub fn existential_deposit_rao(&self) -> Result { + as_u128(&self.constant("Balances", "ExistentialDeposit")?) + .ok_or_else(|| CoreError::Codec("ExistentialDeposit is not an integer".into())) + } + + pub fn subnets(&self, block_hash: Option<&str>) -> Result, CoreError> { + let added = self.query_map("SubtensorModule", "NetworksAdded", &[], block_hash)?; + let tempos = value_map_u16(self.query_map("SubtensorModule", "Tempo", &[], block_hash)?); + let burns = value_map_u128(self.query_map("SubtensorModule", "Burn", &[], block_hash)?); + let counts = + value_map_u16(self.query_map("SubtensorModule", "SubnetworkN", &[], block_hash)?); + let mut out = Vec::new(); + for (key, value) in added { + if !matches!(value, Value::Bool(true)) { + continue; + } + let Some(netuid) = as_u128(&key).and_then(|n| u16::try_from(n).ok()) else { + continue; + }; + out.push(SubnetInfo { + netuid, + tempo: tempos.get(&netuid).copied().unwrap_or(0), + burn_rao: burns.get(&netuid).copied().unwrap_or(0), + neuron_count: counts.get(&netuid).copied().unwrap_or(0), + }); + } + out.sort_by_key(|subnet| subnet.netuid); + Ok(out) + } + + pub fn metagraph(&self, netuid: u16, block_hash: Option<&str>) -> Result { + self.runtime_call( + "SubnetInfoRuntimeApi", + "get_metagraph", + &[Value::Uint(u128::from(netuid))], + block_hash, + ) + } + + /// Typed fast-path for the runtime's lite-neuron collection. + pub fn neurons(&self, netuid: u16, block_hash: Option<&str>) -> Result, CoreError> { + match self.runtime_call( + "NeuronInfoRuntimeApi", + "get_neurons_lite", + &[Value::Uint(u128::from(netuid))], + block_hash, + )? { + Value::List(neurons) => Ok(neurons), + other => Err(CoreError::Codec(format!( + "NeuronInfoRuntimeApi.get_neurons_lite returned {other:?}, expected a list" + ))), + } + } + + pub fn subnet_hyperparameters( + &self, + netuid: u16, + block_hash: Option<&str>, + ) -> Result { + self.runtime_call( + "SubnetInfoRuntimeApi", + "get_subnet_hyperparams", + &[Value::Uint(u128::from(netuid))], + block_hash, + ) + } + + pub fn stake_rao( + &self, + coldkey: &str, + hotkey: &str, + netuid: u16, + block_hash: Option<&str>, + ) -> Result { + let info = self.runtime_call( + "StakeInfoRuntimeApi", + "get_stake_info_for_hotkey_coldkey_netuid", + &[ + Value::str(hotkey), + Value::str(coldkey), + Value::Uint(u128::from(netuid)), + ], + block_hash, + )?; + if matches!(info, Value::Null) { + return Ok(0); + } + field(&info, "stake") + .and_then(as_u128) + .ok_or_else(|| CoreError::Codec("stake runtime API omitted stake".into())) + } + + pub fn quote_stake( + &self, + netuid: u16, + amount_rao: u128, + block_hash: Option<&str>, + ) -> Result { + let quote = self.runtime_call( + "SwapRuntimeApi", + "sim_swap_tao_for_alpha", + &[Value::Uint(u128::from(netuid)), Value::Uint(amount_rao)], + block_hash, + )?; + Ok(SwapQuote { + tao_amount: required_u128("e, "tao_amount")?, + alpha_amount: required_u128("e, "alpha_amount")?, + tao_fee: required_u128("e, "tao_fee")?, + alpha_fee: required_u128("e, "alpha_fee")?, + tao_slippage: required_u128("e, "tao_slippage")?, + alpha_slippage: required_u128("e, "alpha_slippage")?, + }) + } + + pub fn tao(value: u128) -> Result { + value + .checked_mul(RAO_PER_TAO) + .ok_or_else(|| CoreError::Codec("TAO amount overflows u128".into())) + } + + fn storage_raw( + &self, + key: &[u8], + block_hash: Option<&str>, + ) -> Result, CoreError> { + let mut params = vec![JsonValue::String(hex_prefixed(key))]; + if let Some(hash) = block_hash { + params.push(JsonValue::String(hash.to_string())); + } + let value = self.rpc_value("state_getStorage", JsonValue::Array(params))?; + match value { + JsonValue::Null => Ok(None), + JsonValue::String(raw) => Ok(Some(raw)), + _ => Err(CoreError::Rpc( + "state_getStorage returned neither hex nor null".into(), + )), + } + } + + fn storage_keys_paged( + &self, + prefix: &[u8], + block_hash: Option<&str>, + ) -> Result>, CoreError> { + let prefix_hex = hex_prefixed(prefix); + let mut all = Vec::new(); + let mut start: Option = None; + loop { + let mut params = vec![ + JsonValue::String(prefix_hex.clone()), + JsonValue::from(STORAGE_PAGE_SIZE), + start + .as_ref() + .map_or(JsonValue::Null, |key| JsonValue::String(key.clone())), + ]; + if let Some(hash) = block_hash { + params.push(JsonValue::String(hash.to_string())); + } + let value = self.rpc_value("state_getKeysPaged", JsonValue::Array(params))?; + let page = value + .as_array() + .ok_or_else(|| CoreError::Rpc("state_getKeysPaged returned a non-array".into()))?; + if page.is_empty() { + break; + } + let mut last = None; + for item in page { + let key = json_string(item, "storage key")?.to_string(); + all.push(decode_hex(&key)?); + last = Some(key); + } + if page.len() < usize::try_from(STORAGE_PAGE_SIZE).unwrap_or(usize::MAX) { + break; + } + if last == start { + return Err(CoreError::Rpc( + "state_getKeysPaged repeated its cursor".into(), + )); + } + start = last; + } + Ok(all) + } + + fn storage_batch_raw( + &self, + keys: &[Vec], + block_hash: Option<&str>, + ) -> Result>, CoreError> { + if keys.is_empty() { + return Ok(Vec::new()); + } + let key_hex: Vec = keys.iter().map(|key| hex_prefixed(key)).collect(); + let mut params = vec![json!(key_hex)]; + if let Some(hash) = block_hash { + params.push(JsonValue::String(hash.to_string())); + } + let value = self.rpc_value("state_queryStorageAt", JsonValue::Array(params))?; + let sets = value + .as_array() + .ok_or_else(|| CoreError::Rpc("state_queryStorageAt returned a non-array".into()))?; + let changes = sets + .first() + .and_then(JsonValue::as_object) + .and_then(|set| set.get("changes")) + .and_then(JsonValue::as_array) + .ok_or_else(|| CoreError::Rpc("state_queryStorageAt omitted changes".into()))?; + let mut by_key: HashMap> = HashMap::new(); + for change in changes { + let pair = change + .as_array() + .ok_or_else(|| CoreError::Rpc("storage change is not a pair".into()))?; + let key = pair + .first() + .and_then(JsonValue::as_str) + .ok_or_else(|| CoreError::Rpc("storage change has no key".into()))?; + let raw = pair + .get(1) + .and_then(JsonValue::as_str) + .map(ToString::to_string); + by_key.insert(key.to_ascii_lowercase(), raw); + } + Ok(keys + .iter() + .map(|key| { + by_key + .remove(&hex_prefixed(key).to_ascii_lowercase()) + .flatten() + }) + .collect()) + } + + fn wait_for_inclusion( + &self, + extrinsic: &[u8], + hash: String, + start_block: u64, + wait_for_finalization: bool, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + let xt_hex = hex_prefixed(extrinsic).to_ascii_lowercase(); + let mut next_block = start_block; + let poll = self + .block_time() + .unwrap_or_else(|_| Duration::from_millis(250)) + .min(Duration::from_secs(1)); + 'track_inclusion: while Instant::now() < deadline { + let head = self.block_number()?; + while next_block <= head { + let included_at = next_block; + let block_hash = self.block_hash(Some(included_at))?; + if let Some(index) = self.find_extrinsic(&block_hash, &xt_hex)? { + if wait_for_finalization { + match self.wait_until_finalized(&block_hash, included_at, deadline, poll)? { + InclusionFinalization::Finalized => {} + InclusionFinalization::Reorged => { + // The old inclusion block is no longer canonical. + // Rescan from its height so a re-inclusion at the + // same or a later height can still produce a receipt. + next_block = included_at; + continue 'track_inclusion; + } + } + } + return self.decode_outcome(hash, block_hash, included_at, index); + } + next_block = next_block.saturating_add(1); + } + thread::sleep(poll); + } + Ok(TxOutcome::pool_rejection( + hash, + format!("extrinsic was not included within {}s", timeout.as_secs()), + )) + } + + fn find_extrinsic(&self, block_hash: &str, wanted: &str) -> Result, CoreError> { + let block = self.rpc_value("chain_getBlock", json!([block_hash]))?; + let extrinsics = block + .as_object() + .and_then(|object| object.get("block")) + .and_then(JsonValue::as_object) + .and_then(|inner| inner.get("extrinsics")) + .and_then(JsonValue::as_array) + .ok_or_else(|| CoreError::Rpc("chain_getBlock omitted extrinsics".into()))?; + for (index, extrinsic) in extrinsics.iter().enumerate() { + if extrinsic + .as_str() + .is_some_and(|raw| raw.eq_ignore_ascii_case(wanted)) + { + return u32::try_from(index) + .map(Some) + .map_err(|_| CoreError::Rpc("extrinsic index exceeds u32".into())); + } + } + Ok(None) + } + + fn wait_until_finalized( + &self, + inclusion_hash: &str, + included_at: u64, + deadline: Instant, + poll: Duration, + ) -> Result { + while Instant::now() < deadline { + // Fetch finality first, then the canonical hash at the inclusion + // height. Once finality has reached that height, the best chain must + // contain the same finalized ancestor at that height. + let finalized = self.finalized_head()?; + let finalized_at = self.header(Some(&finalized))?.number; + let canonical_hash = self.canonical_block_hash(included_at)?; + if let Some(status) = classify_inclusion_finalization( + inclusion_hash, + canonical_hash.as_deref(), + included_at, + finalized_at, + ) { + return Ok(status); + } + thread::sleep(poll); + } + Err(CoreError::Rpc(format!( + "block {included_at} ({inclusion_hash}) was included but not finalized before the receipt timeout" + ))) + } + + fn decode_outcome( + &self, + extrinsic_hash: String, + block_hash: String, + block_number: u64, + extrinsic_index: u32, + ) -> Result { + let records = self.query("System", "Events", &[], Some(&block_hash))?; + let mut events = Vec::new(); + if let Value::List(items) = records { + for item in items { + if field(&item, "extrinsic_idx") + .and_then(as_u128) + .is_some_and(|index| index == u128::from(extrinsic_index)) + { + events.push(item); + } + } + } + let runtime = self.runtime()?; + let mut success = events + .iter() + .any(|event| event_is(event, "System", "ExtrinsicSuccess")); + let mut error = events + .iter() + .find(|event| event_is(event, "System", "ExtrinsicFailed")) + .and_then(event_attributes) + .and_then(|attributes| field(attributes, "dispatch_error").or(Some(attributes))) + .map(|value| dispatch_error(&runtime, value)) + .transpose()?; + + if error.is_none() { + for (module, event_name) in [ + ("Proxy", "ProxyExecuted"), + ("Sudo", "Sudid"), + ("Multisig", "MultisigExecuted"), + ] { + let Some(attributes) = events + .iter() + .find(|event| event_is(event, module, event_name)) + .and_then(event_attributes) + else { + continue; + }; + let result = field(attributes, "result").unwrap_or(attributes); + if let Some(inner) = variant(result, "Err") { + error = Some(dispatch_error(&runtime, inner)?); + success = false; + break; + } + } + } + + let fee_rao = events + .iter() + .find(|event| event_is(event, "TransactionPayment", "TransactionFeePaid")) + .and_then(event_attributes) + .and_then(|attributes| field(attributes, "actual_fee")) + .and_then(as_u128); + let message = match &error { + Some(error) => error.to_string(), + None if success => "extrinsic succeeded".into(), + None => "extrinsic did not emit System.ExtrinsicSuccess".into(), + }; + Ok(TxOutcome { + success, + extrinsic_hash, + block_hash: Some(block_hash), + block_number: Some(block_number), + extrinsic_index: Some(extrinsic_index), + fee_rao, + events, + error, + message, + data: BTreeMap::new(), + }) + } +} + +/// A read-only view pinned to one block hash. +pub struct Snapshot<'a> { + client: &'a Client, + pub block_hash: String, + pub block_number: u64, +} + +impl Snapshot<'_> { + pub fn query(&self, pallet: &str, storage: &str, params: &[Value]) -> Result { + self.client + .query(pallet, storage, params, Some(&self.block_hash)) + } + + pub fn query_map( + &self, + pallet: &str, + storage: &str, + fixed_params: &[Value], + ) -> Result, CoreError> { + self.client + .query_map(pallet, storage, fixed_params, Some(&self.block_hash)) + } + + pub fn runtime_call( + &self, + api: &str, + method: &str, + params: &[Value], + ) -> Result { + self.client + .runtime_call(api, method, params, Some(&self.block_hash)) + } + + pub fn balance_rao(&self, address: &str) -> Result { + let account = self.query("System", "Account", &[Value::str(address)])?; + field(&account, "data") + .and_then(|data| field(data, "free")) + .and_then(as_u128) + .ok_or_else(|| CoreError::Codec("System.Account.data.free is missing".into())) + } +} + +/// Iterator returned by [`Client::blocks`]. +pub struct BlockStream<'a> { + client: &'a Client, + finalized: bool, + last_number: Option, + poll_interval: Duration, +} + +impl Iterator for BlockStream<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + loop { + let head = if self.finalized { + self.client + .finalized_head() + .and_then(|hash| self.client.header(Some(&hash))) + } else { + self.client.header(None) + }; + match head { + Ok(header) => { + if self.last_number.is_none_or(|last| header.number > last) { + self.last_number = Some(header.number); + return Some(Ok(header)); + } + } + Err(error) => return Some(Err(error)), + } + thread::sleep(self.poll_interval); + } + } +} + +struct RpcBootstrap { + endpoint: String, + http: HttpClient, + next_id: AtomicU64, +} + +impl RpcBootstrap { + fn rpc(&self, method: &str, params: JsonValue) -> Result { + rpc_request( + &self.http, + &self.endpoint, + self.next_id.fetch_add(1, Ordering::Relaxed), + method, + params, + ) + } + + fn runtime_version(&self) -> Result { + parse_runtime_version(self.rpc("state_getRuntimeVersion", json!([]))?) + } + + fn metadata(&self) -> Result, CoreError> { + fetch_metadata(|method, params| self.rpc(method, params)) + } + + fn block_hash(&self, block: Option) -> Result { + let params = block.map_or_else(|| json!([]), |number| json!([number])); + let value = self.rpc("chain_getBlockHash", params)?; + Ok(json_string(&value, "chain_getBlockHash result")?.to_string()) + } + + fn ss58_format(&self) -> Result { + let properties = self.rpc("system_properties", json!([]))?; + let value = properties + .as_object() + .and_then(|object| object.get("ss58Format")) + .map(json_u64) + .transpose()? + .unwrap_or(42); + u16::try_from(value) + .map_err(|_| CoreError::Rpc(format!("ss58Format {value} does not fit u16"))) + } +} + +#[derive(Debug, Clone, Copy)] +struct RuntimeVersion { + spec_version: u32, + transaction_version: u32, +} + +fn parse_runtime_version(value: JsonValue) -> Result { + let object = value + .as_object() + .ok_or_else(|| CoreError::Rpc("state_getRuntimeVersion returned a non-object".into()))?; + let spec = object + .get("specVersion") + .ok_or_else(|| CoreError::Rpc("runtime version has no specVersion".into()))?; + let transaction = object + .get("transactionVersion") + .ok_or_else(|| CoreError::Rpc("runtime version has no transactionVersion".into()))?; + Ok(RuntimeVersion { + spec_version: u32::try_from(json_u64(spec)?) + .map_err(|_| CoreError::Rpc("specVersion does not fit u32".into()))?, + transaction_version: u32::try_from(json_u64(transaction)?) + .map_err(|_| CoreError::Rpc("transactionVersion does not fit u32".into()))?, + }) +} + +/// Fetch metadata V15 when the runtime exposes the metadata runtime API. +/// +/// `state_getMetadata` returns V14 on current Substrate nodes, which omits the +/// runtime-API descriptions required by `Client::runtime_call`. Older nodes may +/// not expose `Metadata_metadata_at_version`, so retain V14 as a compatibility +/// fallback for storage reads and call composition. +fn fetch_metadata(mut rpc: F) -> Result, CoreError> +where + F: FnMut(&str, JsonValue) -> Result, +{ + let requested_version = hex_prefixed(&15u32.to_le_bytes()); + match rpc( + "state_call", + json!(["Metadata_metadata_at_version", requested_version]), + ) { + Ok(value) => { + let encoded = decode_hex(json_string(&value, "Metadata_metadata_at_version result")?)?; + let mut input = encoded.as_slice(); + let metadata = Option::>::decode(&mut input).map_err(|error| { + CoreError::Codec(format!( + "cannot decode Metadata_metadata_at_version result: {error}" + )) + })?; + if !input.is_empty() { + return Err(CoreError::Codec(format!( + "{} trailing bytes in Metadata_metadata_at_version result", + input.len() + ))); + } + if let Some(metadata) = metadata { + return Ok(metadata); + } + } + Err(CoreError::Rpc(_)) => {} + Err(error) => return Err(error), + } + + let value = rpc("state_getMetadata", json!([]))?; + decode_hex(json_string(&value, "state_getMetadata result")?) +} + +fn rpc_request( + client: &HttpClient, + endpoint: &str, + id: u64, + method: &str, + params: JsonValue, +) -> Result { + let response = client + .post(endpoint) + .json(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + })) + .send() + .map_err(|error| CoreError::Rpc(format!("{method} request failed: {error}")))? + .error_for_status() + .map_err(|error| CoreError::Rpc(format!("{method} HTTP error: {error}")))?; + let body: JsonValue = response + .json() + .map_err(|error| CoreError::Rpc(format!("{method} returned invalid JSON: {error}")))?; + if let Some(error) = body.as_object().and_then(|object| object.get("error")) { + return Err(CoreError::Rpc(format!("{method}: {error}"))); + } + body.as_object() + .and_then(|object| object.get("result")) + .cloned() + .ok_or_else(|| CoreError::Rpc(format!("{method} response omitted result"))) +} + +fn http_endpoint(endpoint: &str) -> String { + if let Some(rest) = endpoint.strip_prefix("ws://") { + return format!("http://{rest}"); + } + if let Some(rest) = endpoint.strip_prefix("wss://") { + return format!("https://{rest}"); + } + endpoint.to_string() +} + +fn storage_info(runtime: &Runtime, pallet: &str, storage: &str) -> Result { + runtime + .storage_entry(pallet, storage) + .cloned() + .ok_or_else(|| CoreError::NotInRuntime(format!("storage {pallet}.{storage}"))) +} + +fn runtime_api_method<'a>( + runtime: &'a Runtime, + api: &str, + method: &str, +) -> Result<&'a RuntimeApiMethodInfo, CoreError> { + runtime + .apis + .iter() + .find(|candidate| candidate.name == api) + .and_then(|candidate| { + candidate + .methods + .iter() + .find(|candidate| candidate.name == method) + }) + .ok_or_else(|| CoreError::NotInRuntime(format!("runtime API {api}.{method}"))) +} + +fn decode_storage( + runtime: &Runtime, + info: &StorageInfo, + raw: Option<&str>, +) -> Result { + let bytes = match raw { + Some(raw) => decode_hex(raw)?, + None if info.modifier == "Optional" => return Ok(Value::Null), + None => info.default_bytes.clone(), + }; + runtime.decode_spec(&TypeSpec::Id(info.value_type), &bytes, true) +} + +fn dispatch_error(runtime: &Runtime, value: &Value) -> Result { + if let Some(module) = variant(value, "Module") { + let index = field(module, "index") + .and_then(as_u128) + .or_else(|| tuple_item(module, 0).and_then(as_u128)) + .and_then(|value| u8::try_from(value).ok()) + .ok_or_else(|| CoreError::Codec("module error omitted pallet index".into()))?; + let error_index = field(module, "error") + .or_else(|| tuple_item(module, 1)) + .and_then(first_byte) + .ok_or_else(|| CoreError::Codec("module error omitted error bytes".into()))?; + let pallet = runtime.pallet_at(index).map(|pallet| pallet.name.clone()); + let (name, docs) = runtime + .module_error(index, error_index) + .unwrap_or_else(|_| (format!("Error{error_index}"), Vec::new())); + return Ok(DispatchError { + pallet, + semantic_code: semantic_error_code(&name), + name, + docs, + }); + } + let name = variant_name(value).unwrap_or_else(|| "DispatchError".into()); + Ok(DispatchError { + pallet: None, + semantic_code: semantic_error_code(&name), + name, + docs: Vec::new(), + }) +} + +fn semantic_error_code(name: &str) -> String { + let canonical = match name { + "SubNetworkDoesNotExist" + | "SubnetDoesNotExist" + | "SubnetNotExists" + | "SubNetDoesNotExist" => "subnet_not_exists", + "NotProxy" => "not_proxy", + "NoPermission" | "NotAllowed" | "Unproxyable" => "not_allowed", + "HotKeyNotRegisteredInNetwork" | "HotkeyNotRegisteredInSubNet" | "NotRegistered" => { + "not_registered" + } + "TxRateLimitExceeded" | "SettingWeightsTooFast" => "rate_limited", + _ => return snake_case(name), + }; + canonical.into() +} + +fn snake_case(name: &str) -> String { + let mut out = String::new(); + for (index, ch) in name.chars().enumerate() { + if ch.is_uppercase() { + if index > 0 { + out.push('_'); + } + for lower in ch.to_lowercase() { + out.push(lower); + } + } else { + out.push(ch); + } + } + out +} + +fn event_is(record: &Value, module: &str, name: &str) -> bool { + field(record, "module_id") + .or_else(|| field(record, "event").and_then(|event| field(event, "module_id"))) + .and_then(as_str) + == Some(module) + && field(record, "event_id") + .or_else(|| field(record, "event").and_then(|event| field(event, "event_id"))) + .and_then(as_str) + == Some(name) +} + +fn event_attributes(record: &Value) -> Option<&Value> { + field(record, "attributes") + .or_else(|| field(record, "event").and_then(|event| field(event, "attributes"))) +} + +pub fn field<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { + let Value::Dict(entries) = value else { + return None; + }; + entries.iter().find_map(|(key, value)| match key { + Value::Str(key) if key == name => Some(value), + _ => None, + }) +} + +pub fn variant<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { + field(value, name) +} + +pub fn variant_name(value: &Value) -> Option { + match value { + Value::Str(name) => Some(name.clone()), + Value::Dict(entries) if entries.len() == 1 => entries.first().and_then(|(key, _)| { + if let Value::Str(name) = key { + Some(name.clone()) + } else { + None + } + }), + _ => None, + } +} + +pub fn as_str(value: &Value) -> Option<&str> { + match value { + Value::Str(value) => Some(value), + _ => None, + } +} + +pub fn as_u128(value: &Value) -> Option { + match value { + Value::Int(value) => u128::try_from(*value).ok(), + Value::Uint(value) => Some(*value), + _ => None, + } +} + +pub fn as_bool(value: &Value) -> Option { + match value { + Value::Bool(value) => Some(*value), + _ => None, + } +} + +pub fn value_bytes(value: &Value) -> Option> { + match value { + Value::Bytes(bytes) => Some(bytes.clone()), + Value::Str(value) => { + if value.starts_with("0x") { + decode_hex(value).ok() + } else { + Some(value.as_bytes().to_vec()) + } + } + Value::List(items) | Value::Tuple(items) => { + let direct = items + .iter() + .map(as_u128) + .map(|value| value.and_then(|value| u8::try_from(value).ok())) + .collect::>>(); + direct.or_else(|| match items.as_slice() { + [inner] => value_bytes(inner), + _ => None, + }) + } + Value::Dict(entries) => match entries.as_slice() { + [(_, inner)] => value_bytes(inner), + _ => None, + }, + _ => None, + } +} + +fn tuple_item(value: &Value, index: usize) -> Option<&Value> { + match value { + Value::Tuple(items) | Value::List(items) => items.get(index), + _ => None, + } +} + +fn first_byte(value: &Value) -> Option { + value_bytes(value).and_then(|bytes| bytes.first().copied()) +} + +fn required_u128(value: &Value, name: &str) -> Result { + field(value, name) + .and_then(as_u128) + .ok_or_else(|| CoreError::Codec(format!("runtime result omitted integer field {name}"))) +} + +fn value_map_u16(rows: Vec<(Value, Value)>) -> BTreeMap { + rows.into_iter() + .filter_map(|(key, value)| { + let key = as_u128(&key).and_then(|value| u16::try_from(value).ok())?; + let value = as_u128(&value).and_then(|value| u16::try_from(value).ok())?; + Some((key, value)) + }) + .collect() +} + +fn value_map_u128(rows: Vec<(Value, Value)>) -> BTreeMap { + rows.into_iter() + .filter_map(|(key, value)| { + let key = as_u128(&key).and_then(|value| u16::try_from(value).ok())?; + Some((key, as_u128(&value)?)) + }) + .collect() +} + +fn json_string<'a>(value: &'a JsonValue, context: &str) -> Result<&'a str, CoreError> { + match value { + JsonValue::String(text) => Ok(text), + _ => Err(CoreError::Rpc(format!("{context} is not a string"))), + } +} + +fn json_number_u64(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.to_string().parse().ok(), + _ => None, + } +} + +fn json_number_u128(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.to_string().parse().ok(), + _ => None, + } +} + +fn json_number_i128(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.to_string().parse().ok(), + _ => None, + } +} + +fn json_u64(value: &JsonValue) -> Result { + if let Some(value) = json_number_u64(value) { + return Ok(value); + } + let text = value + .as_str() + .ok_or_else(|| CoreError::Rpc(format!("expected integer, got {value}")))?; + if let Some(hex) = text.strip_prefix("0x") { + return u64::from_str_radix(hex, 16) + .map_err(|error| CoreError::Rpc(format!("invalid hex integer {text}: {error}"))); + } + text.parse::() + .map_err(|error| CoreError::Rpc(format!("invalid integer {text}: {error}"))) +} + +fn json_u128(value: &JsonValue) -> Result { + if let Some(value) = json_number_u128(value) { + return Ok(value); + } + let text = value + .as_str() + .ok_or_else(|| CoreError::Rpc(format!("expected integer, got {value}")))?; + if let Some(hex) = text.strip_prefix("0x") { + return u128::from_str_radix(hex, 16) + .map_err(|error| CoreError::Rpc(format!("invalid hex integer {text}: {error}"))); + } + text.parse::() + .map_err(|error| CoreError::Rpc(format!("invalid integer {text}: {error}"))) +} + +fn json_to_value(value: &JsonValue) -> Result { + Ok(match value { + JsonValue::Null => Value::Null, + JsonValue::Bool(value) => Value::Bool(*value), + JsonValue::Number(value) => { + let number = JsonValue::Number(value.clone()); + if let Some(signed) = json_number_i128(&number) { + Value::Int(i128::from(signed)) + } else if let Some(unsigned) = json_number_u128(&number) { + Value::Uint(unsigned) + } else { + return Err(CoreError::Codec(format!( + "non-integral JSON number cannot become SCALE Value: {value}" + ))); + } + } + JsonValue::String(value) => Value::str(value), + JsonValue::Array(items) => Value::List( + items + .iter() + .map(json_to_value) + .collect::, _>>()?, + ), + JsonValue::Object(entries) => Value::record( + entries + .iter() + .map(|(key, value)| Ok((key.clone(), json_to_value(value)?))) + .collect::, CoreError>>()?, + ), + }) +} + +fn parse_h256(value: &str) -> Result<[u8; 32], CoreError> { + let bytes = decode_hex(value)?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| CoreError::Rpc(format!("expected H256, got {} bytes", bytes.len()))) +} + +fn decode_hex(value: &str) -> Result, CoreError> { + hex::decode(value.trim_start_matches("0x")) + .map_err(|error| CoreError::Codec(format!("invalid hex {value}: {error}"))) +} + +fn hex_prefixed(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +#[cfg(test)] +mod reorg_finalization_tests { + use super::{classify_inclusion_finalization, InclusionFinalization}; + + #[test] + fn canonical_inclusion_remains_pending_below_finalized_height() { + assert_eq!( + classify_inclusion_finalization("0xabc", Some("0xABC"), 10, 9), + None + ); + } + + #[test] + fn canonical_inclusion_finalizes_at_or_above_its_height() { + assert_eq!( + classify_inclusion_finalization("0xabc", Some("0xABC"), 10, 10), + Some(InclusionFinalization::Finalized) + ); + assert_eq!( + classify_inclusion_finalization("0xabc", Some("0xabc"), 10, 12), + Some(InclusionFinalization::Finalized) + ); + } + + #[test] + fn replaced_or_missing_inclusion_hash_is_a_reorg() { + assert_eq!( + classify_inclusion_finalization("0xabc", Some("0xdef"), 10, 12), + Some(InclusionFinalization::Reorged) + ); + assert_eq!( + classify_inclusion_finalization("0xabc", None, 10, 9), + Some(InclusionFinalization::Reorged) + ); + } +} diff --git a/sdk/bittensor-core/src/codec/encode.rs b/sdk/bittensor-core/src/codec/encode.rs index e3e5f12094..57c6aa9b0f 100644 --- a/sdk/bittensor-core/src/codec/encode.rs +++ b/sdk/bittensor-core/src/codec/encode.rs @@ -550,21 +550,39 @@ impl Runtime { .find(|v| v.name == function) .ok_or_else(|| CoreError::Codec(format!("call {pallet}.{function} not found")))?; let mut out = vec![pallet_info.index, chosen.index]; - let Value::Dict(entries) = params else { - return Err(CoreError::Codec("call params must be a dict".into())); - }; - for field in &chosen.fields { - let name = field.name.as_deref().unwrap_or_default(); - let item = entries - .iter() - .find(|(k, _)| matches!(k, Value::Str(s) if s == name)) - .map(|(_, v)| v) - .ok_or_else(|| { - CoreError::Codec(format!( - "missing call param {name:?} for {pallet}.{function}" - )) - })?; - self.encode_id(field.ty.id, item, &mut out)?; + match params { + Value::Dict(entries) => { + for field in &chosen.fields { + let name = field.name.as_deref().unwrap_or_default(); + let item = entries + .iter() + .find(|(k, _)| matches!(k, Value::Str(s) if s == name)) + .map(|(_, v)| v) + .ok_or_else(|| { + CoreError::Codec(format!( + "missing call param {name:?} for {pallet}.{function}" + )) + })?; + self.encode_id(field.ty.id, item, &mut out)?; + } + } + Value::List(items) | Value::Tuple(items) => { + if items.len() != chosen.fields.len() { + return Err(CoreError::Codec(format!( + "call {pallet}.{function} expects {} positional params, got {}", + chosen.fields.len(), + items.len() + ))); + } + for (field, item) in chosen.fields.iter().zip(items.iter()) { + self.encode_id(field.ty.id, item, &mut out)?; + } + } + other => { + return Err(CoreError::Codec(format!( + "call params must be a dict or positional list, got {other}" + ))); + } } Ok(out) } diff --git a/sdk/bittensor-core/src/codec/mod.rs b/sdk/bittensor-core/src/codec/mod.rs index c902c0fd88..2c6bd1cd9e 100644 --- a/sdk/bittensor-core/src/codec/mod.rs +++ b/sdk/bittensor-core/src/codec/mod.rs @@ -24,6 +24,32 @@ mod corpus_tests { use crate::runtime::type_string::TypeSpec; use crate::runtime::Runtime; + fn json_u64(v: &serde_json::Value) -> u64 { + match v { + serde_json::Value::Number(n) => n.to_string().parse().unwrap(), + _ => panic!("fixture value {v} is not an unsigned integer"), + } + } + + fn json_i64(v: &serde_json::Value) -> i64 { + match v { + serde_json::Value::Number(n) => n.to_string().parse().unwrap(), + _ => panic!("fixture value {v} is not a signed integer"), + } + } + + fn as_u32(value: u64) -> u32 { + u32::try_from(value).expect("fixture integer fits u32") + } + + fn as_u16(value: u64) -> u16 { + u16::try_from(value).expect("fixture integer fits u16") + } + + fn as_u8(value: u64) -> u8 { + u8::try_from(value).expect("fixture integer fits u8") + } + /// JSON fixture data as codec input values (objects become string-keyed /// dicts, matching the Python seam's params). fn value_from_json(v: &serde_json::Value) -> Value { @@ -31,10 +57,11 @@ mod corpus_tests { serde_json::Value::Null => Value::Null, serde_json::Value::Bool(b) => Value::Bool(*b), serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { + let text = n.to_string(); + if let Ok(i) = text.parse::() { Value::Int(i128::from(i)) - } else if let Some(u) = n.as_u64() { - Value::Uint(u128::from(u)) + } else if let Ok(u) = text.parse::() { + Value::Uint(u) } else { panic!("fixture number {n} is not an integer") } @@ -75,9 +102,9 @@ mod corpus_tests { let g = golden(); Runtime::parse( &golden_metadata_v15(), - g["network"]["spec_version"].as_u64().unwrap() as u32, - g["network"]["transaction_version"].as_u64().unwrap() as u32, - g["network"]["ss58_format"].as_u64().unwrap() as u16, + as_u32(json_u64(&g["network"]["spec_version"])), + as_u32(json_u64(&g["network"]["transaction_version"])), + as_u16(json_u64(&g["network"]["ss58_format"])), ) .expect("golden metadata parses") } @@ -94,7 +121,7 @@ mod corpus_tests { let corpus: serde_json::Value = serde_json::from_str(&raw).unwrap(); let rt = runtime(); assert_eq!( - corpus["spec_version"].as_u64().unwrap() as u32, + as_u32(json_u64(&corpus["spec_version"])), rt.spec_version, "corpus and golden fixture must be recorded from the same runtime" ); @@ -102,7 +129,7 @@ mod corpus_tests { let mut failures: Vec = Vec::new(); let mut checked = 0usize; for ty in corpus["types"].as_array().unwrap() { - let id = ty["id"].as_u64().unwrap() as u32; + let id = as_u32(json_u64(&ty["id"])); let name = ty["name"].as_str().unwrap(); for (i, sample) in ty["samples"].as_array().unwrap().iter().enumerate() { let scale_hex = sample["scale_hex"].as_str().unwrap(); @@ -352,7 +379,7 @@ mod corpus_tests { &call, &crate::codec::extrinsic::TxParams { era: Value::str("00"), - nonce: immortal["nonce"].as_u64().unwrap(), + nonce: json_u64(&immortal["nonce"]), tip: 0, tip_asset_id: None, genesis_hash: genesis, @@ -374,14 +401,14 @@ mod corpus_tests { era: Value::record(vec![ ( "period".into(), - Value::Int(i128::from(mortal["era"]["period"].as_i64().unwrap())), + Value::Int(i128::from(json_i64(&mortal["era"]["period"]))), ), ( "current".into(), - Value::Int(i128::from(mortal["era"]["current"].as_i64().unwrap())), + Value::Int(i128::from(json_i64(&mortal["era"]["current"]))), ), ]), - nonce: mortal["nonce"].as_u64().unwrap(), + nonce: json_u64(&mortal["nonce"]), tip: 0, tip_asset_id: None, genesis_hash: genesis, @@ -421,11 +448,11 @@ mod corpus_tests { &call, h256(case["public_key_hex"].as_str().unwrap()), &signature, - case["signature_version"].as_u64().unwrap() as u8, + as_u8(json_u64(&case["signature_version"])), &crate::codec::extrinsic::TxParams { era, - nonce: case["nonce"].as_u64().unwrap(), - tip: case["tip"].as_u64().unwrap().into(), + nonce: json_u64(&case["nonce"]), + tip: json_u64(&case["tip"]).into(), tip_asset_id: None, genesis_hash: [0; 32], era_block_hash: [0; 32], @@ -464,7 +491,7 @@ mod corpus_tests { #[test] fn multisig_accounts_match_the_golden_vectors() { let g = golden(); - let ss58_format = g["network"]["ss58_format"].as_u64().unwrap() as u16; + let ss58_format = as_u16(json_u64(&g["network"]["ss58_format"])); for case in g["multisig"].as_array().unwrap() { let signatories: Vec<[u8; 32]> = case["signatories"] .as_array() @@ -472,7 +499,7 @@ mod corpus_tests { .iter() .map(|s| crate::keys::public_key_from_ss58(s.as_str().unwrap()).unwrap()) .collect(); - let threshold = case["threshold"].as_u64().unwrap() as u16; + let threshold = as_u16(json_u64(&case["threshold"])); let (account, sorted) = crate::codec::extrinsic::multisig_account_id(&signatories, threshold).unwrap(); assert_eq!( diff --git a/sdk/bittensor-core/src/digest/mod.rs b/sdk/bittensor-core/src/digest/mod.rs index efc2827f61..d0ee0e7ce3 100644 --- a/sdk/bittensor-core/src/digest/mod.rs +++ b/sdk/bittensor-core/src/digest/mod.rs @@ -20,6 +20,7 @@ use merkleized_metadata::{ generate_metadata_digest, generate_proof_for_extrinsic_parts, types::ExtrinsicMetadata, ExtraInfo, FrameMetadataPrepared, Proof, SignedExtrinsicData, }; +use subtensor_macros::freeze_struct; use crate::error::CoreError; @@ -55,6 +56,7 @@ impl ChainInfo { /// The upstream crate's `ExtraInfo` doesn't derive `Encode`; the field order /// here is pinned by the app's parser (spec_version, spec_name, base58_prefix, /// decimals, token_symbol — the RFC-0078 `MetadataDigest::V1` tail order). +#[freeze_struct("edb2a68250293f5")] #[derive(Encode)] struct ExtraInfoEncoded { spec_version: u32, @@ -79,6 +81,7 @@ impl From for ExtraInfoEncoded { /// The proof blob wire shape the Ledger generic app expects: the merkle proof /// of the types the extrinsic touches, the extrinsic metadata, and the chain /// constants — SCALE-encoded in this order. +#[freeze_struct("e1038cfe3b767760")] #[derive(Encode)] struct MetadataProof { proof: Proof, @@ -236,7 +239,13 @@ mod tests { let field = |name: &str| -> Vec { hex::decode(vector[name].as_str().unwrap()).unwrap() }; let metadata = golden_metadata_v15(); - let spec_version = vector["spec_version"].as_u64().unwrap() as u32; + let spec_version = match &vector["spec_version"] { + serde_json::Value::Number(number) => number + .to_string() + .parse() + .expect("fixture spec_version fits u32"), + value => panic!("fixture spec_version {value} is not an integer"), + }; let proof = generate_extrinsic_proof( &field("call_data_hex"), &field("included_in_extrinsic_hex"), diff --git a/sdk/bittensor-core/src/error.rs b/sdk/bittensor-core/src/error.rs index 9ec5678ff5..0cd903138f 100644 --- a/sdk/bittensor-core/src/error.rs +++ b/sdk/bittensor-core/src/error.rs @@ -23,6 +23,10 @@ pub enum CoreError { /// A hardware signing device could not be reached, rejected the request, /// or the user declined on-device. Device(String), + /// JSON-RPC transport, protocol, or receipt processing failed. + Rpc(String), + /// A semantic transaction was rejected before signing by local policy. + Policy(String), } impl fmt::Display for CoreError { @@ -34,6 +38,8 @@ impl fmt::Display for CoreError { CoreError::Codec(msg) => write!(f, "codec error: {msg}"), CoreError::Crypto(msg) => write!(f, "crypto error: {msg}"), CoreError::Device(msg) => write!(f, "device error: {msg}"), + CoreError::Rpc(msg) => write!(f, "rpc error: {msg}"), + CoreError::Policy(msg) => write!(f, "policy error: {msg}"), } } } diff --git a/sdk/bittensor-core/src/keyfiles/mod.rs b/sdk/bittensor-core/src/keyfiles/mod.rs index c4586b6dcf..b58c5db187 100644 --- a/sdk/bittensor-core/src/keyfiles/mod.rs +++ b/sdk/bittensor-core/src/keyfiles/mod.rs @@ -206,8 +206,10 @@ pub fn deserialize_keypair_from_keyfile_data(keyfile_data: &[u8]) -> Result number.to_string().parse::().ok(), + _ => None, + }) .unwrap_or(CRYPTO_SR25519); if let Some(secret_phrase) = keyfile_dict diff --git a/sdk/bittensor-core/src/lib.rs b/sdk/bittensor-core/src/lib.rs index b11b270509..cdb1bbe34f 100644 --- a/sdk/bittensor-core/src/lib.rs +++ b/sdk/bittensor-core/src/lib.rs @@ -1,14 +1,14 @@ -//! The chain-defined compute core for Bittensor clients. +//! The chain-defined compute core and native Rust SDK for Bittensor clients. //! -//! One rule decides what lives here: Rust owns everything whose right answer -//! is defined by the chain (crypto, SCALE, extrinsic payloads, metadata -//! digests); the client SDKs own everything whose right answer is a product -//! decision (intents, policy, CLI UX, transports). This crate contains no -//! binding code — `bittensor-core-py` (and future uniffi/napi siblings) -//! expose it to other languages. +//! Rust owns the chain-defined pieces (crypto, SCALE, extrinsic payloads, +//! metadata digests) and now also exposes a thin native client and semantic +//! transaction executor over those primitives. Binding crates may reuse the +//! same client or expose only the compute surface to another language. //! //! See `sdk/bittensor-core-spec.md` for the full design. +#[cfg(feature = "host")] +pub mod client; pub mod codec; pub mod digest; pub mod error; @@ -19,5 +19,11 @@ pub mod mlkem; pub mod runtime; pub mod signers; pub mod timelock; +#[cfg(feature = "host")] +pub mod transaction; +#[cfg(feature = "host")] +pub use client::Client; pub use error::CoreError; +#[cfg(feature = "host")] +pub use transaction::{Executor, IntentCall, Plan, Policy, SignerRole, Spend, Wallet}; diff --git a/sdk/bittensor-core/src/timelock/mod.rs b/sdk/bittensor-core/src/timelock/mod.rs index b55879fd6e..a797683661 100644 --- a/sdk/bittensor-core/src/timelock/mod.rs +++ b/sdk/bittensor-core/src/timelock/mod.rs @@ -22,6 +22,7 @@ use sha2::Digest; // a Date.now()-backed drop-in with the same types on that target. #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] use std::time::{SystemTime, UNIX_EPOCH}; +use subtensor_macros::freeze_struct; use tle::{ curves::drand::TinyBLS381, ibe::fullident::Identity, @@ -47,6 +48,7 @@ fn now_unix() -> Result { .map_err(|e| tl_err(format!("SystemTime error: {e:?}"))) } +#[freeze_struct("705b0f6dde3ed6e")] #[derive(Encode, Decode, Debug, PartialEq)] pub struct WeightsTlockPayload { pub hotkey: Vec, @@ -55,6 +57,7 @@ pub struct WeightsTlockPayload { pub version_key: u64, } +#[freeze_struct("b96b617eb03c11a6")] #[derive(Encode, Decode)] pub struct UserData { pub encrypted_data: Vec, diff --git a/sdk/bittensor-core/src/transaction.rs b/sdk/bittensor-core/src/transaction.rs new file mode 100644 index 0000000000..a8aea0f1a7 --- /dev/null +++ b/sdk/bittensor-core/src/transaction.rs @@ -0,0 +1,1185 @@ +//! Semantic transaction planning and policy for the native Rust SDK. +//! +//! Domain packages can construct [`IntentCall`] values without knowing pallet +//! indices. The executor resolves calls against live metadata, simulates fees, +//! enforces one policy choke point, signs, and submits through [`Client`]. + +#![allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] + +use std::collections::BTreeSet; + +use crate::client::{as_u128, Client, TxOutcome}; +use crate::codec::Value; +use crate::error::CoreError; +use crate::keys::{Keypair, CRYPTO_SR25519}; + +/// Which wallet key signs an intent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum SignerRole { + Coldkey, + Hotkey, +} + +/// The key material used by semantic intents. +pub struct Wallet { + pub coldkey: Keypair, + pub hotkey: Keypair, +} + +impl Wallet { + pub fn from_uris(coldkey_uri: &str, hotkey_uri: &str) -> Result { + Ok(Self { + coldkey: Keypair::from_uri(coldkey_uri, CRYPTO_SR25519)?, + hotkey: Keypair::from_uri(hotkey_uri, CRYPTO_SR25519)?, + }) + } + + pub fn signer(&self, role: SignerRole) -> &Keypair { + match role { + SignerRole::Coldkey => &self.coldkey, + SignerRole::Hotkey => &self.hotkey, + } + } +} + +/// Native-currency movement declared by an intent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Spend { + None, + Bounded(u128), + /// The exact outgoing amount depends on live state (`transfer_all`, dynamic registration, …). + Unbounded, +} + +/// Policy semantics proven by a trusted constructor, or the conservative +/// classification assigned to an arbitrary metadata call. +#[derive(Debug, Clone)] +struct SecuritySemantics { + spend: Spend, + netuids: Vec, + affects_all_subnets: bool, + raw: bool, +} + +impl SecuritySemantics { + fn arbitrary() -> Self { + Self { + spend: Spend::Unbounded, + netuids: Vec::new(), + // An arbitrary call may inspect or mutate any subnet. Treat its + // scope as unknown/all rather than letting an empty list bypass an + // allowlist. + affects_all_subnets: true, + raw: true, + } + } + + fn trusted( + spend: Spend, + netuids: impl IntoIterator, + affects_all_subnets: bool, + ) -> Self { + let mut netuids: Vec = netuids.into_iter().collect(); + netuids.sort_unstable(); + netuids.dedup(); + Self { + spend, + netuids, + affects_all_subnets, + raw: false, + } + } +} + +/// A metadata-resolved call plus immutable product-level transaction semantics. +/// +/// The call target, parameters, signer, and policy classification are private +/// so callers cannot change the encoded call after a trusted constructor has +/// attached its spend/subnet semantics. Use [`IntentCall::new`] only for an +/// arbitrary call: it is deliberately classified as raw, unbounded, and +/// unknown/all-subnet scope. Narrower semantics are available only through +/// fixed operation-specific constructors in this module. +#[derive(Debug, Clone)] +pub struct IntentCall { + pub op: String, + pub summary: String, + signer: SignerRole, + pallet: String, + function: String, + params: Value, + security: SecuritySemantics, +} + +impl IntentCall { + /// Construct an arbitrary metadata call. + /// + /// Arbitrary calls are always `raw`, `Spend::Unbounded`, and treated as + /// affecting every subnet. This fail-closed classification means they + /// require explicit `allow_raw_calls`, cannot pass a spend cap, and cannot + /// pass an explicit subnet allowlist. Callers cannot downgrade that + /// classification after construction. + pub fn new( + op: impl Into, + signer: SignerRole, + pallet: impl Into, + function: impl Into, + params: Value, + ) -> Self { + Self::from_parts( + op, + signer, + pallet, + function, + params, + SecuritySemantics::arbitrary(), + ) + } + + /// Explicitly named alias for [`IntentCall::new`]. + pub fn raw_call( + op: impl Into, + signer: SignerRole, + pallet: impl Into, + function: impl Into, + params: Value, + ) -> Self { + Self::new(op, signer, pallet, function, params) + } + + #[allow(clippy::too_many_arguments)] + fn trusted( + op: impl Into, + signer: SignerRole, + pallet: impl Into, + function: impl Into, + params: Value, + spend: Spend, + netuids: impl IntoIterator, + affects_all_subnets: bool, + ) -> Self { + Self::from_parts( + op, + signer, + pallet, + function, + params, + SecuritySemantics::trusted(spend, netuids, affects_all_subnets), + ) + } + + fn from_parts( + op: impl Into, + signer: SignerRole, + pallet: impl Into, + function: impl Into, + params: Value, + security: SecuritySemantics, + ) -> Self { + let op = op.into(); + Self { + summary: op.replace('_', " "), + op, + signer, + pallet: pallet.into(), + function: function.into(), + params, + security, + } + } + + /// Stable operation name used in plans and diagnostics. + pub fn op(&self) -> &str { + &self.op + } + + /// Signer selected by the constructor. Read-only: callers cannot change it. + pub fn signer_role(&self) -> SignerRole { + self.signer + } + + /// Metadata call target. Read-only: callers cannot replace the trusted call. + pub fn pallet(&self) -> &str { + &self.pallet + } + + /// Metadata function target. Read-only: callers cannot replace the trusted call. + pub fn function(&self) -> &str { + &self.function + } + + /// SCALE input value. Read-only: callers cannot replace trusted parameters. + pub fn params(&self) -> &Value { + &self.params + } + + /// Replace presentation text only. This cannot alter policy semantics. + pub fn summary(mut self, summary: impl Into) -> Self { + self.summary = summary.into(); + self + } + + /// Compatibility policy floor for older call sites. This can only make a + /// trusted classification stricter. Arbitrary calls remain unbounded. + pub fn spend(mut self, spend: Spend) -> Self { + self.security.spend = strictest_spend(self.security.spend, spend); + self + } + + /// Compatibility policy floor for older call sites. Supplied netuids are + /// unioned with constructor-derived scope; they can never remove scope. + pub fn touches(mut self, netuids: impl IntoIterator) -> Self { + self.security.netuids.extend(netuids); + self.security.netuids.sort_unstable(); + self.security.netuids.dedup(); + self + } + + /// Broaden this call's subnet scope. There is intentionally no inverse. + pub fn affects_all_subnets(mut self) -> Self { + self.security.affects_all_subnets = true; + self + } + + /// Force the arbitrary-call classification. There is intentionally no + /// inverse that can turn a raw call into a trusted call. + pub fn raw(mut self) -> Self { + self.security = SecuritySemantics::arbitrary(); + self + } + + /// A bounded keep-alive TAO transfer. + pub fn transfer(dest: impl Into, amount_rao: u128) -> Self { + Self::trusted( + "transfer", + SignerRole::Coldkey, + "Balances", + "transfer_keep_alive", + Value::record(vec![ + ("dest".into(), Value::str(dest)), + ("value".into(), Value::Uint(amount_rao)), + ]), + Spend::Bounded(amount_rao), + [], + false, + ) + } + + /// Fund the native mirror of an EVM address with a bounded TAO amount. + pub fn fund_evm_key(mirror: impl Into, amount_rao: u128) -> Self { + Self::trusted( + "fund_evm_key", + SignerRole::Coldkey, + "Balances", + "transfer_keep_alive", + Value::record(vec![ + ("dest".into(), Value::str(mirror)), + ("value".into(), Value::Uint(amount_rao)), + ]), + Spend::Bounded(amount_rao), + [], + false, + ) + } + + /// A bounded TAO transfer that may reap the sender. + pub fn transfer_allow_death(dest: impl Into, amount_rao: u128) -> Self { + Self::trusted( + "transfer", + SignerRole::Coldkey, + "Balances", + "transfer_allow_death", + Value::record(vec![ + ("dest".into(), Value::str(dest)), + ("value".into(), Value::Uint(amount_rao)), + ]), + Spend::Bounded(amount_rao), + [], + false, + ) + } + + /// Transfer the account's full transferable balance. + pub fn transfer_all(dest: impl Into, keep_alive: bool) -> Self { + Self::trusted( + "transfer_all", + SignerRole::Coldkey, + "Balances", + "transfer_all", + Value::record(vec![ + ("dest".into(), Value::str(dest)), + ("keep_alive".into(), Value::Bool(keep_alive)), + ]), + Spend::Unbounded, + [], + false, + ) + } + + /// Stake a bounded amount of TAO on one subnet. + pub fn add_stake(hotkey: impl Into, netuid: u16, amount_rao: u128) -> Self { + Self::trusted( + "add_stake", + SignerRole::Coldkey, + "SubtensorModule", + "add_stake", + Value::record(vec![ + ("hotkey".into(), Value::str(hotkey)), + ("netuid".into(), Value::Uint(u128::from(netuid))), + ("amount_staked".into(), Value::Uint(amount_rao)), + ]), + Spend::Bounded(amount_rao), + [netuid], + false, + ) + } + + /// Stake a bounded amount of TAO with a limit price. + pub fn add_stake_limit( + hotkey: impl Into, + netuid: u16, + amount_rao: u128, + limit_price_rao: u128, + allow_partial: bool, + ) -> Self { + Self::trusted( + "add_stake_limit", + SignerRole::Coldkey, + "SubtensorModule", + "add_stake_limit", + Value::record(vec![ + ("hotkey".into(), Value::str(hotkey)), + ("netuid".into(), Value::Uint(u128::from(netuid))), + ("amount_staked".into(), Value::Uint(amount_rao)), + ("limit_price".into(), Value::Uint(limit_price_rao)), + ("allow_partial".into(), Value::Bool(allow_partial)), + ]), + Spend::Bounded(amount_rao), + [netuid], + false, + ) + } + + /// Remove alpha stake from one subnet. This moves value back to the signer, + /// so it is subnet-scoped but not an outgoing spend. + pub fn remove_stake(hotkey: impl Into, netuid: u16, amount_alpha_rao: u128) -> Self { + Self::trusted( + "remove_stake", + SignerRole::Coldkey, + "SubtensorModule", + "remove_stake", + Value::record(vec![ + ("hotkey".into(), Value::str(hotkey)), + ("netuid".into(), Value::Uint(u128::from(netuid))), + ("amount_unstaked".into(), Value::Uint(amount_alpha_rao)), + ]), + Spend::None, + [netuid], + false, + ) + } + + /// Remove alpha stake from one subnet with a limit price. + pub fn remove_stake_limit( + hotkey: impl Into, + netuid: u16, + amount_alpha_rao: u128, + limit_price_rao: u128, + allow_partial: bool, + ) -> Self { + Self::trusted( + "remove_stake_limit", + SignerRole::Coldkey, + "SubtensorModule", + "remove_stake_limit", + Value::record(vec![ + ("hotkey".into(), Value::str(hotkey)), + ("netuid".into(), Value::Uint(u128::from(netuid))), + ("amount_unstaked".into(), Value::Uint(amount_alpha_rao)), + ("limit_price".into(), Value::Uint(limit_price_rao)), + ("allow_partial".into(), Value::Bool(allow_partial)), + ]), + Spend::None, + [netuid], + false, + ) + } + + /// Register a subnet. The live registration cost is not known locally. + pub fn register_subnet(hotkey: impl Into) -> Self { + Self::trusted( + "register_subnet", + SignerRole::Coldkey, + "SubtensorModule", + "register_network", + Value::record(vec![("hotkey".into(), Value::str(hotkey))]), + Spend::Unbounded, + [], + false, + ) + } + + /// Activate one owned subnet. + pub fn start_call(netuid: u16) -> Self { + Self::trusted( + "start_call", + SignerRole::Coldkey, + "SubtensorModule", + "start_call", + Value::record(vec![("netuid".into(), Value::Uint(u128::from(netuid)))]), + Spend::None, + [netuid], + false, + ) + } + + /// Register a hotkey by burning the subnet's live registration cost. + pub fn burned_register(netuid: u16, hotkey: impl Into) -> Self { + Self::trusted( + "burned_register", + SignerRole::Coldkey, + "SubtensorModule", + "burned_register", + Value::record(vec![ + ("netuid".into(), Value::Uint(u128::from(netuid))), + ("hotkey".into(), Value::str(hotkey)), + ]), + Spend::Unbounded, + [netuid], + false, + ) + } + + /// Register a hotkey on root. + pub fn root_register(hotkey: impl Into) -> Self { + Self::trusted( + "root_register", + SignerRole::Coldkey, + "SubtensorModule", + "root_register", + Value::record(vec![("hotkey".into(), Value::str(hotkey))]), + Spend::None, + [0], + false, + ) + } + + /// Move stake between hotkeys/subnets without transferring ownership. + pub fn move_stake( + origin_hotkey: impl Into, + origin_netuid: u16, + destination_hotkey: impl Into, + destination_netuid: u16, + amount_alpha_rao: u128, + ) -> Self { + Self::trusted( + "move_stake", + SignerRole::Coldkey, + "SubtensorModule", + "move_stake", + Value::record(vec![ + ("origin_hotkey".into(), Value::str(origin_hotkey)), + ("destination_hotkey".into(), Value::str(destination_hotkey)), + ( + "origin_netuid".into(), + Value::Uint(u128::from(origin_netuid)), + ), + ( + "destination_netuid".into(), + Value::Uint(u128::from(destination_netuid)), + ), + ("alpha_amount".into(), Value::Uint(amount_alpha_rao)), + ]), + Spend::None, + [origin_netuid, destination_netuid], + false, + ) + } + + /// Swap stake between two subnets on one hotkey. + pub fn swap_stake( + hotkey: impl Into, + origin_netuid: u16, + destination_netuid: u16, + amount_alpha_rao: u128, + ) -> Self { + Self::trusted( + "swap_stake", + SignerRole::Coldkey, + "SubtensorModule", + "swap_stake", + Value::record(vec![ + ("hotkey".into(), Value::str(hotkey)), + ( + "origin_netuid".into(), + Value::Uint(u128::from(origin_netuid)), + ), + ( + "destination_netuid".into(), + Value::Uint(u128::from(destination_netuid)), + ), + ("alpha_amount".into(), Value::Uint(amount_alpha_rao)), + ]), + Spend::None, + [origin_netuid, destination_netuid], + false, + ) + } + + /// Transfer stake ownership to another coldkey. The alpha value is not + /// cheaply TAO-bounded, so a spend cap must reject it. + pub fn transfer_stake( + destination_coldkey: impl Into, + hotkey: impl Into, + origin_netuid: u16, + destination_netuid: u16, + amount_alpha_rao: u128, + ) -> Self { + Self::trusted( + "transfer_stake", + SignerRole::Coldkey, + "SubtensorModule", + "transfer_stake", + Value::record(vec![ + ( + "destination_coldkey".into(), + Value::str(destination_coldkey), + ), + ("hotkey".into(), Value::str(hotkey)), + ( + "origin_netuid".into(), + Value::Uint(u128::from(origin_netuid)), + ), + ( + "destination_netuid".into(), + Value::Uint(u128::from(destination_netuid)), + ), + ("alpha_amount".into(), Value::Uint(amount_alpha_rao)), + ]), + Spend::Unbounded, + [origin_netuid, destination_netuid], + false, + ) + } + + /// Unstake from every subnet on one hotkey. + pub fn unstake_all(hotkey: impl Into) -> Self { + Self::trusted( + "unstake_all", + SignerRole::Coldkey, + "SubtensorModule", + "unstake_all", + Value::record(vec![("hotkey".into(), Value::str(hotkey))]), + Spend::None, + [], + true, + ) + } + + /// Move all alpha stake to root across every subnet on one hotkey. + pub fn unstake_all_alpha(hotkey: impl Into) -> Self { + Self::trusted( + "unstake_all_alpha", + SignerRole::Coldkey, + "SubtensorModule", + "unstake_all_alpha", + Value::record(vec![("hotkey".into(), Value::str(hotkey))]), + Spend::None, + [], + true, + ) + } + + /// Construct an owner-settable subnet hyperparameter call while rejecting + /// root-only or compound parameters before signing. + pub fn set_hyperparameter(netuid: u16, name: &str, value: Value) -> Result { + let (function, is_bool) = match name { + "immunity_period" => ("sudo_set_immunity_period", false), + "min_allowed_weights" => ("sudo_set_min_allowed_weights", false), + "weights_version" => ("sudo_set_weights_version_key", false), + "activity_cutoff" => ("sudo_set_activity_cutoff", false), + "min_burn" => ("sudo_set_min_burn", false), + "bonds_moving_avg" => ("sudo_set_bonds_moving_average", false), + "serving_rate_limit" => ("sudo_set_serving_rate_limit", false), + "commit_reveal_period" => ("sudo_set_commit_reveal_weights_interval", false), + "max_allowed_uids" => ("sudo_set_max_allowed_uids", false), + "burn_increase_mult" => ("sudo_set_burn_increase_mult", false), + "burn_half_life" => ("sudo_set_burn_half_life", false), + "commit_reveal_weights_enabled" => ("sudo_set_commit_reveal_weights_enabled", true), + "liquid_alpha_enabled" => ("sudo_set_liquid_alpha_enabled", true), + "network_pow_registration_allowed" => { + ("sudo_set_network_pow_registration_allowed", true) + } + "yuma3_enabled" => ("sudo_set_yuma3_enabled", true), + "bonds_reset_enabled" => ("sudo_set_bonds_reset_enabled", true), + "transfers_enabled" => ("sudo_set_toggle_transfer", true), + "owner_cut_enabled" => ("sudo_set_owner_cut_enabled", true), + "owner_cut_auto_lock_enabled" => ("sudo_set_owner_cut_auto_lock_enabled", true), + other => { + return Err(CoreError::Policy(format!( + "unknown or owner-unsettable hyperparameter {other}" + ))) + } + }; + if is_bool && !matches!(value, Value::Bool(_)) { + return Err(CoreError::Policy(format!( + "hyperparameter {name} requires a boolean value" + ))); + } + // AdminUtils owner setters are all two positional fields `(netuid, + // value)`. Positional input deliberately avoids baking generated Rust + // field names into the semantic layer; live metadata remains the + // authority for both order and SCALE types. + Ok(Self::trusted( + "set_hyperparameter", + SignerRole::Coldkey, + "AdminUtils", + function, + Value::Tuple(vec![Value::Uint(u128::from(netuid)), value]), + Spend::None, + [netuid], + false, + )) + } + + /// Construct and validate the runtime's root-claim enum. + pub fn set_root_claim_type( + claim_type: &str, + subnets: Option>, + ) -> Result { + let value = match (claim_type, subnets) { + ("Swap" | "Keep", None) => Value::str(claim_type), + ("KeepSubnets", Some(mut subnets)) if !subnets.is_empty() => { + subnets.sort_unstable(); + subnets.dedup(); + Value::Dict(vec![( + Value::str("KeepSubnets"), + Value::record(vec![( + "subnets".into(), + Value::List( + subnets + .iter() + .copied() + .map(|netuid| Value::Uint(u128::from(netuid))) + .collect(), + ), + )]), + )]) + } + ("KeepSubnets", _) => { + return Err(CoreError::Policy( + "claim type KeepSubnets requires a non-empty subnets list".into(), + )) + } + ("Swap" | "Keep", Some(_)) => { + return Err(CoreError::Policy(format!( + "subnets are only valid for KeepSubnets, not {claim_type}" + ))) + } + (other, _) => { + return Err(CoreError::Policy(format!( + "unknown root claim type {other}" + ))) + } + }; + let netuids = match &value { + Value::Dict(_) => subnets_from_root_claim(&value), + _ => Vec::new(), + }; + Ok(Self::trusted( + "set_root_claim_type", + SignerRole::Coldkey, + "SubtensorModule", + "set_root_claim_type", + Value::record(vec![("new_root_claim_type".into(), value)]), + Spend::None, + netuids, + false, + )) + } + + /// Set a delegate take to an absolute value, selecting the runtime's + /// directional call from the current on-chain take. This mirrors the + /// semantic `set_take` intent without hard-coding a direction at callers. + pub fn set_take( + client: &Client, + hotkey: impl Into, + take: u16, + ) -> Result { + let hotkey = hotkey.into(); + let current_value = client.query( + "SubtensorModule", + "Delegates", + &[Value::str(hotkey.clone())], + None, + )?; + let current = if matches!(current_value, Value::Null) { + 0 + } else { + as_u128(¤t_value).ok_or_else(|| { + CoreError::Codec("SubtensorModule.Delegates is not an integer".into()) + })? + }; + let target = u128::from(take); + if target == current { + return Err(CoreError::Policy(format!( + "delegate take is already {take}/{}; nothing to change", + u16::MAX + ))); + } + let function = if target < current { + "decrease_take" + } else { + "increase_take" + }; + Ok(Self::trusted( + "set_take", + SignerRole::Coldkey, + "SubtensorModule", + function, + Value::record(vec![ + ("hotkey".into(), Value::str(hotkey)), + ("take".into(), Value::Uint(target)), + ]), + Spend::None, + [], + false, + ) + .summary(format!( + "set delegate take to {:.2}% ({take} as u16)", + f64::from(take) * 100.0 / f64::from(u16::MAX) + ))) + } + + pub fn encode(&self, client: &Client) -> Result, CoreError> { + client.compose_call(&self.pallet, &self.function, &self.params) + } + + /// Build an all-or-nothing `Utility.batch_all`, aggregating signer, spend, + /// and subnet policy metadata across every child. + pub fn batch(client: &Client, children: Vec) -> Result { + let Some(first) = children.first() else { + return Err(CoreError::Policy( + "batch requires at least one child".into(), + )); + }; + if children.iter().any(|child| child.op == "batch") { + return Err(CoreError::Policy("nested batches are not supported".into())); + } + if children.iter().any(|child| child.signer != first.signer) { + return Err(CoreError::Policy( + "all batched intents must share one signer".into(), + )); + } + let mut calls = Vec::with_capacity(children.len()); + let mut spend = Spend::None; + let mut netuids = BTreeSet::new(); + let mut affects_all_subnets = false; + let mut summaries = Vec::with_capacity(children.len()); + let mut raw = false; + for child in &children { + calls.push(Value::Bytes(child.encode(client)?)); + spend = aggregate_spend(spend, child.security.spend); + netuids.extend(child.security.netuids.iter().copied()); + affects_all_subnets |= child.security.affects_all_subnets; + raw |= child.security.raw; + summaries.push(child.summary.clone()); + } + Ok(Self { + op: "batch".into(), + summary: format!( + "atomic batch of {}: {}", + children.len(), + summaries.join("; ") + ), + signer: first.signer, + pallet: "Utility".into(), + function: "batch_all".into(), + params: Value::record(vec![("calls".into(), Value::List(calls))]), + security: SecuritySemantics { + spend, + netuids: netuids.into_iter().collect(), + affects_all_subnets, + raw, + }, + }) + } +} + +fn subnets_from_root_claim(value: &Value) -> Vec { + let Value::Dict(variants) = value else { + return Vec::new(); + }; + let Some((_, Value::Dict(fields))) = variants.first() else { + return Vec::new(); + }; + let Some((_, Value::List(subnets))) = fields + .iter() + .find(|(key, _)| matches!(key, Value::Str(name) if name == "subnets")) + else { + return Vec::new(); + }; + subnets + .iter() + .filter_map(|value| match value { + Value::Uint(value) => u16::try_from(*value).ok(), + Value::Int(value) if *value >= 0 => u16::try_from(*value).ok(), + _ => None, + }) + .collect() +} + +fn strictest_spend(left: Spend, right: Spend) -> Spend { + match (left, right) { + (Spend::Unbounded, _) | (_, Spend::Unbounded) => Spend::Unbounded, + (Spend::Bounded(left), Spend::Bounded(right)) => Spend::Bounded(left.max(right)), + (Spend::Bounded(value), Spend::None) | (Spend::None, Spend::Bounded(value)) => { + Spend::Bounded(value) + } + (Spend::None, Spend::None) => Spend::None, + } +} + +fn aggregate_spend(left: Spend, right: Spend) -> Spend { + match (left, right) { + (Spend::Unbounded, _) | (_, Spend::Unbounded) => Spend::Unbounded, + (Spend::None, other) | (other, Spend::None) => other, + (Spend::Bounded(left), Spend::Bounded(right)) => left + .checked_add(right) + .map_or(Spend::Unbounded, Spend::Bounded), + } +} + +/// Transaction guardrails enforced before any signature is created. +#[derive(Debug, Clone, Default)] +pub struct Policy { + pub max_fee_rao: Option, + pub max_spend_rao: Option, + pub allowed_netuids: Option>, + pub allow_raw_calls: bool, +} + +impl Policy { + pub fn check(&self, intent: &IntentCall, fee_rao: Option) -> Vec { + let mut violations = Vec::new(); + if intent.security.raw && !self.allow_raw_calls { + violations.push("raw calls are disabled by policy".into()); + } + if let Some(max_fee) = self.max_fee_rao { + match fee_rao { + Some(fee) if fee > max_fee => violations.push(format!( + "estimated fee {fee} rao exceeds max_fee_rao {max_fee}" + )), + None => violations + .push("fee estimation failed, so max_fee_rao cannot be enforced safely".into()), + _ => {} + } + } + if let Some(max_spend) = self.max_spend_rao { + match intent.security.spend { + Spend::Bounded(spend) if spend > max_spend => violations.push(format!( + "spend {spend} rao exceeds max_spend_rao {max_spend}" + )), + Spend::Unbounded => violations + .push("unbounded spend is not allowed while max_spend_rao is set".into()), + _ => {} + } + } + if let Some(allowed) = &self.allowed_netuids { + if intent.security.affects_all_subnets { + violations.push( + "intent affects every subnet but policy only allows an explicit subset".into(), + ); + } + for netuid in &intent.security.netuids { + if !allowed.contains(netuid) { + violations.push(format!("netuid {netuid} is not allowed by policy")); + } + } + } + violations + } +} + +/// Dry-run output. The exact call bytes in this plan are the bytes later signed. +#[derive(Debug, Clone)] +pub struct Plan { + pub op: String, + pub summary: String, + pub signer: SignerRole, + pub signer_address: String, + pub fee_rao: Option, + pub warnings: Vec, + pub violations: Vec, + pub call_data: Vec, +} + +impl Plan { + pub fn ok(&self) -> bool { + self.violations.is_empty() + } +} + +/// The one execution choke point for plans, policy, signing, and submission. +pub struct Executor<'a> { + client: &'a Client, + policy: Option, +} + +impl<'a> Executor<'a> { + pub fn new(client: &'a Client) -> Self { + Self { + client, + policy: None, + } + } + + pub fn with_policy(client: &'a Client, policy: Policy) -> Self { + Self { + client, + policy: Some(policy), + } + } + + pub fn plan(&self, intent: &IntentCall, wallet: &Wallet) -> Result { + self.plan_with_proxy(intent, wallet, None, None, None) + } + + pub fn plan_with_policy( + &self, + intent: &IntentCall, + wallet: &Wallet, + policy: &Policy, + ) -> Result { + self.plan_with_proxy(intent, wallet, Some(policy), None, None) + } + + pub fn plan_with_proxy( + &self, + intent: &IntentCall, + wallet: &Wallet, + policy: Option<&Policy>, + proxy_for: Option<&str>, + proxy_type: Option<&str>, + ) -> Result { + let mut call_data = intent.encode(self.client)?; + if let Some(real) = proxy_for { + call_data = self.client.compose_call( + "Proxy", + "proxy", + &Value::record(vec![ + ("real".into(), Value::str(real)), + ( + "force_proxy_type".into(), + proxy_type.map_or(Value::Null, Value::str), + ), + ("call".into(), Value::Bytes(call_data)), + ]), + )?; + } + let signer = wallet.signer(intent.signer); + let mut warnings = Vec::new(); + let fee_rao = match self.client.estimate_fee(&call_data, signer) { + Ok(fee) => Some(fee), + Err(error) => { + warnings.push(format!("could not estimate fee: {error}")); + None + } + }; + let active = policy.or(self.policy.as_ref()); + let violations = active.map_or_else(Vec::new, |policy| policy.check(intent, fee_rao)); + Ok(Plan { + op: intent.op.clone(), + summary: intent.summary.clone(), + signer: intent.signer, + signer_address: signer.ss58_address(), + fee_rao, + warnings, + violations, + call_data, + }) + } + + pub fn execute(&self, intent: &IntentCall, wallet: &Wallet) -> Result { + self.execute_with(intent, wallet, None, None, None, true) + } + + pub fn execute_with( + &self, + intent: &IntentCall, + wallet: &Wallet, + policy: Option<&Policy>, + proxy_for: Option<&str>, + proxy_type: Option<&str>, + wait_for_finalization: bool, + ) -> Result { + let plan = self.plan_with_proxy(intent, wallet, policy, proxy_for, proxy_type)?; + if !plan.ok() { + return Err(CoreError::Policy(plan.violations.join("; "))); + } + self.client.submit( + &plan.call_data, + wallet.signer(intent.signer), + None, + Some(64), + wait_for_finalization, + ) + } + + pub fn submit_shielded( + &self, + intent: &IntentCall, + wallet: &Wallet, + policy: Option<&Policy>, + ) -> Result { + let plan = self.plan_with_proxy(intent, wallet, policy, None, None)?; + if !plan.ok() { + return Err(CoreError::Policy(plan.violations.join("; "))); + } + self.client + .submit_shielded(&plan.call_data, wallet.signer(intent.signer), false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arbitrary_call_fails_closed_for_every_policy_dimension() { + let intent = IntentCall::new( + "caller_supplied", + SignerRole::Coldkey, + "Balances", + "transfer_keep_alive", + Value::record(vec![ + ("dest".into(), Value::str("5caller-controlled")), + ("value".into(), Value::Uint(1)), + ]), + ); + let policy = Policy { + max_spend_rao: Some(u128::MAX), + allowed_netuids: Some(BTreeSet::from([1])), + allow_raw_calls: false, + ..Policy::default() + }; + + let violations = policy.check(&intent, Some(0)); + assert!(violations + .iter() + .any(|violation| violation == "raw calls are disabled by policy")); + assert!(violations + .iter() + .any(|violation| violation + == "unbounded spend is not allowed while max_spend_rao is set")); + assert!(violations.iter().any(|violation| violation + == "intent affects every subnet but policy only allows an explicit subset")); + } + + #[test] + fn allowing_raw_does_not_disable_spend_or_subnet_guardrails() { + let intent = IntentCall::raw_call( + "caller_supplied", + SignerRole::Coldkey, + "Sudo", + "sudo", + Value::Null, + ); + let policy = Policy { + max_spend_rao: Some(u128::MAX), + allowed_netuids: Some(BTreeSet::from([1])), + allow_raw_calls: true, + ..Policy::default() + }; + + assert_eq!( + policy.check(&intent, Some(0)), + vec![ + String::from("unbounded spend is not allowed while max_spend_rao is set"), + String::from( + "intent affects every subnet but policy only allows an explicit subset", + ), + ] + ); + } + + #[test] + fn trusted_transfer_uses_constructor_derived_bounded_spend() { + let intent = IntentCall::transfer("5trusted-destination", 10); + let exact = Policy { + max_spend_rao: Some(10), + ..Policy::default() + }; + assert!(exact.check(&intent, Some(0)).is_empty()); + + let too_small = Policy { + max_spend_rao: Some(9), + ..Policy::default() + }; + assert_eq!( + too_small.check(&intent, Some(0)), + vec![String::from("spend 10 rao exceeds max_spend_rao 9")] + ); + } + + #[test] + fn trusted_move_stake_uses_constructor_derived_netuids() { + let intent = IntentCall::move_stake("5origin", 1, "5destination", 2, 10); + let policy = Policy { + allowed_netuids: Some(BTreeSet::from([1])), + ..Policy::default() + }; + assert_eq!( + policy.check(&intent, Some(0)), + vec![String::from("netuid 2 is not allowed by policy")] + ); + } + + #[test] + fn compatibility_builders_cannot_downgrade_arbitrary_calls() { + let intent = IntentCall::new( + "caller_supplied", + SignerRole::Coldkey, + "Balances", + "transfer_keep_alive", + Value::record(vec![("value".into(), Value::Uint(1))]), + ) + .spend(Spend::None) + .touches([]); + let policy = Policy { + max_spend_rao: Some(u128::MAX), + allowed_netuids: Some(BTreeSet::from([1])), + allow_raw_calls: true, + ..Policy::default() + }; + + assert_eq!( + policy.check(&intent, Some(0)), + vec![ + String::from("unbounded spend is not allowed while max_spend_rao is set"), + String::from( + "intent affects every subnet but policy only allows an explicit subset", + ), + ] + ); + } + + #[test] + fn compatibility_builders_cannot_lower_trusted_spend_or_scope() { + let intent = IntentCall::transfer("5trusted-destination", 10) + .spend(Spend::None) + .touches([2]); + let policy = Policy { + max_spend_rao: Some(9), + allowed_netuids: Some(BTreeSet::from([1])), + ..Policy::default() + }; + + assert_eq!( + policy.check(&intent, Some(0)), + vec![ + String::from("spend 10 rao exceeds max_spend_rao 9"), + String::from("netuid 2 is not allowed by policy"), + ] + ); + } +} diff --git a/sdk/bittensor-core/tests/Dockerfile.localnet-fast b/sdk/bittensor-core/tests/Dockerfile.localnet-fast new file mode 100644 index 0000000000..135f4789e5 --- /dev/null +++ b/sdk/bittensor-core/tests/Dockerfile.localnet-fast @@ -0,0 +1,28 @@ +ARG BASE_IMAGE=ubuntu:latest + +FROM $BASE_IMAGE + +ARG BUILD_TRIPLE=x86_64-unknown-linux-gnu + +ENV RUST_BACKTRACE=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY build/ci_target/fast-runtime/${BUILD_TRIPLE}/release/node-subtensor \ + /target/fast-runtime/release/node-subtensor +COPY build/ci_target/fast-runtime/${BUILD_TRIPLE}/release/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm \ + /target/fast-runtime/release/node_subtensor_runtime.compact.compressed.wasm +COPY snapshot.json /snapshot.json +COPY scripts/localnet.sh /scripts/localnet.sh + +RUN chmod +x /target/fast-runtime/release/node-subtensor /scripts/localnet.sh + +ENV BUILD_BINARY=0 +ENV RUN_IN_DOCKER=1 + +EXPOSE 30334 30335 9944 9945 + +ENTRYPOINT ["/scripts/localnet.sh"] +CMD ["True"] diff --git a/sdk/bittensor-core/tests/e2e-manifest.json b/sdk/bittensor-core/tests/e2e-manifest.json new file mode 100644 index 0000000000..f9add51f30 --- /dev/null +++ b/sdk/bittensor-core/tests/e2e-manifest.json @@ -0,0 +1,341 @@ +[ + { + "test": "intent_add_proxy" + }, + { + "test": "intent_add_stake" + }, + { + "test": "intent_add_stake_limit" + }, + { + "test": "intent_announce_coldkey_swap" + }, + { + "test": "intent_associate_evm_key" + }, + { + "test": "intent_associate_hotkey" + }, + { + "test": "intent_batch" + }, + { + "test": "intent_burned_register" + }, + { + "test": "intent_claim_root" + }, + { + "test": "intent_clear_coldkey_swap_announcement" + }, + { + "test": "intent_commit_weights" + }, + { + "test": "intent_contribute_crowdloan" + }, + { + "test": "intent_create_crowdloan" + }, + { + "test": "intent_create_pure_proxy" + }, + { + "test": "intent_decrease_take" + }, + { + "test": "intent_dispute_coldkey_swap" + }, + { + "test": "intent_dissolve_crowdloan" + }, + { + "test": "intent_evm_withdraw" + }, + { + "test": "intent_execute_proxy_announced" + }, + { + "test": "intent_finalize_crowdloan" + }, + { + "test": "intent_fund_evm_key" + }, + { + "test": "intent_increase_take" + }, + { + "test": "intent_kill_pure_proxy" + }, + { + "test": "intent_lock_stake" + }, + { + "test": "intent_move_lock" + }, + { + "test": "intent_move_stake" + }, + { + "test": "intent_multisig_approve" + }, + { + "test": "intent_multisig_cancel" + }, + { + "test": "intent_multisig_execute" + }, + { + "test": "intent_multisig_threshold_1" + }, + { + "test": "intent_refund_crowdloan" + }, + { + "test": "intent_register_leased_network" + }, + { + "test": "intent_register_subnet" + }, + { + "test": "intent_remove_proxies" + }, + { + "test": "intent_remove_proxy" + }, + { + "test": "intent_remove_stake" + }, + { + "test": "intent_remove_stake_limit" + }, + { + "test": "intent_reset_axon" + }, + { + "test": "intent_reveal_weights" + }, + { + "test": "intent_root_register" + }, + { + "test": "intent_serve_axon" + }, + { + "test": "intent_serve_axon_tls" + }, + { + "test": "intent_serve_prometheus" + }, + { + "test": "intent_set_auto_stake" + }, + { + "test": "intent_set_childkey_take" + }, + { + "test": "intent_set_children" + }, + { + "test": "intent_set_crowdloan_max_contribution" + }, + { + "test": "intent_set_hyperparameter" + }, + { + "test": "intent_set_identity" + }, + { + "test": "intent_set_mechanism_count" + }, + { + "test": "intent_set_mechanism_emission_split" + }, + { + "test": "intent_set_perpetual_lock" + }, + { + "test": "intent_set_root_claim_type" + }, + { + "test": "intent_set_subnet_identity" + }, + { + "test": "intent_set_take" + }, + { + "test": "intent_set_weights" + }, + { + "test": "intent_stake_burn" + }, + { + "test": "intent_start_call" + }, + { + "test": "intent_swap_coldkey_announced" + }, + { + "test": "intent_swap_hotkey" + }, + { + "test": "intent_swap_stake" + }, + { + "test": "intent_terminate_lease" + }, + { + "test": "intent_transfer" + }, + { + "test": "intent_transfer_all" + }, + { + "test": "intent_transfer_stake" + }, + { + "test": "intent_trim_subnet" + }, + { + "test": "intent_unstake_all" + }, + { + "test": "intent_unstake_all_alpha" + }, + { + "test": "intent_update_crowdloan_cap" + }, + { + "test": "intent_update_crowdloan_end" + }, + { + "test": "intent_update_crowdloan_min_contribution" + }, + { + "test": "intent_update_symbol" + }, + { + "test": "intent_withdraw_crowdloan" + }, + { + "test": "test_batch_applies_all_children_atomically" + }, + { + "test": "test_failed_batch_reverts_everything" + }, + { + "test": "test_policy_aggregates_spend_across_batch" + }, + { + "test": "test_mev_shield_next_key_is_mlkem768" + }, + { + "test": "test_submit_shielded_runs_full_pipeline" + }, + { + "test": "test_crowdloan_create_read_update" + }, + { + "test": "test_nonexistent_subnet_maps_to_semantic_code" + }, + { + "test": "test_compose_nested_sudo_call" + }, + { + "test": "test_raw_call_escape_hatch_and_commitment" + }, + { + "test": "test_root_claim_type_variants" + }, + { + "test": "test_keep_subnets_requires_subnets" + }, + { + "test": "test_coldkey_swap_announcement_flow" + }, + { + "test": "test_key_association" + }, + { + "test": "test_plan_simulates_real_fee" + }, + { + "test": "test_policy_blocks_with_live_fee" + }, + { + "test": "test_spend_cap_blocks_value_movers" + }, + { + "test": "test_netuid_allowlist_blocks_live" + }, + { + "test": "test_pending_op_open_read_cancel" + }, + { + "test": "test_account_object_m_of_n_executes" + }, + { + "test": "test_proxy_flow" + }, + { + "test": "test_block_number" + }, + { + "test": "test_subnets_all_batched" + }, + { + "test": "test_balance_and_existential_deposit" + }, + { + "test": "test_generic_accessors_over_generated_descriptors" + }, + { + "test": "test_reads_catalog_nonempty" + }, + { + "test": "test_typed_reads" + }, + { + "test": "test_quote_stake_slippage_and_fee" + }, + { + "test": "test_metagraph_fast_path_matches_runtime" + }, + { + "test": "test_snapshot_pins_reads_to_one_block" + }, + { + "test": "test_leases_read" + }, + { + "test": "test_block_subscription_streams_increasing_headers" + }, + { + "test": "test_transfer_moves_exactly_the_amount" + }, + { + "test": "test_add_stake_increases_stake" + }, + { + "test": "test_delegation_lifecycle" + }, + { + "test": "test_owned_subnet_is_registered" + }, + { + "test": "test_serving_endpoints" + }, + { + "test": "test_owner_sets_hyperparameter" + }, + { + "test": "test_unsettable_hyperparameter_rejected_at_construction" + }, + { + "test": "test_identity_coldkey_and_subnet" + }, + { + "test": "test_auto_stake_destination" + } +] diff --git a/sdk/bittensor-core/tests/e2e.rs b/sdk/bittensor-core/tests/e2e.rs new file mode 100644 index 0000000000..ec822e09a8 --- /dev/null +++ b/sdk/bittensor-core/tests/e2e.rs @@ -0,0 +1,1541 @@ +#![allow( + clippy::arithmetic_side_effects, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::unwrap_used +)] + +mod e2e_support; + +use std::collections::BTreeSet; + +use bittensor_core::client::{as_str, as_u128, field, value_bytes, variant_name}; +use bittensor_core::codec::Value; +use bittensor_core::transaction::{Executor, IntentCall, Policy, SignerRole, Spend, Wallet}; +use bittensor_core::CoreError; +use e2e_support::*; + +fn value_list(value: &Value) -> &[Value] { + match value { + Value::List(values) => values, + other => panic!("expected list, got {other:#?}"), + } +} + +fn tuple(value: &Value) -> &[Value] { + match value { + Value::Tuple(values) => values, + other => panic!("expected tuple, got {other:#?}"), + } +} + +fn value_contains_str(value: &Value, expected: &str) -> bool { + match value { + Value::Str(value) => value == expected, + Value::Bytes(bytes) => std::str::from_utf8(bytes).is_ok_and(|value| value == expected), + Value::List(values) | Value::Tuple(values) => values + .iter() + .any(|value| value_contains_str(value, expected)), + Value::Dict(entries) => entries.iter().any(|(key, value)| { + value_contains_str(key, expected) || value_contains_str(value, expected) + }), + _ => false, + } +} + +fn text_bytes(value: &Value) -> Option { + value_bytes(value).and_then(|bytes| String::from_utf8(bytes).ok()) +} + +fn value_contains_u128(value: &Value, expected: u128) -> bool { + match value { + Value::Uint(value) => *value == expected, + Value::Int(value) => u128::try_from(*value).ok() == Some(expected), + Value::List(values) | Value::Tuple(values) => values + .iter() + .any(|value| value_contains_u128(value, expected)), + Value::Dict(entries) => entries.iter().any(|(key, value)| { + value_contains_u128(key, expected) || value_contains_u128(value, expected) + }), + _ => false, + } +} + +macro_rules! intent_plan_test { + ($name:ident, $op:literal) => { + #[test] + fn $name() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let intent = sample_intent(&ctx, $op, netuid).expect("Rust SDK intent sample"); + let plan = ctx + .executor() + .plan(&intent, &ctx.alice) + .unwrap_or_else(|error| panic!("{} failed to plan: {error}", $op)); + assert_eq!(plan.op, $op); + assert!(!plan.call_data.is_empty()); + } + }; +} + +intent_plan_test!(intent_add_proxy, "add_proxy"); +intent_plan_test!(intent_add_stake, "add_stake"); +intent_plan_test!(intent_add_stake_limit, "add_stake_limit"); +intent_plan_test!(intent_announce_coldkey_swap, "announce_coldkey_swap"); +intent_plan_test!(intent_associate_evm_key, "associate_evm_key"); +intent_plan_test!(intent_associate_hotkey, "associate_hotkey"); +intent_plan_test!(intent_batch, "batch"); +intent_plan_test!(intent_burned_register, "burned_register"); +intent_plan_test!(intent_claim_root, "claim_root"); +intent_plan_test!( + intent_clear_coldkey_swap_announcement, + "clear_coldkey_swap_announcement" +); +intent_plan_test!(intent_commit_weights, "commit_weights"); +intent_plan_test!(intent_contribute_crowdloan, "contribute_crowdloan"); +intent_plan_test!(intent_create_crowdloan, "create_crowdloan"); +intent_plan_test!(intent_create_pure_proxy, "create_pure_proxy"); +intent_plan_test!(intent_decrease_take, "decrease_take"); +intent_plan_test!(intent_dispute_coldkey_swap, "dispute_coldkey_swap"); +intent_plan_test!(intent_dissolve_crowdloan, "dissolve_crowdloan"); +intent_plan_test!(intent_evm_withdraw, "evm_withdraw"); +intent_plan_test!(intent_execute_proxy_announced, "execute_proxy_announced"); +intent_plan_test!(intent_finalize_crowdloan, "finalize_crowdloan"); +intent_plan_test!(intent_fund_evm_key, "fund_evm_key"); +intent_plan_test!(intent_increase_take, "increase_take"); +intent_plan_test!(intent_kill_pure_proxy, "kill_pure_proxy"); +intent_plan_test!(intent_lock_stake, "lock_stake"); +intent_plan_test!(intent_move_lock, "move_lock"); +intent_plan_test!(intent_move_stake, "move_stake"); +intent_plan_test!(intent_multisig_approve, "multisig_approve"); +intent_plan_test!(intent_multisig_cancel, "multisig_cancel"); +intent_plan_test!(intent_multisig_execute, "multisig_execute"); +intent_plan_test!(intent_multisig_threshold_1, "multisig_threshold_1"); +intent_plan_test!(intent_refund_crowdloan, "refund_crowdloan"); +intent_plan_test!(intent_register_leased_network, "register_leased_network"); +intent_plan_test!(intent_register_subnet, "register_subnet"); +intent_plan_test!(intent_remove_proxies, "remove_proxies"); +intent_plan_test!(intent_remove_proxy, "remove_proxy"); +intent_plan_test!(intent_remove_stake, "remove_stake"); +intent_plan_test!(intent_remove_stake_limit, "remove_stake_limit"); +intent_plan_test!(intent_reset_axon, "reset_axon"); +intent_plan_test!(intent_reveal_weights, "reveal_weights"); +intent_plan_test!(intent_root_register, "root_register"); +intent_plan_test!(intent_serve_axon, "serve_axon"); +intent_plan_test!(intent_serve_axon_tls, "serve_axon_tls"); +intent_plan_test!(intent_serve_prometheus, "serve_prometheus"); +intent_plan_test!(intent_set_auto_stake, "set_auto_stake"); +intent_plan_test!(intent_set_childkey_take, "set_childkey_take"); +intent_plan_test!(intent_set_children, "set_children"); +intent_plan_test!( + intent_set_crowdloan_max_contribution, + "set_crowdloan_max_contribution" +); +intent_plan_test!(intent_set_hyperparameter, "set_hyperparameter"); +intent_plan_test!(intent_set_identity, "set_identity"); +intent_plan_test!(intent_set_mechanism_count, "set_mechanism_count"); +intent_plan_test!( + intent_set_mechanism_emission_split, + "set_mechanism_emission_split" +); +intent_plan_test!(intent_set_perpetual_lock, "set_perpetual_lock"); +intent_plan_test!(intent_set_root_claim_type, "set_root_claim_type"); +intent_plan_test!(intent_set_subnet_identity, "set_subnet_identity"); +intent_plan_test!(intent_set_take, "set_take"); +intent_plan_test!(intent_set_weights, "set_weights"); +intent_plan_test!(intent_stake_burn, "stake_burn"); +intent_plan_test!(intent_start_call, "start_call"); +intent_plan_test!(intent_swap_coldkey_announced, "swap_coldkey_announced"); +intent_plan_test!(intent_swap_hotkey, "swap_hotkey"); +intent_plan_test!(intent_swap_stake, "swap_stake"); +intent_plan_test!(intent_terminate_lease, "terminate_lease"); +intent_plan_test!(intent_transfer, "transfer"); +intent_plan_test!(intent_transfer_all, "transfer_all"); +intent_plan_test!(intent_transfer_stake, "transfer_stake"); +intent_plan_test!(intent_trim_subnet, "trim_subnet"); +intent_plan_test!(intent_unstake_all, "unstake_all"); +intent_plan_test!(intent_unstake_all_alpha, "unstake_all_alpha"); +intent_plan_test!(intent_update_crowdloan_cap, "update_crowdloan_cap"); +intent_plan_test!(intent_update_crowdloan_end, "update_crowdloan_end"); +intent_plan_test!( + intent_update_crowdloan_min_contribution, + "update_crowdloan_min_contribution" +); +intent_plan_test!(intent_update_symbol, "update_symbol"); +intent_plan_test!(intent_withdraw_crowdloan, "withdraw_crowdloan"); + +#[test] +fn test_batch_applies_all_children_atomically() { + let ctx = TestContext::new(); + let dave = Wallet::from_uris("//Dave", "//Dave//hot") + .expect("Dave wallet") + .coldkey + .ss58_address(); + let eve = Wallet::from_uris("//Eve", "//Eve//hot") + .expect("Eve wallet") + .coldkey + .ss58_address(); + let dave_before = ctx.client.balance_rao(&dave).expect("Dave balance"); + let eve_before = ctx.client.balance_rao(&eve).expect("Eve balance"); + let batch = IntentCall::batch( + &ctx.client, + vec![ + transfer(dave.clone(), amount_tao(1)), + transfer(eve.clone(), amount_tao(2)), + ], + ) + .expect("batch composes"); + let result = ctx + .executor() + .execute(&batch, &ctx.alice) + .expect("batch submits"); + assert_success(&result); + assert_eq!( + ctx.client.balance_rao(&dave).expect("Dave balance") - dave_before, + amount_tao(1) + ); + assert_eq!( + ctx.client.balance_rao(&eve).expect("Eve balance") - eve_before, + amount_tao(2) + ); +} + +#[test] +fn test_failed_batch_reverts_everything() { + let ctx = TestContext::new(); + let dave = Wallet::from_uris("//Dave", "//Dave//hot") + .expect("Dave wallet") + .coldkey + .ss58_address(); + let eve = Wallet::from_uris("//Eve", "//Eve//hot") + .expect("Eve wallet") + .coldkey + .ss58_address(); + let dave_before = ctx.client.balance_rao(&dave).expect("Dave balance"); + let batch = IntentCall::batch( + &ctx.client, + vec![ + transfer(dave.clone(), amount_tao(1)), + transfer(eve, amount_tao(10_000_000_000)), + ], + ) + .expect("batch composes"); + let result = ctx + .executor() + .execute(&batch, &ctx.alice) + .expect("batch submits"); + assert!(!result.success); + assert_eq!( + ctx.client.balance_rao(&dave).expect("Dave balance"), + dave_before + ); +} + +#[test] +fn test_policy_aggregates_spend_across_batch() { + let ctx = TestContext::new(); + let dave = Wallet::from_uris("//Dave", "//Dave//hot") + .expect("Dave wallet") + .coldkey + .ss58_address(); + let eve = Wallet::from_uris("//Eve", "//Eve//hot") + .expect("Eve wallet") + .coldkey + .ss58_address(); + let batch = IntentCall::batch( + &ctx.client, + vec![transfer(dave, 600_000_000), transfer(eve, 600_000_000)], + ) + .expect("batch composes"); + let policy = Policy { + max_spend_rao: Some(amount_tao(1)), + ..Policy::default() + }; + let plan = ctx + .executor() + .plan_with_policy(&batch, &ctx.alice, &policy) + .expect("batch plans"); + assert!(!plan.ok()); +} + +#[test] +fn test_mev_shield_next_key_is_mlkem768() { + let ctx = TestContext::new(); + let key = ctx + .client + .query("MevShield", "NextKey", &[], None) + .expect("NextKey read"); + assert_eq!(value_bytes(&key).expect("NextKey bytes").len(), 1_184); +} + +#[test] +fn test_submit_shielded_runs_full_pipeline() { + let ctx = TestContext::new(); + let intent = transfer(ctx.bob.coldkey.ss58_address(), amount_tao(2)); + let result = ctx + .executor() + .submit_shielded(&intent, &ctx.alice, None) + .expect("shielded pipeline returns a typed outcome"); + assert!(!result.message.is_empty()); +} + +#[test] +fn test_crowdloan_create_read_update() { + let ctx = TestContext::new(); + let current = ctx.client.block_number().expect("block number"); + let create = IntentCall::new( + "create_crowdloan", + SignerRole::Coldkey, + "Crowdloan", + "create", + record([ + ("deposit", u128v(amount_tao(100))), + ("min_contribution", u128v(amount_tao(1))), + ("cap", u128v(amount_tao(1_000))), + ("end", u64v(current.saturating_add(5_000))), + ("call", Value::Null), + ("target_address", s(ctx.bob.coldkey.ss58_address())), + ]), + ) + .spend(Spend::Bounded(amount_tao(100))); + let result = ctx + .executor() + .execute(&create, &ctx.alice) + .expect("create submits"); + assert_success(&result); + + let next = ctx + .client + .query("Crowdloan", "NextCrowdloanId", &[], None) + .expect("next id"); + let id = u32::try_from(as_u128(&next).expect("integer next id") - 1).expect("u32 id"); + let info = ctx + .client + .query("Crowdloan", "Crowdloans", &[u32v(id)], None) + .expect("crowdloan read"); + let alice_cold = ctx.alice.coldkey.ss58_address(); + assert_eq!( + field(&info, "creator").and_then(as_str), + Some(alice_cold.as_str()) + ); + assert!(field(&info, "raised").and_then(as_u128).unwrap_or_default() >= amount_tao(100)); + assert_eq!( + field(&info, "cap").and_then(as_u128), + Some(amount_tao(1_000)) + ); + + let update = IntentCall::new( + "update_crowdloan_cap", + SignerRole::Coldkey, + "Crowdloan", + "update_cap", + record([ + ("crowdloan_id", u32v(id)), + ("new_cap", u128v(amount_tao(2_000))), + ]), + ); + let result = ctx + .executor() + .execute(&update, &ctx.alice) + .expect("update submits"); + assert_success(&result); + let info = ctx + .client + .query("Crowdloan", "Crowdloans", &[u32v(id)], None) + .expect("crowdloan read"); + assert_eq!( + field(&info, "cap").and_then(as_u128), + Some(amount_tao(2_000)) + ); +} + +#[test] +fn test_nonexistent_subnet_maps_to_semantic_code() { + let ctx = TestContext::new(); + let intent = IntentCall::new( + "burned_register", + SignerRole::Coldkey, + "SubtensorModule", + "burned_register", + record([ + ("netuid", u16v(999)), + ("hotkey", s(ctx.alice.hotkey.ss58_address())), + ]), + ); + let result = ctx + .executor() + .execute(&intent, &ctx.alice) + .expect("call submits"); + assert!(!result.success); + assert_eq!( + result + .error + .as_ref() + .map(|error| error.semantic_code.as_str()), + Some("subnet_not_exists") + ); +} + +#[test] +fn test_compose_nested_sudo_call() { + let ctx = TestContext::new(); + let before = ctx + .client + .query("SubtensorModule", "TxRateLimit", &[], None) + .expect("TxRateLimit read"); + let before = as_u128(&before).expect("TxRateLimit integer"); + let inner = ctx + .client + .compose_call( + "AdminUtils", + "sudo_set_tx_rate_limit", + &record([("tx_rate_limit", u128v(before.saturating_add(1)))]), + ) + .expect("admin call composes"); + let outer = IntentCall::new( + "raw_sudo", + SignerRole::Coldkey, + "Sudo", + "sudo", + record([("call", bytes(inner))]), + ) + .raw(); + let result = ctx + .executor() + .execute(&outer, &ctx.alice) + .expect("sudo submits"); + assert_success(&result); + let after = ctx + .client + .query("SubtensorModule", "TxRateLimit", &[], None) + .expect("TxRateLimit read"); + assert_eq!(as_u128(&after), Some(before.saturating_add(1))); +} + +#[test] +fn test_raw_call_escape_hatch_and_commitment() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let hotkey = ctx.alice.hotkey.ss58_address(); + let info = record([( + "fields", + list([list([Value::Dict(vec![( + s("Raw5"), + bytes(b"hello".to_vec()), + )])])]), + )]); + let raw = IntentCall::new( + "raw_commitment", + SignerRole::Hotkey, + "Commitments", + "set_commitment", + record([("netuid", u(netuid)), ("info", info)]), + ) + .touches([netuid]) + .raw(); + let policy = Policy { + max_spend_rao: Some(amount_tao(100)), + ..Policy::default() + }; + let plan = ctx + .executor() + .plan_with_policy(&raw, &ctx.alice, &policy) + .expect("raw call plans"); + assert!(!plan.ok()); + + let result = ctx + .executor() + .execute(&raw, &ctx.alice) + .expect("commitment submits"); + assert_success(&result); + let commitment = ctx + .client + .query( + "Commitments", + "CommitmentOf", + &[u(netuid), s(hotkey.clone())], + None, + ) + .expect("commitment read"); + assert!(!matches!(commitment, Value::Null)); + assert!( + value_contains_str(&commitment, "hello") || value_contains_str(&commitment, "0x68656c6c6f") + ); + let revealed = ctx + .client + .query( + "Commitments", + "RevealedCommitments", + &[u(netuid), s(hotkey)], + None, + ) + .expect("revealed commitment read"); + assert!(matches!(revealed, Value::Null)); +} + +#[test] +fn test_root_claim_type_variants() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let coldkey = ctx.alice.coldkey.ss58_address(); + + for (claim, subnets, expected) in [ + ("Swap", None, "Swap"), + ("KeepSubnets", Some(vec![netuid]), "KeepSubnets"), + ("Keep", None, "Keep"), + ] { + let intent = IntentCall::set_root_claim_type(claim, subnets).expect("valid claim type"); + let result = ctx + .executor() + .execute(&intent, &ctx.alice) + .expect("claim type submits"); + assert_success(&result); + let stored = ctx + .client + .query( + "SubtensorModule", + "RootClaimType", + &[s(coldkey.clone())], + None, + ) + .expect("claim type read"); + assert_eq!( + variant_name(&stored).as_deref().or_else(|| as_str(&stored)), + Some(expected) + ); + if expected == "KeepSubnets" { + assert!(value_contains_u128(&stored, u128::from(netuid))); + } + } +} + +#[test] +fn test_keep_subnets_requires_subnets() { + assert!(IntentCall::set_root_claim_type("KeepSubnets", None).is_err()); +} + +#[test] +fn test_coldkey_swap_announcement_flow() { + let ctx = TestContext::new(); + let swapper = random_wallet(); + let new_cold = random_wallet().coldkey.ss58_address(); + let swapper_address = swapper.coldkey.ss58_address(); + let before = ctx + .client + .query( + "SubtensorModule", + "ColdkeySwapAnnouncements", + &[s(swapper_address.clone())], + None, + ) + .expect("announcement read"); + assert!(matches!(before, Value::Null)); + + let funding = transfer(swapper_address.clone(), amount_tao(2)); + let funded = ctx + .executor() + .execute(&funding, &ctx.alice) + .expect("fund swapper"); + assert_success(&funded); + + let hash = bittensor_core::keys::public_key_from_ss58(&new_cold) + .map(|public| sp_core::hashing::blake2_256(&public)) + .expect("new coldkey public key"); + let announce = IntentCall::new( + "announce_coldkey_swap", + SignerRole::Coldkey, + "SubtensorModule", + "announce_coldkey_swap", + record([("new_coldkey_hash", bytes(hash.to_vec()))]), + ); + let result = Executor::new(&ctx.client) + .execute(&announce, &swapper) + .expect("announcement submits"); + assert_success(&result); + let announcement = ctx + .client + .query( + "SubtensorModule", + "ColdkeySwapAnnouncements", + &[s(swapper_address.clone())], + None, + ) + .expect("announcement read"); + assert!(!matches!(announcement, Value::Null)); + assert!( + tuple(&announcement) + .first() + .and_then(as_u128) + .unwrap_or_default() + > 0 + ); + assert_eq!( + value_bytes(&tuple(&announcement)[1]).as_deref(), + Some(hash.as_slice()) + ); + let disputed = ctx + .client + .query( + "SubtensorModule", + "ColdkeySwapDisputes", + &[s(swapper_address.clone())], + None, + ) + .expect("coldkey swap dispute read"); + assert_eq!(as_u128(&disputed).unwrap_or_default(), 0); + + let early = IntentCall::new( + "swap_coldkey_announced", + SignerRole::Coldkey, + "SubtensorModule", + "swap_coldkey_announced", + record([("new_coldkey", s(new_cold))]), + ); + let result = Executor::new(&ctx.client) + .execute(&early, &swapper) + .expect("early swap submits"); + assert!(!result.success); +} + +#[test] +fn test_key_association() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let intent = IntentCall::new( + "associate_hotkey", + SignerRole::Coldkey, + "SubtensorModule", + "try_associate_hotkey", + record([("hotkey", s(ctx.alice.hotkey.ss58_address()))]), + ); + let result = ctx + .executor() + .execute(&intent, &ctx.alice) + .expect("association submits"); + assert_success(&result); + let associated = ctx + .client + .query( + "SubtensorModule", + "AssociatedEvmAddress", + &[u(netuid), u16v(0)], + None, + ) + .expect("associated EVM key read"); + assert!(matches!(associated, Value::Null)); +} + +#[test] +fn test_plan_simulates_real_fee() { + let ctx = TestContext::new(); + let intent = transfer(ctx.bob.coldkey.ss58_address(), amount_tao(1)); + let plan = ctx + .executor() + .plan(&intent, &ctx.alice) + .expect("transfer plans"); + assert!(plan.fee_rao.is_some_and(|fee| fee > 0)); +} + +#[test] +fn test_policy_blocks_with_live_fee() { + let ctx = TestContext::new(); + let intent = transfer(ctx.bob.coldkey.ss58_address(), amount_tao(5)); + let policy = Policy { + max_spend_rao: Some(amount_tao(1)), + ..Policy::default() + }; + let result = ctx + .executor() + .execute_with(&intent, &ctx.alice, Some(&policy), None, None, true); + assert!(matches!(result, Err(CoreError::Policy(_)))); +} + +#[test] +fn test_spend_cap_blocks_value_movers() { + let ctx = TestContext::new(); + let cap = Policy { + max_spend_rao: Some(amount_tao(1)), + ..Policy::default() + }; + let transfer_stake = IntentCall::new( + "transfer_stake", + SignerRole::Coldkey, + "SubtensorModule", + "transfer_stake", + record([ + ("destination_coldkey", s(ctx.bob.coldkey.ss58_address())), + ("hotkey", s(ctx.bob.hotkey.ss58_address())), + ("origin_netuid", u16v(1)), + ("destination_netuid", u16v(1)), + ("alpha_amount", u128v(amount_tao(1))), + ]), + ) + .spend(Spend::Unbounded) + .touches([1]); + let register = IntentCall::new( + "register_subnet", + SignerRole::Coldkey, + "SubtensorModule", + "register_network", + record([("hotkey", s(ctx.alice.hotkey.ss58_address()))]), + ) + .spend(Spend::Unbounded); + let burned = IntentCall::new( + "burned_register", + SignerRole::Coldkey, + "SubtensorModule", + "burned_register", + record([ + ("netuid", u16v(1)), + ("hotkey", s(ctx.alice.hotkey.ss58_address())), + ]), + ) + .spend(Spend::Unbounded) + .touches([1]); + for intent in [&transfer_stake, ®ister, &burned] { + let plan = ctx + .executor() + .plan_with_policy(intent, &ctx.alice, &cap) + .expect("value mover plans"); + assert!(!plan.ok(), "spend cap did not block {}", intent.op); + } +} + +#[test] +fn test_netuid_allowlist_blocks_live() { + let ctx = TestContext::new(); + let allow = Policy { + allowed_netuids: Some(BTreeSet::from([1])), + ..Policy::default() + }; + let move_stake = IntentCall::new( + "move_stake", + SignerRole::Coldkey, + "SubtensorModule", + "move_stake", + record([ + ("origin_hotkey", s(ctx.bob.hotkey.ss58_address())), + ("destination_hotkey", s(ctx.bob.hotkey.ss58_address())), + ("origin_netuid", u16v(1)), + ("destination_netuid", u16v(2)), + ("alpha_amount", u128v(amount_tao(1))), + ]), + ) + .touches([1, 2]); + let plan = ctx + .executor() + .plan_with_policy(&move_stake, &ctx.alice, &allow) + .expect("move stake plans"); + assert!(!plan.ok()); + + let all = IntentCall::new( + "unstake_all_alpha", + SignerRole::Coldkey, + "SubtensorModule", + "unstake_all_alpha", + record([("hotkey", s(ctx.bob.hotkey.ss58_address()))]), + ) + .affects_all_subnets(); + let plan = ctx + .executor() + .plan_with_policy(&all, &ctx.alice, &allow) + .expect("unstake all alpha plans"); + assert!(!plan.ok()); +} + +fn pending_multisig(ctx: &TestContext, account: &str, call_hash: &[u8; 32]) -> Value { + ctx.client + .query( + "Multisig", + "Multisigs", + &[s(account), bytes(call_hash.to_vec())], + None, + ) + .expect("multisig storage read") +} + +fn multisig_execute_intent( + fixture: &MultisigFixture, + signer_public: [u8; 32], + call: Vec, + timepoint: Option, +) -> IntentCall { + IntentCall::new( + "multisig_execute", + SignerRole::Coldkey, + "Multisig", + "as_multi", + record([ + ("threshold", u16v(fixture.threshold)), + ("other_signatories", fixture.others(signer_public)), + ("maybe_timepoint", timepoint.unwrap_or(Value::Null)), + ("call", bytes(call)), + ("max_weight", max_weight()), + ]), + ) +} + +#[test] +fn test_pending_op_open_read_cancel() { + let ctx = TestContext::new(); + let alice = ctx.alice.coldkey.ss58_address(); + let bob = ctx.bob.coldkey.ss58_address(); + let fixture = MultisigFixture::new(&ctx.client, &[&alice, &bob], 2); + let inner = transfer(bob.clone(), 100_000_000) + .encode(&ctx.client) + .expect("inner call composes"); + let hash = call_hash(&inner); + let open = multisig_execute_intent( + &fixture, + ctx.alice.coldkey.public_key_bytes(), + inner.clone(), + None, + ); + let _ = ctx + .executor() + .execute(&open, &ctx.alice) + .expect("open multisig submits"); + + let pending = pending_multisig(&ctx, &fixture.address, &hash); + assert!( + !matches!(pending, Value::Null), + "no pending multisig operation" + ); + let approvals = field(&pending, "approvals").expect("approvals field"); + assert!(value_contains_str(approvals, &alice)); + let when = field(&pending, "when").expect("timepoint").clone(); + + let cancel = IntentCall::new( + "multisig_cancel", + SignerRole::Coldkey, + "Multisig", + "cancel_as_multi", + record([ + ("threshold", u16v(fixture.threshold)), + ( + "other_signatories", + fixture.others(ctx.alice.coldkey.public_key_bytes()), + ), + ("timepoint", when), + ("call_hash", bytes(hash.to_vec())), + ]), + ); + let result = ctx + .executor() + .execute(&cancel, &ctx.alice) + .expect("cancel submits"); + assert_success(&result); + assert!(matches!( + pending_multisig(&ctx, &fixture.address, &hash), + Value::Null + )); +} + +#[test] +fn test_account_object_m_of_n_executes() { + let ctx = TestContext::new(); + let dave = Wallet::from_uris("//Dave", "//Dave//hot").expect("Dave wallet"); + let alice_address = ctx.alice.coldkey.ss58_address(); + let bob_address = ctx.bob.coldkey.ss58_address(); + let dave_address = dave.coldkey.ss58_address(); + let fixture = MultisigFixture::new( + &ctx.client, + &[&alice_address, &bob_address, &dave_address], + 2, + ); + assert_eq!(fixture.threshold, 2); + assert!(fixture.address.starts_with('5')); + + let funding = transfer(fixture.address.clone(), amount_tao(20)); + let result = ctx + .executor() + .execute(&funding, &ctx.alice) + .expect("funding submits"); + assert_success(&result); + + let recipient = Wallet::from_uris("//Eve", "//Eve//hot") + .expect("Eve wallet") + .coldkey + .ss58_address(); + let payout = transfer(recipient.clone(), amount_tao(5)) + .encode(&ctx.client) + .expect("payout composes"); + let hash = call_hash(&payout); + let before = ctx + .client + .balance_rao(&recipient) + .expect("recipient balance"); + + let first = multisig_execute_intent( + &fixture, + ctx.alice.coldkey.public_key_bytes(), + payout.clone(), + None, + ); + let result = ctx + .executor() + .execute(&first, &ctx.alice) + .expect("first approval submits"); + assert_success(&result); + assert_eq!( + ctx.client + .balance_rao(&recipient) + .expect("recipient balance"), + before + ); + + let pending = pending_multisig(&ctx, &fixture.address, &hash); + let when = field(&pending, "when").expect("timepoint").clone(); + let second = multisig_execute_intent( + &fixture, + ctx.bob.coldkey.public_key_bytes(), + payout, + Some(when), + ); + let result = ctx + .executor() + .execute(&second, &ctx.bob) + .expect("second approval submits"); + assert_success(&result); + assert_eq!( + ctx.client + .balance_rao(&recipient) + .expect("recipient balance") + - before, + amount_tao(5) + ); +} + +#[test] +fn test_proxy_flow() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let alice = ctx.alice.coldkey.ss58_address(); + let bob = ctx.bob.coldkey.ss58_address(); + let charlie = Wallet::from_uris("//Charlie", "//Charlie//hot") + .expect("Charlie wallet") + .coldkey + .ss58_address(); + + let add = IntentCall::new( + "add_proxy", + SignerRole::Coldkey, + "Proxy", + "add_proxy", + record([ + ("delegate", s(bob.clone())), + ("proxy_type", proxy_type("Transfer")), + ("delay", u32v(0)), + ]), + ); + let result = ctx + .executor() + .execute(&add, &ctx.alice) + .expect("add proxy submits"); + assert_success(&result); + let proxies = ctx + .client + .query("Proxy", "Proxies", &[s(alice.clone())], None) + .expect("proxies read"); + let delegations = tuple(&proxies).first().expect("delegation list"); + assert!(value_contains_str(delegations, &bob)); + assert!(value_contains_str(delegations, "Transfer")); + + let alice_before = ctx.client.balance_rao(&alice).expect("Alice balance"); + let charlie_before = ctx.client.balance_rao(&charlie).expect("Charlie balance"); + let transfer = transfer(charlie.clone(), amount_tao(1)); + let result = ctx + .executor() + .execute_with(&transfer, &ctx.bob, None, Some(&alice), None, true) + .expect("proxied transfer submits"); + assert_success(&result); + assert_eq!( + ctx.client.balance_rao(&charlie).expect("Charlie balance") - charlie_before, + amount_tao(1) + ); + assert_eq!( + alice_before - ctx.client.balance_rao(&alice).expect("Alice balance"), + amount_tao(1) + ); + + let filtered = add_stake(ctx.bob.hotkey.ss58_address(), netuid, amount_tao(1)); + let result = ctx + .executor() + .execute_with(&filtered, &ctx.bob, None, Some(&alice), None, true) + .expect("filtered proxy call submits"); + assert!(!result.success); + assert!( + result.message.to_ascii_lowercase().contains("filter") + || result + .error + .as_ref() + .is_some_and(|error| error.semantic_code == "not_allowed") + ); + + let remove = IntentCall::new( + "remove_proxy", + SignerRole::Coldkey, + "Proxy", + "remove_proxy", + record([ + ("delegate", s(bob.clone())), + ("proxy_type", proxy_type("Transfer")), + ("delay", u32v(0)), + ]), + ); + let result = ctx + .executor() + .execute(&remove, &ctx.alice) + .expect("remove proxy submits"); + assert_success(&result); + let proxies = ctx + .client + .query("Proxy", "Proxies", &[s(alice.clone())], None) + .expect("proxies read"); + assert!(value_list(tuple(&proxies).first().expect("delegation list")).is_empty()); + + let result = ctx + .executor() + .execute_with(&transfer, &ctx.bob, None, Some(&alice), None, true) + .expect("removed proxy call returns typed outcome"); + assert!(!result.success); + assert!( + result.message.to_ascii_lowercase().contains("not a proxy") + || result + .error + .as_ref() + .is_some_and(|error| error.semantic_code == "not_proxy") + ); +} + +#[test] +fn test_block_number() { + let ctx = TestContext::new(); + assert!(ctx.client.block_number().expect("block number") > 0); +} + +#[test] +fn test_subnets_all_batched() { + let ctx = TestContext::new(); + let subnets = ctx.client.subnets(None).expect("subnets read"); + assert!(subnets.len() >= 2); + assert!(subnets + .windows(2) + .all(|pair| pair[0].netuid < pair[1].netuid)); +} + +#[test] +fn test_balance_and_existential_deposit() { + let ctx = TestContext::new(); + assert!( + ctx.client + .balance_rao(&ctx.alice.coldkey.ss58_address()) + .expect("Alice balance") + > 0 + ); + assert!(ctx.client.existential_deposit_rao().expect("ED") > 0); +} + +#[test] +fn test_generic_accessors_over_generated_descriptors() { + let ctx = TestContext::new(); + let tempo = ctx + .client + .query("SubtensorModule", "Tempo", &[u16v(1)], None) + .expect("Tempo read"); + assert!(as_u128(&tempo).is_some()); + let ed = ctx + .client + .constant("Balances", "ExistentialDeposit") + .expect("ED constant"); + assert!(as_u128(&ed).is_some_and(|value| value > 0)); + let neurons = ctx + .client + .runtime_call("NeuronInfoRuntimeApi", "get_neurons_lite", &[u16v(1)], None) + .expect("neurons runtime call"); + assert!(matches!(neurons, Value::List(_))); +} + +#[test] +fn test_reads_catalog_nonempty() { + let ctx = TestContext::new(); + assert!(ctx.client.read_catalog().len() >= 12); +} + +#[test] +fn test_typed_reads() { + let ctx = TestContext::new(); + let hp = ctx + .client + .subnet_hyperparameters(1, None) + .expect("hyperparameters read"); + assert!(field(&hp, "tempo").is_some()); + + let rate = ctx + .client + .query("SubtensorModule", "WeightsSetRateLimit", &[u16v(1)], None) + .expect("weights rate limit read"); + assert!(as_u128(&rate).is_some()); + + let metagraph = ctx.client.metagraph(1, None).expect("metagraph read"); + assert!(field(&metagraph, "hotkeys").is_some()); + + let positions = ctx + .client + .runtime_call( + "StakeInfoRuntimeApi", + "get_stake_info_for_coldkey", + &[s(ctx.alice.coldkey.ss58_address())], + None, + ) + .expect("stake positions read"); + assert!(matches!(positions, Value::List(_))); + + let children = ctx + .client + .query( + "SubtensorModule", + "ChildKeys", + &[s(ctx.alice.hotkey.ss58_address()), u16v(1)], + None, + ) + .expect("children read"); + assert!(matches!(children, Value::List(_))); +} + +#[test] +fn test_quote_stake_slippage_and_fee() { + let ctx = TestContext::new(); + let quote = ctx + .client + .quote_stake(1, amount_tao(1), None) + .expect("stake quote"); + assert!(quote.alpha_amount > 0); + assert!(quote.tao_fee <= quote.tao_amount); +} + +#[test] +fn test_metagraph_fast_path_matches_runtime() { + let ctx = TestContext::new(); + let fast = ctx.client.neurons(1, None).expect("fast neurons read"); + let raw = ctx + .client + .runtime_call("NeuronInfoRuntimeApi", "get_neurons_lite", &[u16v(1)], None) + .expect("raw neurons read"); + assert_eq!(fast.len(), value_list(&raw).len()); + assert!(fast + .iter() + .all(|neuron| field(neuron, "hotkey").and_then(as_str).is_some())); +} + +#[test] +fn test_snapshot_pins_reads_to_one_block() { + let ctx = TestContext::new(); + let snapshot = ctx.client.at(None).expect("snapshot"); + assert!(snapshot.block_number > 0); + let live = ctx + .client + .runtime_call("NeuronInfoRuntimeApi", "get_neurons_lite", &[u16v(1)], None) + .expect("live neurons"); + let pinned = snapshot + .runtime_call("NeuronInfoRuntimeApi", "get_neurons_lite", &[u16v(1)]) + .expect("pinned neurons"); + assert_eq!(value_list(&live).len(), value_list(&pinned).len()); + assert!( + snapshot + .balance_rao(&ctx.alice.coldkey.ss58_address()) + .expect("pinned balance") + > 0 + ); +} + +#[test] +fn test_leases_read() { + let ctx = TestContext::new(); + let leases = ctx + .client + .query_map("SubtensorModule", "SubnetLeases", &[], None) + .expect("leases read"); + assert!(leases.iter().all(|(key, _)| as_u128(key).is_some())); + let missing = ctx + .client + .query("SubtensorModule", "SubnetLeases", &[u32v(999_999)], None) + .expect("missing lease read"); + assert!(matches!(missing, Value::Null)); +} + +#[test] +fn test_block_subscription_streams_increasing_headers() { + let ctx = TestContext::new(); + let mut blocks = ctx.client.blocks(false); + let first = blocks + .next() + .expect("first header") + .expect("first header result"); + let second = blocks + .next() + .expect("second header") + .expect("second header result"); + assert!(second.number >= first.number); +} + +#[test] +fn test_transfer_moves_exactly_the_amount() { + let ctx = TestContext::new(); + let bob = ctx.bob.coldkey.ss58_address(); + let before = ctx.client.balance_rao(&bob).expect("Bob balance"); + let result = ctx + .executor() + .execute(&transfer(bob.clone(), amount_tao(3)), &ctx.alice) + .expect("transfer submits"); + assert_success(&result); + assert_eq!( + ctx.client.balance_rao(&bob).expect("Bob balance") - before, + amount_tao(3) + ); +} + +#[test] +fn test_add_stake_increases_stake() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let cold = ctx.alice.coldkey.ss58_address(); + let hot = ctx.alice.hotkey.ss58_address(); + let before = ctx + .client + .stake_rao(&cold, &hot, netuid, None) + .expect("stake read"); + let result = ctx + .executor() + .execute(&add_stake(hot.clone(), netuid, amount_tao(10)), &ctx.alice) + .expect("stake submits"); + assert_success(&result); + let after = ctx + .client + .stake_rao(&cold, &hot, netuid, None) + .expect("stake read"); + assert!(after > before); +} + +#[test] +fn test_delegation_lifecycle() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let cold = ctx.alice.coldkey.ss58_address(); + let hot = ctx.alice.hotkey.ss58_address(); + let _ = ctx + .executor() + .execute(&root_register(hot.clone()), &ctx.alice); + + let delegate = ctx + .client + .runtime_call( + "DelegateInfoRuntimeApi", + "get_delegate", + &[s(hot.clone())], + None, + ) + .expect("delegate read"); + assert!(!matches!(delegate, Value::Null)); + assert!(value_contains_str(&delegate, &hot)); + + let delegates = ctx + .client + .runtime_call("DelegateInfoRuntimeApi", "get_delegates", &[], None) + .expect("delegates read"); + assert!(value_list(&delegates) + .iter() + .any(|delegate| value_contains_str(delegate, &hot))); + + let current = ctx + .client + .query("SubtensorModule", "Delegates", &[s(hot.clone())], None) + .expect("take read"); + let current = as_u128(¤t).unwrap_or_default(); + let minimum = ctx + .client + .query("SubtensorModule", "MinDelegateTake", &[], None) + .expect("min take read"); + let maximum = ctx + .client + .query("SubtensorModule", "MaxDelegateTake", &[], None) + .expect("max take read"); + assert!(as_u128(&minimum).unwrap_or_default() <= current); + assert!(current <= as_u128(&maximum).unwrap_or(u128::from(u16::MAX))); + + let target = current + .saturating_sub(1_000) + .max(current / 2) + .min(u128::from(u16::MAX)); + if target != current { + let target_u16 = u16::try_from(target).expect("delegate take fits u16"); + let set_take = + IntentCall::set_take(&ctx.client, hot.clone(), target_u16).expect("set take intent"); + let result = ctx + .executor() + .execute(&set_take, &ctx.alice) + .expect("set take submits"); + assert_success(&result); + let after = ctx + .client + .query("SubtensorModule", "Delegates", &[s(hot.clone())], None) + .expect("take read"); + assert_eq!(as_u128(&after), Some(target)); + } + + let nominations = ctx + .client + .runtime_call( + "DelegateInfoRuntimeApi", + "get_delegated", + &[s(cold.clone())], + None, + ) + .expect("delegated nominations"); + assert!(value_list(&nominations).iter().any(|nomination| { + let Value::Tuple(parts) = nomination else { + return false; + }; + let Some(delegate) = parts.first() else { + return false; + }; + let Some(position) = parts.get(1) else { + return false; + }; + let values = match position { + Value::Tuple(values) | Value::List(values) => values, + _ => return false, + }; + value_contains_str(delegate, &hot) + && values.first().and_then(as_u128) == Some(u128::from(netuid)) + && values.get(1).and_then(as_u128).unwrap_or_default() > 0 + })); + + let by_coldkey = ctx + .client + .runtime_call( + "StakeInfoRuntimeApi", + "get_stake_info_for_coldkeys", + &[Value::List(vec![s(cold.clone())])], + None, + ) + .expect("batched stake positions"); + assert!(value_list(&by_coldkey).iter().any(|entry| { + let Value::Tuple(parts) = entry else { + return false; + }; + parts + .first() + .is_some_and(|value| value_contains_str(value, &cold)) + && parts.get(1).is_some_and(|positions| { + value_list(positions).iter().any(|position| { + field(position, "netuid").and_then(as_u128) == Some(u128::from(netuid)) + }) + }) + })); + + let positions = ctx + .client + .runtime_call( + "StakeInfoRuntimeApi", + "get_stake_info_for_coldkey", + &[s(cold)], + None, + ) + .expect("stake positions"); + assert!(value_list(&positions).iter().any(|position| { + field(position, "netuid").and_then(as_u128) == Some(u128::from(netuid)) + && field(position, "stake") + .and_then(as_u128) + .unwrap_or_default() + > 0 + })); +} + +#[test] +fn test_owned_subnet_is_registered() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let subnets = ctx.client.subnets(None).expect("subnets read"); + assert!(subnets.iter().any(|subnet| subnet.netuid == netuid)); + + let metagraph = ctx + .client + .metagraph(netuid, None) + .expect("owned metagraph read"); + assert!( + field(&metagraph, "hotkeys") + .is_some_and(|hotkeys| value_contains_str(hotkeys, &ctx.alice.hotkey.ss58_address())), + "owned subnet metagraph did not contain Alice's hotkey: {metagraph:#?}" + ); +} + +#[test] +fn test_serving_endpoints() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let tls = IntentCall::new( + "serve_axon_tls", + SignerRole::Hotkey, + "SubtensorModule", + "serve_axon_tls", + record([ + ("netuid", u(netuid)), + ("version", u32v(1)), + ("ip", u128v(3_405_803_781)), + ("port", u16v(8_091)), + ("ip_type", u8v(4)), + ("protocol", u8v(4)), + ("placeholder1", u8v(0)), + ("placeholder2", u8v(0)), + ("certificate", bytes(vec![0xab; 32])), + ]), + ) + .touches([netuid]); + let result = ctx + .executor() + .execute(&tls, &ctx.alice) + .expect("TLS axon submission"); + assert_success(&result); + + let prometheus = IntentCall::new( + "serve_prometheus", + SignerRole::Hotkey, + "SubtensorModule", + "serve_prometheus", + record([ + ("netuid", u(netuid)), + ("version", u32v(1)), + ("ip", u128v(3_405_803_781)), + ("port", u16v(9_090)), + ("ip_type", u8v(4)), + ]), + ) + .touches([netuid]); + let result = ctx + .executor() + .execute(&prometheus, &ctx.alice) + .expect("Prometheus submission"); + assert_success(&result); +} + +#[test] +fn test_owner_sets_hyperparameter() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let intent = IntentCall::set_hyperparameter(netuid, "immunity_period", u16v(42)) + .expect("owner-settable hyperparameter"); + let result = ctx + .executor() + .execute(&intent, &ctx.alice) + .expect("hyperparameter submission"); + let hyperparameters = ctx + .client + .subnet_hyperparameters(netuid, None) + .expect("hyperparameters read"); + let applied = field(&hyperparameters, "immunity_period").and_then(as_u128) == Some(42); + let message = result.message.to_lowercase(); + let throttled = !result.success + && ["rate limit", "prohibited", "freeze"] + .iter() + .any(|needle| message.contains(needle)); + assert!( + applied || throttled, + "success={} message={} hyperparameters={hyperparameters:#?}", + result.success, + result.message + ); +} + +#[test] +fn test_unsettable_hyperparameter_rejected_at_construction() { + assert!(IntentCall::set_hyperparameter(1, "tempo", u16v(1)).is_err()); +} + +#[test] +fn test_identity_coldkey_and_subnet() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let coldkey = ctx.alice.coldkey.ss58_address(); + + let identity = IntentCall::new( + "set_identity", + SignerRole::Coldkey, + "SubtensorModule", + "set_identity", + record([ + ("name", bytes(b"E2E Alice".to_vec())), + ("url", bytes(b"https://a.example".to_vec())), + ("github_repo", bytes(Vec::new())), + ("image", bytes(Vec::new())), + ("discord", bytes(Vec::new())), + ("description", bytes(Vec::new())), + ("additional", bytes(Vec::new())), + ]), + ); + let result = ctx + .executor() + .execute(&identity, &ctx.alice) + .expect("identity submission"); + assert_success(&result); + let stored = ctx + .client + .query("SubtensorModule", "IdentitiesV2", &[s(coldkey)], None) + .expect("identity read"); + assert_eq!( + field(&stored, "name").and_then(text_bytes).as_deref(), + Some("E2E Alice") + ); + + let subnet_identity = IntentCall::new( + "set_subnet_identity", + SignerRole::Coldkey, + "SubtensorModule", + "set_subnet_identity", + subnet_identity_params(netuid, "e2e-net"), + ) + .touches([netuid]); + let result = ctx + .executor() + .execute(&subnet_identity, &ctx.alice) + .expect("subnet identity submission"); + assert_success(&result); + let stored = ctx + .client + .query("SubtensorModule", "SubnetIdentitiesV3", &[u(netuid)], None) + .expect("subnet identity read"); + assert_eq!( + field(&stored, "subnet_name") + .and_then(text_bytes) + .as_deref(), + Some("e2e-net") + ); +} + +#[test] +fn test_auto_stake_destination() { + let ctx = TestContext::new(); + let netuid = ctx.owned_subnet(); + let coldkey = ctx.alice.coldkey.ss58_address(); + let hotkey = ctx.alice.hotkey.ss58_address(); + let intent = IntentCall::new( + "set_auto_stake", + SignerRole::Coldkey, + "SubtensorModule", + "set_coldkey_auto_stake_hotkey", + record([("netuid", u(netuid)), ("hotkey", s(hotkey.clone()))]), + ) + .touches([netuid]); + let _ = ctx.executor().execute(&intent, &ctx.alice); + let destination = ctx + .client + .query( + "SubtensorModule", + "AutoStakeDestination", + &[s(coldkey), u(netuid)], + None, + ) + .expect("auto-stake destination read"); + assert_eq!(as_str(&destination), Some(hotkey.as_str())); +} diff --git a/sdk/bittensor-core/tests/e2e_support/mod.rs b/sdk/bittensor-core/tests/e2e_support/mod.rs new file mode 100644 index 0000000000..93aec396f9 --- /dev/null +++ b/sdk/bittensor-core/tests/e2e_support/mod.rs @@ -0,0 +1,1075 @@ +#![allow( + dead_code, + clippy::arithmetic_side_effects, + clippy::expect_used, + clippy::indexing_slicing, + clippy::unwrap_used +)] + +use std::env; +use std::process::{Command, Output}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use bittensor_core::client::{as_str, as_u128, field, Client, TxOutcome}; +use bittensor_core::codec::extrinsic::{multisig_account_id, multisig_ss58}; +use bittensor_core::codec::Value; +use bittensor_core::keys::{public_key_from_ss58, ss58_from_public, Keypair, CRYPTO_SR25519}; +use bittensor_core::transaction::{Executor, IntentCall, SignerRole, Spend, Wallet}; +use bittensor_core::CoreError; +use sp_core::hashing::blake2_256; + +pub const RAO_PER_TAO: u128 = 1_000_000_000; +const DEFAULT_IMAGE: &str = "ghcr.io/raofoundation/subtensor-localnet:monorepo-sdk"; +const LOCALNET_START_TIMEOUT: Duration = Duration::from_secs(180); + +pub struct TestContext { + _localnet: Localnet, + pub client: Client, + pub alice: Wallet, + pub bob: Wallet, +} + +impl TestContext { + pub fn new() -> Self { + let localnet = Localnet::start(); + let client = Client::connect(&localnet.endpoint) + .unwrap_or_else(|error| panic!("connect {}: {error}", localnet.endpoint)); + let alice = Wallet::from_uris("//Alice", "//Alice//hot").expect("Alice dev wallet"); + let bob = Wallet::from_uris("//Bob", "//Bob//hot").expect("Bob dev wallet"); + Self { + _localnet: localnet, + client, + alice, + bob, + } + } + + pub fn executor(&self) -> Executor<'_> { + Executor::new(&self.client) + } + + pub fn owned_subnet(&self) -> u16 { + register_subnet(&self.client, &self.alice) + } +} + +struct Localnet { + endpoint: String, + container_name: Option, +} + +impl Localnet { + fn start() -> Self { + if let Ok(endpoint) = env::var("E2E_ENDPOINT") { + if !endpoint.trim().is_empty() { + return Self { + endpoint, + container_name: None, + }; + } + } + + let image = env::var("LOCALNET_IMAGE_NAME") + .or_else(|_| env::var("LOCALNET_IMAGE")) + .unwrap_or_else(|_| DEFAULT_IMAGE.into()); + if env::var_os("SKIP_PULL").is_none() { + checked(Command::new("docker").args(["pull", &image]), "docker pull"); + } + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let name = format!("bittensor-rust-e2e-{}-{nonce}", std::process::id()); + checked( + Command::new("docker").args([ + "run", + "--rm", + "-d", + "--name", + &name, + "-p", + "127.0.0.1::9944", + "-p", + "127.0.0.1::9945", + &image, + ]), + "docker run", + ); + + let deadline = Instant::now() + LOCALNET_START_TIMEOUT; + loop { + let logs = output(Command::new("docker").args(["logs", &name]), "docker logs"); + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&logs.stdout), + String::from_utf8_lossy(&logs.stderr) + ); + if combined.contains("Imported #1") { + break; + } + if Instant::now() >= deadline { + let _ = Command::new("docker").args(["rm", "-f", &name]).output(); + panic!( + "localnet container {name} did not import block #1 within {}s\n{combined}", + LOCALNET_START_TIMEOUT.as_secs() + ); + } + thread::sleep(Duration::from_secs(1)); + } + + let port = output( + Command::new("docker").args(["port", &name, "9944/tcp"]), + "docker port", + ); + let mapping = String::from_utf8_lossy(&port.stdout); + let port = mapping + .lines() + .next() + .and_then(|line| line.rsplit(':').next()) + .map(str::trim) + .filter(|port| !port.is_empty()) + .unwrap_or_else(|| panic!("cannot parse docker port mapping: {mapping}")); + Self { + endpoint: format!("ws://127.0.0.1:{port}"), + container_name: Some(name), + } + } +} + +impl Drop for Localnet { + fn drop(&mut self) { + if let Some(name) = &self.container_name { + let _ = Command::new("docker").args(["rm", "-f", name]).output(); + } + } +} + +fn checked(command: &mut Command, label: &str) { + let output = command + .output() + .unwrap_or_else(|error| panic!("{label}: cannot start command: {error}")); + if !output.status.success() { + panic!( + "{label} failed ({})\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +fn output(command: &mut Command, label: &str) -> Output { + command + .output() + .unwrap_or_else(|error| panic!("{label}: cannot start command: {error}")) +} + +pub fn assert_success(result: &TxOutcome) { + assert!( + result.success, + "transaction failed: {} ({:?})\nevents: {:#?}", + result.message, result.error, result.events + ); +} + +pub fn amount_tao(tao: u128) -> u128 { + tao.checked_mul(RAO_PER_TAO).expect("TAO amount fits u128") +} + +pub fn random_wallet() -> Wallet { + let mnemonic = Keypair::generate_mnemonic(12).expect("random mnemonic"); + Wallet { + coldkey: Keypair::from_mnemonic(&mnemonic, CRYPTO_SR25519, None).expect("random coldkey"), + hotkey: Keypair::from_mnemonic(&mnemonic, CRYPTO_SR25519, None).expect("random hotkey"), + } +} + +pub fn register_subnet(client: &Client, wallet: &Wallet) -> u16 { + let intent = IntentCall::new( + "register_subnet", + SignerRole::Coldkey, + "SubtensorModule", + "register_network", + record([("hotkey", s(wallet.hotkey.ss58_address()))]), + ); + let result = Executor::new(client) + .execute(&intent, wallet) + .expect("register subnet submits"); + assert_success(&result); + let netuid = client + .subnets(None) + .expect("subnets read") + .into_iter() + .map(|subnet| subnet.netuid) + .max() + .expect("at least root subnet"); + let start = IntentCall::new( + "start_call", + SignerRole::Coldkey, + "SubtensorModule", + "start_call", + record([("netuid", u(netuid))]), + ) + .touches([netuid]); + let _ = Executor::new(client).execute(&start, wallet); + netuid +} + +pub fn transfer(dest: impl Into, amount_rao: u128) -> IntentCall { + IntentCall::transfer(dest, amount_rao) +} + +pub fn transfer_allow_death(dest: impl Into, amount_rao: u128) -> IntentCall { + IntentCall::transfer_allow_death(dest, amount_rao) +} + +pub fn add_stake(hotkey: impl Into, netuid: u16, amount_rao: u128) -> IntentCall { + IntentCall::add_stake(hotkey, netuid, amount_rao) +} + +pub fn root_register(hotkey: impl Into) -> IntentCall { + IntentCall::root_register(hotkey) +} + +pub fn wait_for_blocks(client: &Client, count: u64) { + let start = client.block_number().expect("block number"); + let deadline = Instant::now() + Duration::from_secs(60); + while client.block_number().expect("block number") < start + count { + assert!( + Instant::now() < deadline, + "timed out waiting for {count} blocks" + ); + thread::sleep(Duration::from_millis(50)); + } +} + +pub fn event_attributes<'a>(result: &'a TxOutcome, module: &str, event: &str) -> Option<&'a Value> { + result.events.iter().find_map(|record| { + let module_id = field(record, "module_id").and_then(as_str); + let event_id = field(record, "event_id").and_then(as_str); + (module_id == Some(module) && event_id == Some(event)) + .then(|| field(record, "attributes")) + .flatten() + }) +} + +pub fn value_u128(value: &Value, name: &str) -> u128 { + field(value, name) + .and_then(as_u128) + .unwrap_or_else(|| panic!("missing integer field {name} in {value:#?}")) +} + +pub fn value_str<'a>(value: &'a Value, name: &str) -> &'a str { + field(value, name) + .and_then(as_str) + .unwrap_or_else(|| panic!("missing string field {name} in {value:#?}")) +} + +pub fn record(fields: [(&str, Value); N]) -> Value { + Value::record( + fields + .into_iter() + .map(|(name, value)| (name.to_string(), value)) + .collect(), + ) +} + +pub fn s(value: impl Into) -> Value { + Value::str(value) +} + +pub fn u(value: u16) -> Value { + Value::Uint(u128::from(value)) +} + +pub fn u32v(value: u32) -> Value { + Value::Uint(u128::from(value)) +} + +pub fn u64v(value: u64) -> Value { + Value::Uint(u128::from(value)) +} + +pub fn u128v(value: u128) -> Value { + Value::Uint(value) +} + +pub fn list(values: impl IntoIterator) -> Value { + Value::List(values.into_iter().collect()) +} + +pub fn bytes(value: impl Into>) -> Value { + Value::Bytes(value.into()) +} + +pub fn boolv(value: bool) -> Value { + Value::Bool(value) +} + +pub fn call_hash(call: &[u8]) -> [u8; 32] { + blake2_256(call) +} + +pub struct MultisigFixture { + pub address: String, + pub sorted: Vec<[u8; 32]>, + pub threshold: u16, +} + +impl MultisigFixture { + pub fn new(client: &Client, signers: &[&str], threshold: u16) -> Self { + let public: Vec<[u8; 32]> = signers + .iter() + .map(|address| public_key_from_ss58(address).expect("valid signatory address")) + .collect(); + let (account, sorted) = multisig_account_id(&public, threshold).expect("multisig account"); + Self { + address: multisig_ss58(account, client.ss58_format()), + sorted, + threshold, + } + } + + pub fn others(&self, signer: [u8; 32]) -> Value { + list( + self.sorted + .iter() + .copied() + .filter(|candidate| *candidate != signer) + .map(|candidate| bytes(candidate.to_vec())), + ) + } +} + +pub fn max_weight() -> Value { + record([ + ("ref_time", u128v(1_000_000_000_000)), + ("proof_size", u128v(1_000_000)), + ]) +} + +pub fn proxy_type(name: &str) -> Value { + s(name) +} + +pub fn sample_intent(ctx: &TestContext, op: &str, netuid: u16) -> Result { + let alice_cold = ctx.alice.coldkey.ss58_address(); + let alice_hot = ctx.alice.hotkey.ss58_address(); + let bob_cold = ctx.bob.coldkey.ss58_address(); + let bob_hot = ctx.bob.hotkey.ss58_address(); + let one = amount_tao(1); + let inner_transfer = || { + ctx.client.compose_call( + "Balances", + "transfer_keep_alive", + &record([("dest", s(bob_cold.clone())), ("value", u128v(one / 2))]), + ) + }; + + let make = |signer: SignerRole, pallet: &str, function: &str, params: Value| { + IntentCall::new(op, signer, pallet, function, params) + }; + + let intent = match op { + "add_proxy" => make( + SignerRole::Coldkey, + "Proxy", + "add_proxy", + record([ + ("delegate", s(bob_cold.clone())), + ("proxy_type", proxy_type("Transfer")), + ("delay", u32v(0)), + ]), + ), + "add_stake" => add_stake(bob_hot.clone(), netuid, one), + "add_stake_limit" => make( + SignerRole::Coldkey, + "SubtensorModule", + "add_stake_limit", + record([ + ("hotkey", s(bob_hot.clone())), + ("netuid", u(netuid)), + ("amount_staked", u128v(one)), + ("limit_price", u128v(one)), + ("allow_partial", boolv(false)), + ]), + ) + .spend(Spend::Bounded(one)) + .touches([netuid]), + "announce_coldkey_swap" => make( + SignerRole::Coldkey, + "SubtensorModule", + "announce_coldkey_swap", + record([( + "new_coldkey_hash", + bytes(blake2_256(&public_key_from_ss58(&bob_cold)?).to_vec()), + )]), + ), + "associate_evm_key" => make( + SignerRole::Hotkey, + "SubtensorModule", + "associate_evm_key", + record([ + ("netuid", u(netuid)), + ("evm_key", s(format!("0x{}", "11".repeat(20)))), + ("block_number", u64v(ctx.client.block_number()?)), + ("signature", s(format!("0x{}", "22".repeat(65)))), + ]), + ) + .touches([netuid]), + "associate_hotkey" => make( + SignerRole::Coldkey, + "SubtensorModule", + "try_associate_hotkey", + record([("hotkey", s(bob_hot.clone()))]), + ), + "batch" => IntentCall::batch( + &ctx.client, + vec![ + transfer(bob_cold.clone(), one / 2), + add_stake(bob_hot.clone(), netuid, one), + ], + )?, + "burned_register" => make( + SignerRole::Coldkey, + "SubtensorModule", + "burned_register", + record([("netuid", u(netuid)), ("hotkey", s(alice_hot.clone()))]), + ) + .spend(Spend::Unbounded) + .touches([netuid]), + "claim_root" => make( + SignerRole::Coldkey, + "SubtensorModule", + "claim_root", + record([("subnets", list([u(netuid)]))]), + ) + .touches([netuid]), + "clear_coldkey_swap_announcement" => make( + SignerRole::Coldkey, + "SubtensorModule", + "clear_coldkey_swap_announcement", + record([]), + ), + "commit_weights" => make( + SignerRole::Hotkey, + "SubtensorModule", + "commit_timelocked_mechanism_weights", + record([ + ("netuid", u(netuid)), + ("mecid", u16v(0)), + ("commit", bytes(vec![1, 2, 3, 4])), + ("reveal_round", u64v(ctx.client.block_number()? + 100)), + ("commit_reveal_version", u64v(4)), + ]), + ) + .touches([netuid]), + "contribute_crowdloan" => make( + SignerRole::Coldkey, + "Crowdloan", + "contribute", + record([("crowdloan_id", u32v(0)), ("amount", u128v(one))]), + ) + .spend(Spend::Bounded(one)), + "create_crowdloan" => make( + SignerRole::Coldkey, + "Crowdloan", + "create", + record([ + ("deposit", u128v(amount_tao(100))), + ("min_contribution", u128v(one)), + ("cap", u128v(amount_tao(1_000))), + ("end", u64v(ctx.client.block_number()? + 5_000)), + ("call", Value::Null), + ("target_address", s(bob_cold.clone())), + ]), + ) + .spend(Spend::Bounded(amount_tao(100))), + "create_pure_proxy" => make( + SignerRole::Coldkey, + "Proxy", + "create_pure", + record([ + ("proxy_type", proxy_type("Any")), + ("delay", u32v(0)), + ("index", u16v(0)), + ]), + ), + "decrease_take" => make( + SignerRole::Coldkey, + "SubtensorModule", + "decrease_take", + record([("hotkey", s(alice_hot.clone())), ("take", u16v(500))]), + ), + "set_take" => { + let current = ctx + .client + .query( + "SubtensorModule", + "Delegates", + &[s(alice_hot.clone())], + None, + ) + .ok() + .and_then(|value| as_u128(&value)) + .unwrap_or_default(); + let target: u16 = if current == 500 { 501 } else { 500 }; + IntentCall::set_take(&ctx.client, alice_hot.clone(), target)? + } + "dispute_coldkey_swap" => make( + SignerRole::Coldkey, + "SubtensorModule", + "dispute_coldkey_swap", + record([]), + ), + "dissolve_crowdloan" => crowdloan_id_call(op, "dissolve", 0), + "evm_withdraw" => make( + SignerRole::Coldkey, + "EVM", + "withdraw", + record([ + ( + "address", + bytes(ctx.alice.coldkey.public_key_bytes()[..20].to_vec()), + ), + ("value", u128v(one)), + ]), + ), + "execute_proxy_announced" => make( + SignerRole::Coldkey, + "Proxy", + "proxy_announced", + record([ + ("delegate", s(bob_cold.clone())), + ("real", s(alice_cold.clone())), + ("force_proxy_type", Value::Null), + ("call", bytes(inner_transfer()?)), + ]), + ), + "finalize_crowdloan" => crowdloan_id_call(op, "finalize", 0), + "fund_evm_key" => { + let mut input = b"evm:".to_vec(); + input.extend_from_slice(&[0x11; 20]); + IntentCall::fund_evm_key( + ss58_from_public(blake2_256(&input), ctx.client.ss58_format()), + one, + ) + } + "increase_take" => make( + SignerRole::Coldkey, + "SubtensorModule", + "increase_take", + record([("hotkey", s(alice_hot.clone())), ("take", u16v(1_000))]), + ), + "kill_pure_proxy" => make( + SignerRole::Coldkey, + "Proxy", + "kill_pure", + record([ + ("spawner", s(bob_cold.clone())), + ("proxy_type", proxy_type("Any")), + ("index", u16v(0)), + ("height", u32v(1)), + ("ext_index", u32v(0)), + ]), + ), + "lock_stake" => make( + SignerRole::Coldkey, + "SubtensorModule", + "lock_stake", + record([ + ("hotkey", s(alice_hot.clone())), + ("netuid", u(netuid)), + ("amount", u128v(one)), + ]), + ) + .touches([netuid]), + "move_lock" => make( + SignerRole::Coldkey, + "SubtensorModule", + "move_lock", + record([ + ("destination_hotkey", s(bob_hot.clone())), + ("netuid", u(netuid)), + ]), + ) + .touches([netuid]), + "move_stake" => make( + SignerRole::Coldkey, + "SubtensorModule", + "move_stake", + record([ + ("origin_hotkey", s(bob_hot.clone())), + ("destination_hotkey", s(bob_hot.clone())), + ("origin_netuid", u(netuid)), + ("destination_netuid", u(netuid)), + ("alpha_amount", u128v(one)), + ]), + ) + .touches([netuid]), + "multisig_approve" => make( + SignerRole::Coldkey, + "Multisig", + "as_multi", + record([ + ("threshold", u16v(2)), + ("other_signatories", list([s(bob_cold.clone())])), + ("maybe_timepoint", Value::Null), + ("call", Value::Bytes(inner_transfer()?)), + ("max_weight", max_weight()), + ]), + ), + "multisig_cancel" => make( + SignerRole::Coldkey, + "Multisig", + "cancel_as_multi", + record([ + ("threshold", u16v(2)), + ("other_signatories", list([s(bob_cold.clone())])), + ( + "timepoint", + record([("height", u32v(1)), ("index", u32v(0))]), + ), + ("call_hash", bytes(call_hash(&inner_transfer()?).to_vec())), + ]), + ), + "multisig_execute" => make( + SignerRole::Coldkey, + "Multisig", + "as_multi", + record([ + ("threshold", u16v(2)), + ("other_signatories", list([s(bob_cold.clone())])), + ("maybe_timepoint", Value::Null), + ("call", bytes(inner_transfer()?)), + ("max_weight", max_weight()), + ]), + ), + "multisig_threshold_1" => make( + SignerRole::Coldkey, + "Multisig", + "as_multi_threshold_1", + record([ + ("other_signatories", list([s(bob_cold.clone())])), + ("call", bytes(inner_transfer()?)), + ]), + ), + "refund_crowdloan" => crowdloan_id_call(op, "refund", 0), + "register_leased_network" => make( + SignerRole::Coldkey, + "SubtensorModule", + "register_leased_network", + record([ + ("emissions_share", u16v(20)), + ("end_block", u64v(1_000_000_000)), + ]), + ) + .spend(Spend::Unbounded), + "register_subnet" => make( + SignerRole::Coldkey, + "SubtensorModule", + "register_network", + record([("hotkey", s(alice_hot.clone()))]), + ) + .spend(Spend::Unbounded), + "remove_proxies" => make(SignerRole::Coldkey, "Proxy", "remove_proxies", record([])), + "remove_proxy" => make( + SignerRole::Coldkey, + "Proxy", + "remove_proxy", + record([ + ("delegate", s(bob_cold.clone())), + ("proxy_type", proxy_type("Transfer")), + ("delay", u32v(0)), + ]), + ), + "remove_stake" => make( + SignerRole::Coldkey, + "SubtensorModule", + "remove_stake", + record([ + ("hotkey", s(bob_hot.clone())), + ("netuid", u(netuid)), + ("amount_unstaked", u128v(one)), + ]), + ) + .touches([netuid]), + "remove_stake_limit" => make( + SignerRole::Coldkey, + "SubtensorModule", + "remove_stake_limit", + record([ + ("hotkey", s(bob_hot.clone())), + ("netuid", u(netuid)), + ("amount_unstaked", u128v(one)), + ("limit_price", u128v(one)), + ("allow_partial", boolv(false)), + ]), + ) + .touches([netuid]), + "reset_axon" => make( + SignerRole::Hotkey, + "SubtensorModule", + "serve_axon", + serve_axon_params(netuid, 0, 0, 1, 4), + ) + .touches([netuid]), + "reveal_weights" => make( + SignerRole::Hotkey, + "SubtensorModule", + "reveal_weights", + record([ + ("netuid", u(netuid)), + ("uids", list([u16v(0)])), + ("values", list([u16v(u16::MAX)])), + ("salt", list([u16v(1), u16v(2), u16v(3)])), + ("version_key", u64v(0)), + ]), + ) + .touches([netuid]), + "root_register" => root_register(alice_hot.clone()).touches([0]), + "serve_axon" => make( + SignerRole::Hotkey, + "SubtensorModule", + "serve_axon", + serve_axon_params(netuid, 1, 3_405_803_781, 8_091, 4), + ) + .touches([netuid]), + "serve_axon_tls" => make( + SignerRole::Hotkey, + "SubtensorModule", + "serve_axon_tls", + record([ + ("netuid", u(netuid)), + ("version", u32v(1)), + ("ip", u128v(3_405_803_781)), + ("port", u16v(8_091)), + ("ip_type", u8v(4)), + ("protocol", u8v(4)), + ("placeholder1", u8v(0)), + ("placeholder2", u8v(0)), + ("certificate", bytes(vec![0xab; 32])), + ]), + ) + .touches([netuid]), + "serve_prometheus" => make( + SignerRole::Hotkey, + "SubtensorModule", + "serve_prometheus", + record([ + ("netuid", u(netuid)), + ("version", u32v(1)), + ("ip", u128v(3_405_803_781)), + ("port", u16v(9_090)), + ("ip_type", u8v(4)), + ]), + ) + .touches([netuid]), + "set_auto_stake" => make( + SignerRole::Coldkey, + "SubtensorModule", + "set_coldkey_auto_stake_hotkey", + record([("netuid", u(netuid)), ("hotkey", s(bob_hot.clone()))]), + ) + .touches([netuid]), + "set_childkey_take" => make( + SignerRole::Coldkey, + "SubtensorModule", + "set_childkey_take", + record([ + ("hotkey", s(alice_hot.clone())), + ("netuid", u(netuid)), + ("take", u16v(1_000)), + ]), + ) + .touches([netuid]), + "set_children" => make( + SignerRole::Coldkey, + "SubtensorModule", + "set_children", + record([ + ("hotkey", s(alice_hot.clone())), + ("netuid", u(netuid)), + ( + "children", + list([Value::Tuple(vec![u128v(1_u128 << 63), s(bob_hot.clone())])]), + ), + ]), + ) + .touches([netuid]), + "set_crowdloan_max_contribution" => make( + SignerRole::Coldkey, + "Crowdloan", + "set_max_contribution", + record([ + ("crowdloan_id", u32v(0)), + ("new_max_contribution", u128v(amount_tao(50))), + ]), + ), + "set_hyperparameter" => { + IntentCall::set_hyperparameter(netuid, "immunity_period", u16v(42))? + } + "set_identity" => make( + SignerRole::Coldkey, + "SubtensorModule", + "set_identity", + identity_params("verify"), + ), + "set_mechanism_count" => make( + SignerRole::Coldkey, + "AdminUtils", + "sudo_set_mechanism_count", + record([("netuid", u(netuid)), ("mechanism_count", u16v(2))]), + ) + .touches([netuid]), + "set_mechanism_emission_split" => make( + SignerRole::Coldkey, + "AdminUtils", + "sudo_set_mechanism_emission_split", + record([ + ("netuid", u(netuid)), + ("maybe_split", list([u16v(32_768), u16v(32_767)])), + ]), + ) + .touches([netuid]), + "set_perpetual_lock" => make( + SignerRole::Coldkey, + "SubtensorModule", + "set_perpetual_lock", + record([("netuid", u(netuid)), ("enabled", boolv(true))]), + ) + .touches([netuid]), + "set_root_claim_type" => make( + SignerRole::Coldkey, + "SubtensorModule", + "set_root_claim_type", + record([( + "new_root_claim_type", + Value::Dict(vec![( + s("KeepSubnets"), + record([("subnets", list([u(netuid)]))]), + )]), + )]), + ) + .touches([netuid]), + "set_subnet_identity" => make( + SignerRole::Coldkey, + "SubtensorModule", + "set_subnet_identity", + subnet_identity_params(netuid, "verify"), + ) + .touches([netuid]), + "set_weights" => make( + SignerRole::Hotkey, + "SubtensorModule", + "set_mechanism_weights", + record([ + ("netuid", u(netuid)), + ("mecid", u16v(0)), + ("dests", list([u16v(0)])), + ("weights", list([u16v(u16::MAX)])), + ("version_key", u64v(0)), + ]), + ) + .touches([netuid]), + "stake_burn" => make( + SignerRole::Coldkey, + "SubtensorModule", + "add_stake_burn", + record([ + ("hotkey", s(alice_hot.clone())), + ("netuid", u(netuid)), + ("amount", u128v(one)), + ("limit", u128v(one)), + ]), + ) + .spend(Spend::Bounded(one)) + .touches([netuid]), + "start_call" => make( + SignerRole::Coldkey, + "SubtensorModule", + "start_call", + record([("netuid", u(netuid))]), + ) + .touches([netuid]), + "swap_coldkey_announced" => make( + SignerRole::Coldkey, + "SubtensorModule", + "swap_coldkey_announced", + record([("new_coldkey", s(bob_cold.clone()))]), + ) + .affects_all_subnets(), + "swap_hotkey" => make( + SignerRole::Coldkey, + "SubtensorModule", + "swap_hotkey", + record([ + ("hotkey", s(alice_hot.clone())), + ("new_hotkey", s(bob_hot.clone())), + ("netuid", Value::Null), + ]), + ) + .affects_all_subnets(), + "swap_stake" => make( + SignerRole::Coldkey, + "SubtensorModule", + "swap_stake", + record([ + ("hotkey", s(bob_hot.clone())), + ("origin_netuid", u(netuid)), + ("destination_netuid", u(netuid)), + ("alpha_amount", u128v(one)), + ]), + ) + .touches([netuid]), + "terminate_lease" => make( + SignerRole::Coldkey, + "SubtensorModule", + "terminate_lease", + record([("lease_id", u32v(0)), ("hotkey", s(alice_hot.clone()))]), + ), + "transfer" => transfer(bob_cold.clone(), one), + "transfer_all" => make( + SignerRole::Coldkey, + "Balances", + "transfer_all", + record([("dest", s(bob_cold.clone())), ("keep_alive", boolv(true))]), + ) + .spend(Spend::Unbounded), + "transfer_stake" => make( + SignerRole::Coldkey, + "SubtensorModule", + "transfer_stake", + record([ + ("destination_coldkey", s(bob_cold.clone())), + ("hotkey", s(bob_hot.clone())), + ("origin_netuid", u(netuid)), + ("destination_netuid", u(netuid)), + ("alpha_amount", u128v(one)), + ]), + ) + .spend(Spend::Unbounded) + .touches([netuid]), + "trim_subnet" => make( + SignerRole::Coldkey, + "AdminUtils", + "sudo_trim_to_max_allowed_uids", + record([("netuid", u(netuid)), ("max_n", u16v(64))]), + ) + .touches([netuid]), + "unstake_all" => make( + SignerRole::Coldkey, + "SubtensorModule", + "unstake_all", + record([("hotkey", s(bob_hot.clone()))]), + ) + .affects_all_subnets(), + "unstake_all_alpha" => make( + SignerRole::Coldkey, + "SubtensorModule", + "unstake_all_alpha", + record([("hotkey", s(bob_hot.clone()))]), + ) + .affects_all_subnets(), + "update_crowdloan_cap" => make( + SignerRole::Coldkey, + "Crowdloan", + "update_cap", + record([ + ("crowdloan_id", u32v(0)), + ("new_cap", u128v(amount_tao(2_000))), + ]), + ), + "update_crowdloan_end" => make( + SignerRole::Coldkey, + "Crowdloan", + "update_end", + record([("crowdloan_id", u32v(0)), ("new_end", u64v(1_000_000_000))]), + ), + "update_crowdloan_min_contribution" => make( + SignerRole::Coldkey, + "Crowdloan", + "update_min_contribution", + record([ + ("crowdloan_id", u32v(0)), + ("new_min_contribution", u128v(amount_tao(2))), + ]), + ), + "update_symbol" => make( + SignerRole::Coldkey, + "SubtensorModule", + "update_symbol", + record([ + ("netuid", u(netuid)), + ("symbol", bytes("β".as_bytes().to_vec())), + ]), + ) + .touches([netuid]), + "withdraw_crowdloan" => crowdloan_id_call(op, "withdraw", 0), + other => { + return Err(CoreError::Policy(format!( + "no Rust e2e sample for intent {other}" + ))) + } + }; + Ok(intent) +} + +fn crowdloan_id_call(op: &str, function: &str, crowdloan_id: u32) -> IntentCall { + IntentCall::new( + op, + SignerRole::Coldkey, + "Crowdloan", + function, + record([("crowdloan_id", u32v(crowdloan_id))]), + ) +} + +pub fn u8v(value: u8) -> Value { + Value::Uint(u128::from(value)) +} + +pub fn u16v(value: u16) -> Value { + Value::Uint(u128::from(value)) +} + +pub fn serve_axon_params(netuid: u16, version: u32, ip: u128, port: u16, protocol: u8) -> Value { + record([ + ("netuid", u(netuid)), + ("version", u32v(version)), + ("ip", u128v(ip)), + ("port", u16v(port)), + ("ip_type", u8v(4)), + ("protocol", u8v(protocol)), + ("placeholder1", u8v(0)), + ("placeholder2", u8v(0)), + ]) +} + +pub fn identity_params(name: &str) -> Value { + record([ + ("name", bytes(name.as_bytes().to_vec())), + ("url", bytes(Vec::new())), + ("github_repo", bytes(Vec::new())), + ("image", bytes(Vec::new())), + ("discord", bytes(Vec::new())), + ("description", bytes(Vec::new())), + ("additional", bytes(Vec::new())), + ]) +} + +pub fn subnet_identity_params(netuid: u16, name: &str) -> Value { + record([ + ("netuid", u(netuid)), + ("subnet_name", bytes(name.as_bytes().to_vec())), + ("github_repo", bytes(Vec::new())), + ("subnet_contact", bytes(Vec::new())), + ("subnet_url", bytes(Vec::new())), + ("discord", bytes(Vec::new())), + ("description", bytes(Vec::new())), + ("logo_url", bytes(Vec::new())), + ("additional", bytes(Vec::new())), + ]) +} diff --git a/sdk/python/README.md b/sdk/python/README.md index e6a2839f7d..12624aa122 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -376,12 +376,8 @@ python -m codegen.check --coverage # every chain call has a deliberate sta python -m codegen.check --names # every classified error name still exists ``` -End-to-end tests prove real on-chain state changes against a writable -localnet. With a node already running (`just e2e` attaches to it), or with -no arguments pytest starts a `LOCALNET_IMAGE` docker container itself: +The migrated chain-facing SDK coverage lives in the Rust core e2e suite: ```bash -just e2e # attach to ws://127.0.0.1:9944 -just e2e ws://other-host:9944 # attach elsewhere -uv run pytest -m e2e # docker-managed localnet +E2E_ENDPOINT=ws://127.0.0.1:9944 cargo test -p bittensor-core --test e2e -- --nocapture ``` diff --git a/sdk/python/justfile b/sdk/python/justfile index cd1b9adc0c..55e5597c02 100644 --- a/sdk/python/justfile +++ b/sdk/python/justfile @@ -23,7 +23,7 @@ fmt: typecheck: uv run ty check --exit-zero-on-warning bittensor -# Offline unit tests (tests marked e2e are deselected by default). +# Offline unit tests. test *ARGS: uv run pytest {{ARGS}} @@ -36,13 +36,7 @@ codegen-static: uv run python -m codegen.check --coverage uv run python -m codegen.check --names -# --- targets that need a running localnet node (default ws://127.0.0.1:9944) --- - -# Chain-facing e2e tests against an already-running node (the dev inner -# loop). Without `endpoint` docker is used: unset E2E_ENDPOINT and pytest -# starts a LOCALNET_IMAGE container itself. -e2e endpoint="ws://127.0.0.1:9944" *ARGS: - E2E_ENDPOINT={{endpoint}} uv run pytest -m e2e {{ARGS}} +# --- targets that need a running node (default ws://127.0.0.1:9944) --- # Regenerate bittensor/_generated from a node's metadata and re-record the # golden fixture. Run after a runtime upgrade, then commit both. @@ -61,4 +55,3 @@ check-docs: # Committed _generated/ must match the node's metadata. drift endpoint="ws://127.0.0.1:9944": uv run python -m codegen.check --drift {{endpoint}} - diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index fca31b1e2c..74c5344b53 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -57,7 +57,6 @@ dev = [ "pytest-asyncio>=0.24", "pytest-cov>=6.0", "pytest-xdist>=3.6", - "pytest-rerunfailures>=14.0", "hypothesis>=6.100", "ruff>=0.14", "ty", @@ -106,10 +105,6 @@ ignore = [ [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" -markers = [ - "e2e: tests that need a running localnet chain (deselected by default)", -] -addopts = "-m 'not e2e'" [tool.ty.rules] # Ratchet plan: these categories have pre-existing findings (mostly the Money diff --git a/sdk/python/tests/e2e/__init__.py b/sdk/python/tests/e2e/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/sdk/python/tests/e2e/conftest.py b/sdk/python/tests/e2e/conftest.py deleted file mode 100644 index 7e2f694435..0000000000 --- a/sdk/python/tests/e2e/conftest.py +++ /dev/null @@ -1,153 +0,0 @@ -"""E2E fixtures: a writable localnet chain, one Client session, dev wallets. - -Chain resolution, in order: - -1. ``E2E_ENDPOINT`` set -> attach to that node (the dev inner loop: run a - localnet yourself and iterate without docker churn). -2. Otherwise start a disposable docker localnet (``LOCALNET_IMAGE``, default - the fork's image) and tear it down at session end. ``LOCALNET_IMAGE_NAME`` - is accepted as a legacy alias for workflow compatibility. ``SKIP_PULL=1`` - skips the pull when CI pre-loads the image. - -Every test in this directory is automatically marked ``e2e`` (deselected by -the default pytest run; select with ``-m e2e``). Each module must declare - - pytestmark = pytest.mark.asyncio(loop_scope="session") - -so its tests run on the same session event loop that owns the session-scoped -websocket Client — an async fixture used from a different loop hangs forever -(a collection hook is too late for pytest-asyncio to see the marker, which is -why this lives in the modules). - -Tests must stay re-runnable against a long-lived dev node: prove writes by -state *delta* (or accept the chain's rate-limit answer where a rerun can be -throttled), and never assume a fresh chain. -""" - -from __future__ import annotations - -import os -import subprocess -import time - -import pytest -import pytest_asyncio - -import bittensor as sub -from tests.harness.samples import dev_wallet - -DEFAULT_IMAGE = "ghcr.io/raofoundation/subtensor-localnet:monorepo-sdk" -CONTAINER_NAME = f"bittensor-e2e-{os.getpid()}" - - -def _localnet_image() -> str: - return os.getenv("LOCALNET_IMAGE") or os.getenv("LOCALNET_IMAGE_NAME") or DEFAULT_IMAGE - - -def pytest_collection_modifyitems(config, items): - this_dir = os.path.dirname(__file__) - for item in items: - if str(item.fspath).startswith(this_dir): - item.add_marker(pytest.mark.e2e) - - -@pytest.fixture(scope="session", autouse=True) -def _isolated_local_state(tmp_path_factory): - """Redirect every ~/.bittensor cache/config file to a temp dir so e2e runs - never touch (or depend on) the developer's real local state.""" - mp = pytest.MonkeyPatch() - tmp = tmp_path_factory.mktemp("btcli-state") - for var, filename in [ - ("BTCLI_CONFIG", "btcli.json"), - ("BTCLI_PROXIES_PATH", "proxies.json"), - ("BTCLI_ADDRESSES_PATH", "addresses.json"), - ("BTCLI_MULTISIGS_PATH", "multisigs.json"), - ("BTCLI_MULTISIG_CACHE", "multisig_cache.json"), - ("BTCLI_SUBNET_NAMES_CACHE", "subnet_names.json"), - ("BTCLI_TOKEN_SYMBOLS_CACHE", "token_symbols.json"), - ]: - mp.setenv(var, str(tmp / filename)) - yield - mp.undo() - - -def _wait_for_import(name: str, timeout: float = 180.0) -> None: - """Block until the node logs its first imported block (chain is live).""" - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - logs = subprocess.run(["docker", "logs", name], capture_output=True, text=True) - if "Imported #1" in logs.stdout or "Imported #1" in logs.stderr: - return - time.sleep(1) - raise RuntimeError(f"localnet container {name} did not import a block in {timeout}s") - - -@pytest.fixture(scope="session") -def chain_endpoint() -> str: - endpoint = os.getenv("E2E_ENDPOINT") - if endpoint: - yield endpoint - return - - image = _localnet_image() - if not os.getenv("SKIP_PULL"): - subprocess.run(["docker", "pull", image], check=True) - subprocess.run( - [ - "docker", - "run", - "--rm", - "-d", - "--name", - CONTAINER_NAME, - "-p", - "9944:9944", - "-p", - "9945:9945", - image, - ], - check=True, - ) - try: - _wait_for_import(CONTAINER_NAME) - yield "ws://127.0.0.1:9944" - finally: - subprocess.run(["docker", "rm", "-f", CONTAINER_NAME], capture_output=True) - - -@pytest_asyncio.fixture(scope="session", loop_scope="session") -async def client(chain_endpoint: str): - async with sub.Client(chain_endpoint) as c: - yield c - - -@pytest.fixture(scope="session") -def alice(): - return dev_wallet("//Alice", "//Alice//hot") - - -@pytest.fixture(scope="session") -def bob(): - return dev_wallet("//Bob", "//Bob//hot") - - -@pytest_asyncio.fixture(scope="session", loop_scope="session") -async def owned_subnet(client, alice) -> int: - """A subnet registered (and started) by Alice for this session. - - Registered once and shared by every test that needs an Alice-owned subnet - with her hotkey at uid 0. Tests that mutate owner-scoped state they can't - share should register their own via ``register_subnet``. - """ - return await register_subnet(client, alice) - - -async def register_subnet(client, wallet) -> int: - """Register a fresh subnet for ``wallet`` and return its netuid.""" - result = await client.execute_tool("register_subnet", {}, wallet) - assert result.success, f"register_subnet failed: {result.message}" - netuid = max(s.netuid for s in await client.subnets.all()) - # Schedule permitting; a start-call delay refusal doesn't block staking on - # localnet, so the result is deliberately not asserted. - await client.execute_tool("start_call", {"netuid": netuid}, wallet) - return netuid diff --git a/sdk/python/tests/e2e/test_batch_mev.py b/sdk/python/tests/e2e/test_batch_mev.py deleted file mode 100644 index b793a613b8..0000000000 --- a/sdk/python/tests/e2e/test_batch_mev.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Atomic batches and the MEV-shielded submission pipeline.""" - -from __future__ import annotations - -import pytest - -import bittensor as sub -from bittensor.keyfiles import Keypair -from tests.harness.samples import BOB - -pytestmark = pytest.mark.asyncio(loop_scope="session") - -DAVE = Keypair.create_from_uri("//Dave").ss58_address -EVE = Keypair.create_from_uri("//Eve").ss58_address - - -async def test_batch_applies_all_children_atomically(client, alice): - dave_before = await client.balances.get(DAVE) - eve_before = await client.balances.get(EVE) - result = await client.execute( - sub.Batch( - intents=[ - sub.Transfer(dest_ss58=DAVE, amount_tao=1.0), - sub.Transfer(dest_ss58=EVE, amount_tao=2.0), - ] - ), - alice, - ) - dave_after = await client.balances.get(DAVE) - eve_after = await client.balances.get(EVE) - assert result.success, result.message - assert dave_after.rao - dave_before.rao == 10**9 - assert eve_after.rao - eve_before.rao == 2 * 10**9 - - -async def test_failed_batch_reverts_everything(client, alice): - dave_before = await client.balances.get(DAVE) - result = await client.execute( - sub.Batch( - intents=[ - sub.Transfer(dest_ss58=DAVE, amount_tao=1.0), - sub.Transfer(dest_ss58=EVE, amount_tao=10**10), # must fail - ] - ), - alice, - ) - dave_after = await client.balances.get(DAVE) - assert not result.success - assert dave_after.rao == dave_before.rao, "atomicity: sibling transfer must revert" - - -async def test_policy_aggregates_spend_across_batch(client, alice): - plan = await client.plan( - sub.Batch( - intents=[ - sub.Transfer(dest_ss58=DAVE, amount_tao=0.6), - sub.Transfer(dest_ss58=EVE, amount_tao=0.6), - ] - ), - alice, - policy=sub.Policy(max_spend_tao=1.0), - ) - assert not plan.ok - - -async def test_mev_shield_next_key_is_mlkem768(client): - key = await client.read("mev_shield_next_key") - assert key is not None - assert len(bytes.fromhex(key[2:])) == 1184 - - -async def test_submit_shielded_runs_full_pipeline(client, alice): - """Read NextKey, sign the inner extrinsic at nonce+1, ML-KEM-768 encrypt, - wrap in submit_encrypted, submit at nonce. On-chain reveal is - validator-side (a mainnet feature) — the dev localnet may reject the pool - submission, so this asserts the pipeline returns a typed result rather - than asserting inclusion.""" - result = await client.submit_shielded(sub.Transfer(dest_ss58=BOB, amount_tao=2.0), alice) - assert hasattr(result, "success") - assert isinstance(result.message, str) diff --git a/sdk/python/tests/e2e/test_crowdloan.py b/sdk/python/tests/e2e/test_crowdloan.py deleted file mode 100644 index f716016798..0000000000 --- a/sdk/python/tests/e2e/test_crowdloan.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Crowdloan flow: create -> read -> update cap.""" - -from __future__ import annotations - -import pytest - -import bittensor as sub -from tests.harness.samples import BOB - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_crowdloan_create_read_update(client, alice): - alice_cold = alice.coldkey.ss58_address - block = await client.block() - - result = await client.execute( - sub.CreateCrowdloan( - deposit_tao=100, - min_contribution_tao=1, - cap_tao=1000, - end=block + 5000, # within [MinimumBlockDuration, MaximumBlockDuration] - target_ss58=BOB, - ), - alice, - ) - assert result.success, result.message - - crowdloan_id = int(await client.query(sub.storage.Crowdloan.NextCrowdloanId)) - 1 - info = await client.read("crowdloan", crowdloan_id=crowdloan_id) - assert info is not None - assert info["creator"] == alice_cold - assert info["raised"].tao >= 100 # the deposit counts toward the raise - assert info["cap"].tao == 1000 - - result = await client.execute( - sub.UpdateCrowdloanCap(crowdloan_id=crowdloan_id, new_cap_tao=2000), alice - ) - info = await client.read("crowdloan", crowdloan_id=crowdloan_id) - assert result.success, result.message - assert info is not None - assert info["cap"].tao == 2000 diff --git a/sdk/python/tests/e2e/test_errors.py b/sdk/python/tests/e2e/test_errors.py deleted file mode 100644 index 68b41b749b..0000000000 --- a/sdk/python/tests/e2e/test_errors.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Typed error mapping from real chain dispatch failures.""" - -from __future__ import annotations - -import pytest - -import bittensor as sub - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_nonexistent_subnet_maps_to_semantic_code(client, alice): - result = await client.execute(sub.BurnedRegister(netuid=999), alice) - assert not result.success - assert result.error is not None - assert result.error.code.value == "subnet_not_exists" diff --git a/sdk/python/tests/e2e/test_governance.py b/sdk/python/tests/e2e/test_governance.py deleted file mode 100644 index 60d5628661..0000000000 --- a/sdk/python/tests/e2e/test_governance.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Governance and account-level flows: sudo nesting, root claim type, -coldkey swap announcement, commitments via the raw-call escape hatch.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -import bittensor as sub -from bittensor.intents.coldkey import coldkey_hash -from bittensor.keyfiles import Keypair -from tests.harness.samples import BOB - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_compose_nested_sudo_call(client, alice): - """A runtime upgrade is Sudo.sudo(System.set_code(...)); prove the nesting - with a reversible admin set through the public compose API.""" - before = int(await client.query(sub.storage.SubtensorModule.TxRateLimit)) - inner = await client.compose( - sub.calls.AdminUtils.sudo_set_tx_rate_limit(tx_rate_limit=before + 1) - ) - result = await client.submit_call(sub.calls.Sudo.sudo(call=inner), alice) - after = int(await client.query(sub.storage.SubtensorModule.TxRateLimit)) - assert result.success, result.message - assert after == before + 1 - - -async def test_raw_call_escape_hatch_and_commitment(client, alice, owned_subnet): - hot = alice.hotkey.ss58_address - raw_call = sub.calls.Commitments.set_commitment( - netuid=owned_subnet, info={"fields": [[{"Raw5": "0x" + b"hello".hex()}]]} - ) - # An active policy refuses raw calls unless allow_raw_calls is set. - with pytest.raises(sub.PolicyError): - await client.submit_call( - raw_call, alice, signer="hotkey", policy=sub.Policy(max_spend_tao=100.0) - ) - result = await client.submit_call(raw_call, alice, signer="hotkey") - commitment = await client.read("commitment", netuid=owned_subnet, hotkey_ss58=hot) - assert result.success, result.message - assert commitment is not None - assert commitment.data == "hello" - revealed = await client.read("revealed_commitment", netuid=owned_subnet, hotkey_ss58=hot) - assert revealed is None # nothing revealed yet - - -async def test_root_claim_type_variants(client, alice, owned_subnet): - alice_cold = alice.coldkey.ss58_address - - result = await client.execute(sub.SetRootClaimType(claim_type="Swap"), alice) - claim = await client.read("root_claim_type", coldkey_ss58=alice_cold) - assert result.success, result.message - assert claim["type"] == "Swap" - assert claim["subnets"] is None - - result = await client.execute( - sub.SetRootClaimType(claim_type="KeepSubnets", subnets=[owned_subnet]), alice - ) - claim = await client.read("root_claim_type", coldkey_ss58=alice_cold) - assert result.success, result.message - assert claim["type"] == "KeepSubnets" - assert claim["subnets"] == [owned_subnet] - - result = await client.execute(sub.SetRootClaimType(claim_type="Keep"), alice) - claim = await client.read("root_claim_type", coldkey_ss58=alice_cold) - assert result.success, result.message - assert claim["type"] == "Keep" - assert claim["subnets"] is None - - -async def test_keep_subnets_requires_subnets(): - with pytest.raises(ValueError): - sub.SetRootClaimType(claim_type="KeepSubnets") - - -async def test_coldkey_swap_announcement_flow(client, alice): - assert await client.read("coldkey_swap_announcement", coldkey_ss58=BOB) is None - - # A throwaway funded coldkey keeps this re-runnable — announcements are - # per-account state that never resets on a long-lived chain. - swapper = Keypair.create_from_mnemonic(Keypair.generate_mnemonic()) - swapper_wallet = SimpleNamespace( - coldkey=swapper, - coldkeypub=Keypair(ss58_address=swapper.ss58_address), - hotkey=swapper, - ) - new_cold = Keypair.create_from_mnemonic(Keypair.generate_mnemonic()).ss58_address - await client.execute(sub.Transfer(dest_ss58=swapper.ss58_address, amount_tao=2.0), alice) - - result = await client.execute( - sub.AnnounceColdkeySwap(new_coldkey_ss58=new_cold), swapper_wallet - ) - announcement = await client.read("coldkey_swap_announcement", coldkey_ss58=swapper.ss58_address) - assert result.success, result.message - assert announcement is not None - assert announcement["execute_block"] > 0 - assert announcement["new_coldkey_hash"] == coldkey_hash(new_cold) - assert not announcement["disputed"] - - # Executing before the announcement delay has passed must be refused. - result = await client.execute( - sub.SwapColdkeyAnnounced(new_coldkey_ss58=new_cold), swapper_wallet - ) - assert not result.success - - -async def test_key_association(client, alice, owned_subnet): - result = await client.execute(sub.AssociateHotkey(hotkey_ss58=alice.hotkey.ss58_address), alice) - assert result.success, result.message - assert await client.read("associated_evm_key", netuid=owned_subnet, uid=0) is None diff --git a/sdk/python/tests/e2e/test_intent_sweep.py b/sdk/python/tests/e2e/test_intent_sweep.py deleted file mode 100644 index 4c6fecf0b7..0000000000 --- a/sdk/python/tests/e2e/test_intent_sweep.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Every registered intent composes and plans against the LIVE runtime. - -The offline table tests (tests/unit/test_intents_table.py) prove the SDK is -self-consistent; this sweep proves it matches the actual chain metadata — -the behavioral complement to ``codegen.check --drift``. A call rename or -argument change on the chain fails here even if the committed generated -layer is stale. -""" - -from __future__ import annotations - -import pytest - -import bittensor as sub -from bittensor.intents import REGISTRY, build -from tests.harness.samples import BOB, BOB_HOT, INTENT_SAMPLES - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -# Weights intents pre-flight the signer's registration on the target subnet, -# so they plan against the session's owned subnet (alice is uid 0 there) — -# netuid 1 on a long-lived dev chain may not have her registered. -NEEDS_OWNED_SUBNET = {"set_weights", "commit_weights", "reveal_weights"} - - -@pytest.mark.parametrize("op", sorted(REGISTRY)) -async def test_intent_plans_against_live_metadata(op: str, client, alice, owned_subnet): - args = dict(INTENT_SAMPLES[op]) - if op in NEEDS_OWNED_SUBNET: - args["netuid"] = owned_subnet - try: - plan = await client.plan(build(op, args), alice) - except sub.ChainError as error: - # The weights pre-flight reads real chain state and may legitimately - # answer "rate limited" on a freshly registered subnet (LastUpdate is - # set at registration). That decision required live metadata to - # decode, which is what this sweep proves — accept it. - if op in NEEDS_OWNED_SUBNET and error.code is sub.ErrorCode.RATE_LIMITED: - return - raise - assert plan.op == op - - -async def test_plan_simulates_real_fee(client, alice): - plan = await client.plan(sub.Transfer(dest_ss58=BOB, amount_tao=1.0), alice) - assert plan.fee is not None - assert plan.fee.rao > 0 - - -async def test_policy_blocks_with_live_fee(client, alice): - with pytest.raises(sub.PolicyError): - await client.execute( - sub.Transfer(dest_ss58=BOB, amount_tao=5.0), - alice, - policy=sub.Policy(max_spend_tao=1.0), - ) - - -async def test_spend_cap_blocks_value_movers(client, alice): - cap = sub.Policy(max_spend_tao=1.0) - value_movers = [ - sub.TransferStake( - dest_coldkey_ss58=BOB, - hotkey_ss58=BOB_HOT, - origin_netuid=1, - dest_netuid=1, - amount_alpha=1.0, - ), - sub.RegisterSubnet(), - sub.BurnedRegister(netuid=1), - ] - for intent in value_movers: - plan = await client.plan(intent, alice, policy=cap) - assert not plan.ok, f"spend cap did not block {intent.op}" - - -async def test_netuid_allowlist_blocks_live(client, alice): - allow = sub.Policy(allowed_netuids=[1]) - plan = await client.plan( - sub.MoveStake( - origin_hotkey_ss58=BOB_HOT, - origin_netuid=1, - dest_hotkey_ss58=BOB_HOT, - dest_netuid=2, - amount_alpha=1.0, - ), - alice, - policy=allow, - ) - assert not plan.ok - plan = await client.plan(sub.UnstakeAllAlpha(hotkey_ss58=BOB_HOT), alice, policy=allow) - assert not plan.ok diff --git a/sdk/python/tests/e2e/test_multisig.py b/sdk/python/tests/e2e/test_multisig.py deleted file mode 100644 index bb00ab0b16..0000000000 --- a/sdk/python/tests/e2e/test_multisig.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Multisig: pending-op lifecycle and the M-of-N account object.""" - -from __future__ import annotations - -import pytest - -import bittensor as sub -from bittensor.keyfiles import Keypair -from tests.harness.samples import BOB, dev_wallet - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_pending_op_open_read_cancel(client, alice): - """2-of-2 (Alice, Bob): open the op (first approval), read it back through - the Multisigs map and the typed read, then cancel it. - - Re-runnable: a prior interrupted run may have left the op pending, so the - open result bool is not asserted — only that a pending op exists and that - cancel clears it. - """ - alice_cold = alice.coldkey.ss58_address - inner = {"op": "transfer", "dest_ss58": BOB, "amount_tao": 0.1} - - await client.execute( - sub.MultisigExecute(threshold=2, other_signatories=[BOB], call=inner), alice - ) - entries = await client.query_map(sub.storage.Multisig.Multisigs) - mine = [(k, v) for k, v in entries if str((v or {}).get("depositor")) == alice_cold] - assert len(mine) >= 1, "no pending multisig op with Alice as depositor" - - (ms_account, call_hash), value = mine[0] - read_back = await client.read( - "multisig", account_ss58=str(ms_account), call_hash=str(call_hash) - ) - assert read_back is not None - assert alice_cold in read_back["approvals"] - - timepoint = { - "height": int(value["when"]["height"]), - "index": int(value["when"]["index"]), - } - result = await client.execute( - sub.MultisigCancel(threshold=2, other_signatories=[BOB], call=inner, timepoint=timepoint), - alice, - ) - entries = await client.query_map(sub.storage.Multisig.Multisigs) - still = [k for k, v in entries if str((v or {}).get("depositor")) == alice_cold] - assert result.success, result.message - assert not still, f"{len(still)} pending ops left after cancel" - - -async def test_account_object_m_of_n_executes(client, alice, bob): - """2-of-3 over Alice/Bob/Dave: the first approval records consent, the - threshold-reaching approval executes the inner call.""" - dave = dev_wallet("//Dave", "//Dave//hot") - ms = await client.multisig( - [ - alice.coldkey.ss58_address, - bob.coldkey.ss58_address, - dave.coldkey.ss58_address, - ], - threshold=2, - ) - assert ms.threshold == 2 - assert ms.address.startswith("5") - - await client.execute(sub.Transfer(dest_ss58=ms.address, amount_tao=20.0), alice) - - # A fresh recipient keeps the run re-runnable (no prior balance). - recipient = Keypair.create_from_mnemonic(Keypair.generate_mnemonic()).ss58_address - payout = sub.calls.Balances.transfer_keep_alive(dest=recipient, value=5 * 10**9) - - before = await client.balances.get(recipient) - first = await ms.approve(payout, alice) - mid = await client.balances.get(recipient) - assert first.success, first.message - assert mid.rao == before.rao, "first approval must not execute" - - second = await ms.approve(payout, bob) - after = await client.balances.get(recipient) - assert second.success, second.message - assert after.rao - before.rao == 5 * 10**9 diff --git a/sdk/python/tests/e2e/test_proxy.py b/sdk/python/tests/e2e/test_proxy.py deleted file mode 100644 index 2336ca3617..0000000000 --- a/sdk/python/tests/e2e/test_proxy.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Proxy signing mode: the real coldkey stays offline, a delegate signs. - -One flow test — add -> proxied transfer -> filtered call -> remove -> refused -— because the steps only make sense in sequence against shared chain state. -""" - -from __future__ import annotations - -import pytest - -import bittensor as sub -from bittensor.keyfiles import Keypair -from tests.harness.samples import BOB, BOB_HOT - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_proxy_flow(client, alice, bob, owned_subnet): - alice_cold = alice.coldkey.ss58_address - charlie = Keypair.create_from_uri("//Charlie").ss58_address - - # -- add: Bob becomes a Transfer-type proxy of Alice ---------------------- - result = await client.execute(sub.AddProxy(delegate_ss58=BOB, proxy_type="Transfer"), alice) - proxy_state = await client.read("proxies", coldkey_ss58=alice_cold) - assert result.success, result.message - assert any( - p["delegate"] == BOB and p["proxy_type"] == "Transfer" for p in proxy_state["proxies"] - ) - - try: - # -- proxied transfer moves the REAL account's funds ------------------ - alice_before = await client.balances.get(alice_cold) - charlie_before = await client.balances.get(charlie) - result = await client.execute( - sub.Transfer(dest_ss58=charlie, amount_tao=1.0), bob, proxy_for=alice_cold - ) - alice_after = await client.balances.get(alice_cold) - charlie_after = await client.balances.get(charlie) - assert result.success, result.message - assert charlie_after.rao - charlie_before.rao == 10**9 - assert alice_before.rao - alice_after.rao == 10**9 - - # -- a call outside the proxy type fails via ProxyExecuted, not silently - result = await client.execute( - sub.AddStake(hotkey_ss58=BOB_HOT, netuid=owned_subnet, amount_tao=1.0), - bob, - proxy_for=alice_cold, - ) - assert not result.success - assert "filter" in result.message - finally: - # -- remove clears the delegation (also the rerun cleanup) ------------- - result = await client.execute( - sub.RemoveProxy(delegate_ss58=BOB, proxy_type="Transfer"), alice - ) - proxy_state = await client.read("proxies", coldkey_ss58=alice_cold) - assert result.success, result.message - assert proxy_state["proxies"] == [] - - # -- without delegation the proxied call is refused (NotProxy) ------------- - result = await client.execute( - sub.Transfer(dest_ss58=charlie, amount_tao=1.0), bob, proxy_for=alice_cold - ) - assert not result.success - assert "not a proxy" in result.message.lower() diff --git a/sdk/python/tests/e2e/test_reads.py b/sdk/python/tests/e2e/test_reads.py deleted file mode 100644 index 1dd8d63412..0000000000 --- a/sdk/python/tests/e2e/test_reads.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Read-side e2e: typed reads, generic accessors, snapshots, subscriptions. - -Ports the read sections of the old verify.py sweep. Everything here is -read-only against the localnet (netuid 1 ships pre-registered on the image). -""" - -from __future__ import annotations - -import pytest - -import bittensor as sub -from tests.harness.samples import ALICE - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_block_number(client): - block = await client.block() - assert isinstance(block, int) and block > 0 - - -async def test_subnets_all_batched(client): - subnets = await client.subnets.all() - assert len(subnets) >= 2 # root + at least one subnet - assert all(isinstance(s, sub.SubnetInfo) for s in subnets) - - -async def test_balance_and_existential_deposit(client, alice): - balance = await client.balances.get(alice.coldkey.ss58_address) - assert balance.rao > 0 - assert balance.netuid == 0 - deposit = await client.balances.existential_deposit() - assert deposit.rao > 0 - - -async def test_generic_accessors_over_generated_descriptors(client): - tempo = await client.query(sub.storage.SubtensorModule.Tempo, [1]) - assert isinstance(tempo, int) - ed = await client.constant(sub.constants.Balances.ExistentialDeposit) - assert int(ed) > 0 - neurons = await client.runtime(sub.runtime_api.NeuronInfoRuntimeApi.get_neurons_lite, [1]) - assert isinstance(neurons, list) - - -async def test_reads_catalog_nonempty(client): - assert len(client.reads()) >= 12 - - -async def test_typed_reads(client): - hp = await client.read("subnet_hyperparameters", netuid=1) - assert isinstance(hp, dict) and "tempo" in hp - - assert isinstance(await client.read("weights_rate_limit", netuid=1), int) - - mg = await client.read("metagraph", netuid=1) - assert isinstance(mg, dict) and "hotkeys" in mg - - positions = await client.read("stake_for_coldkey", coldkey_ss58=ALICE) - assert isinstance(positions, list) - - kids = await client.read("children", hotkey_ss58=ALICE, netuid=1) - assert isinstance(kids, list) - - -async def test_quote_stake_slippage_and_fee(client): - quote = await client.read("quote_stake", netuid=1, amount_tao=1.0) - assert quote.alpha.rao > 0 - assert quote.tao_fee.rao >= 0 - - -async def test_metagraph_fast_path_matches_runtime(client): - neurons = await client.neurons.all(1) - raw = await client.runtime(sub.runtime_api.NeuronInfoRuntimeApi.get_neurons_lite, [1]) - assert len(neurons) == len(raw) - assert all(isinstance(n, sub.Neuron) and n.hotkey for n in neurons) - - -async def test_snapshot_pins_reads_to_one_block(client, alice): - snapshot = await client.at() - assert snapshot.block > 0 - neurons_live = await client.neurons.all(1) - neurons_snap = await snapshot.neurons.all(1) - assert len(neurons_snap) == len(neurons_live) - balance = await snapshot.balances.get(alice.coldkey.ss58_address) - assert balance.rao >= 0 - - -async def test_leases_read(client): - leases = await client.read("leases") - assert isinstance(leases, list) - assert await client.read("lease", lease_id=999_999) is None - - -async def test_block_subscription_streams_increasing_headers(client): - seen: list[int] = [] - async for header in client.blocks(): - seen.append(header.number) - if len(seen) >= 2: - break - assert len(seen) == 2 - assert seen[1] >= seen[0] diff --git a/sdk/python/tests/e2e/test_staking.py b/sdk/python/tests/e2e/test_staking.py deleted file mode 100644 index 694aac011a..0000000000 --- a/sdk/python/tests/e2e/test_staking.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Value movement proven by state delta: transfers, staking, delegation.""" - -from __future__ import annotations - -import pytest - -import bittensor as sub -from tests.harness.samples import BOB - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_transfer_moves_exactly_the_amount(client, alice): - before = await client.balances.get(BOB) - result = await client.execute(sub.Transfer(dest_ss58=BOB, amount_tao=3.0), alice) - after = await client.balances.get(BOB) - assert result.success, result.message - assert after.rao - before.rao == 3 * 10**9 - - -async def test_add_stake_increases_stake(client, alice, owned_subnet): - cold = alice.coldkey.ss58_address - hot = alice.hotkey.ss58_address - before = await client.staking.get(cold, hot, owned_subnet) - result = await client.execute( - sub.AddStake(hotkey_ss58=hot, netuid=owned_subnet, amount_tao=10.0), alice - ) - after = await client.staking.get(cold, hot, owned_subnet) - assert result.success, result.message - assert after.rao > before.rao - - -async def test_delegation_lifecycle(client, alice, owned_subnet): - """root_register -> delegate reads -> set_take (decrease path) -> nominations.""" - cold = alice.coldkey.ss58_address - hot = alice.hotkey.ss58_address - - # Makes the hotkey a delegate; a rerun is a no-op refusal, which is fine — - # the reads below prove the resulting state either way. - await client.execute(sub.RootRegister(), alice) - - assert await client.read("is_delegate", hotkey_ss58=hot) is True - - delegate = await client.read("delegate", hotkey_ss58=hot) - assert delegate is not None - assert delegate.hotkey == hot - assert 0 <= delegate.take <= 1 - - all_delegates = await client.read("delegates") - assert any(d.hotkey == hot for d in all_delegates) - - take_info = await client.read("delegate_take", hotkey_ss58=hot) - assert take_info["min"] <= take_info["take"] <= take_info["max"] - - # Move take down to an absolute target: exercises the sugar's - # "read current, choose decrease_take" path. - target_u16 = max(take_info["take_u16"] - 1000, take_info["take_u16"] // 2) - result = await client.execute(sub.SetTake(hotkey_ss58=hot, take=target_u16), alice) - after_take = await client.read("delegate_take", hotkey_ss58=hot) - assert result.success, result.message - assert after_take["take_u16"] == target_u16 - - nominations = await client.read("delegated", coldkey_ss58=cold) - assert any(n.netuid == owned_subnet and n.stake.rao > 0 for n in nominations) - - by_coldkey = await client.read("stake_for_coldkeys", coldkey_ss58s=[cold]) - assert cold in by_coldkey - assert any(p.netuid == owned_subnet for p in by_coldkey[cold]) diff --git a/sdk/python/tests/e2e/test_subnet_lifecycle.py b/sdk/python/tests/e2e/test_subnet_lifecycle.py deleted file mode 100644 index c395e39df8..0000000000 --- a/sdk/python/tests/e2e/test_subnet_lifecycle.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Owner-scoped subnet operations: serving, hyperparameters, identity, auto-stake. - -All of these act on the session's owned subnet (registered by the conftest -fixture, alice as owner with her hotkey at uid 0). -""" - -from __future__ import annotations - -import pytest - -import bittensor as sub - -pytestmark = pytest.mark.asyncio(loop_scope="session") - - -async def test_owned_subnet_is_registered(client, alice, owned_subnet): - subnets = await client.subnets.all() - assert owned_subnet in [s.netuid for s in subnets] - mg = await client.read("metagraph", netuid=owned_subnet) - assert alice.hotkey.ss58_address in mg["hotkeys"] - - -async def test_serving_endpoints(client, alice, owned_subnet): - # serve_axon and serve_axon_tls share one serving rate-limit bucket, so only - # the TLS superset is submitted live (it proves the certificate path); plain - # serve_axon is covered by the plan sweep. Prometheus is a separate bucket. - result = await client.execute( - sub.ServeAxonTls( - netuid=owned_subnet, ip="203.0.113.5", port=8091, certificate="0x" + "ab" * 32 - ), - alice, - ) - assert result.success, result.message - result = await client.execute( - sub.ServePrometheus(netuid=owned_subnet, ip="203.0.113.5", port=9090), alice - ) - assert result.success, result.message - - -async def test_owner_sets_hyperparameter(client, alice, owned_subnet): - result = await client.execute( - sub.SetHyperparameter(netuid=owned_subnet, name="immunity_period", value=42), alice - ) - hp = await client.read("subnet_hyperparameters", netuid=owned_subnet) - applied = int(hp.get("immunity_period")) == 42 - # Chain timing guards that legitimately defer an owner set on a rerun (the - # call still reached dispatch, proving the owner path). - throttled = not result.success and any( - s in result.message.lower() for s in ("rate limit", "prohibited", "freeze") - ) - assert applied or throttled, f"success={result.success} msg={result.message[:60]}" - - -async def test_unsettable_hyperparameter_rejected_at_construction(owned_subnet): - with pytest.raises(ValueError): - sub.SetHyperparameter(netuid=owned_subnet, name="tempo", value=1) - - -async def test_identity_coldkey_and_subnet(client, alice, owned_subnet): - result = await client.execute(sub.SetIdentity(name="E2E Alice", url="https://a.example"), alice) - identity = await client.read("identity", coldkey_ss58=alice.coldkey.ss58_address) - assert result.success, result.message - assert identity is not None - assert identity.get("name") == "E2E Alice" - - result = await client.execute( - sub.SetSubnetIdentity(netuid=owned_subnet, subnet_name="e2e-net"), alice - ) - subnet_identity = await client.read("subnet_identity", netuid=owned_subnet) - assert result.success, result.message - assert subnet_identity is not None - assert subnet_identity.get("subnet_name") == "e2e-net" - - -async def test_auto_stake_destination(client, alice, owned_subnet): - hot = alice.hotkey.ss58_address - # Re-setting the same destination is refused ("already set"), so assert - # the read state rather than the extrinsic bool (re-runnable). - await client.execute(sub.SetAutoStake(netuid=owned_subnet, hotkey_ss58=hot), alice) - dest = await client.read( - "auto_stake", coldkey_ss58=alice.coldkey.ss58_address, netuid=owned_subnet - ) - assert dest == hot diff --git a/sdk/python/tests/harness/samples.py b/sdk/python/tests/harness/samples.py index 01b4d91cda..0d4ffc341b 100644 --- a/sdk/python/tests/harness/samples.py +++ b/sdk/python/tests/harness/samples.py @@ -1,10 +1,9 @@ """Sample arguments for every registered intent and read. -The single source of truth the registry table tests (offline, against -FakeSubstrate) and the e2e intent sweep (against live metadata) iterate -over. A meta-test asserts every entry in ``bittensor.intents.REGISTRY`` / -``bittensor.reads.REGISTRY`` has a sample here, so adding an operation -without wiring it into the test tables fails CI. +The single source of truth for the offline registry table tests against +FakeSubstrate. A meta-test asserts every entry in +``bittensor.intents.REGISTRY`` / ``bittensor.reads.REGISTRY`` has a sample +here, so adding an operation without wiring it into the test tables fails CI. Addresses are the standard dev keys, derived (not hardcoded) so they stay correct if the ss58 format ever changes. diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 6f7a07b3cc..f12b4194be 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -107,7 +107,6 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, - { name = "pytest-rerunfailures" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "ty" }, @@ -129,7 +128,6 @@ dev = [ { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pytest-cov", specifier = ">=6.0" }, - { name = "pytest-rerunfailures", specifier = ">=14.0" }, { name = "pytest-xdist", specifier = ">=3.6" }, { name = "ruff", specifier = ">=0.14" }, { name = "ty" }, @@ -823,19 +821,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, ] -[[package]] -name = "pytest-rerunfailures" -version = "16.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955 }, -] - [[package]] name = "pytest-xdist" version = "3.8.0"