diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index fa03ffb6eb..c4aacd6b88 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -3,5 +3,4 @@ self-hosted-runner: labels: - fireactions-turbo-16 - fireactions-turbo-8 - - fireactions-tryruntime - Benchmarking diff --git a/.github/actions/run-typescript-e2e/action.yml b/.github/actions/run-typescript-e2e/action.yml new file mode 100644 index 0000000000..b79a3d3854 --- /dev/null +++ b/.github/actions/run-typescript-e2e/action.yml @@ -0,0 +1,52 @@ +name: Run TypeScript E2E suite +description: Download a prebuilt node and run one Moonwall environment. + +inputs: + binary: + description: Node artifact variant to download. + required: true + test: + description: Moonwall environment to run. + required: true + +runs: + using: composite + steps: + - name: Download binary + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: node-subtensor-${{ inputs.binary }} + path: target/release + + - name: Make binary executable + shell: bash + run: chmod +x target/release/node-subtensor + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: ts-tests/.nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10 + + - name: Install e2e dependencies + shell: bash + working-directory: ts-tests + run: pnpm install --frozen-lockfile + + - name: Install lsof (dev foundation RPC port discovery) + if: inputs.test == 'dev' + shell: bash + run: | + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends lsof + + - name: Run tests + shell: bash + working-directory: ts-tests + env: + MOONWALL_TEST: ${{ inputs.test }} + run: pnpm moonwall test "$MOONWALL_TEST" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0383c6b2e1..f5f4126cf6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,9 +3,9 @@ You are reviewing code for a Substrate-based blockchain with a $4B market cap. Lives and livelihoods depend on the security and correctness of this code. Be thorough, precise, and uncompromising on safety. ## Branch Strategy -* Unless this is a hotfix or deployment PR (`devnet` => `testnet`, or `testnet` => `mainnet`), all PRs must target `devnet` -* Flag PRs targeting `mainnet` directly unless they are hotfixes -* `testnet` and `mainnet` branches must only receive promotion merges from the branch directly upstream of them +* All PRs target `main`. Deployment is automated, not PR-driven: merges to `main` ride the release train, which promotes devnet → testnet → mainnet via on-chain `setCode` (see `docs/internals/release-process.mdx`) +* `devnet`, `testnet`, and `mainnet` are CI-managed mirror branches recording what each network currently runs; they are ruleset-locked and only the release train updates them +* Flag any PR that targets `devnet`, `testnet`, or `mainnet` — those branches never receive merges ## CRITICAL: Runtime Safety (Chain-Bricking Prevention) The runtime CANNOT panic under any circumstances. A single panic can brick the entire chain. diff --git a/.github/scripts/classify-runtime-changes.sh b/.github/scripts/classify-runtime-changes.sh new file mode 100755 index 0000000000..521142e17f --- /dev/null +++ b/.github/scripts/classify-runtime-changes.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: classify-runtime-changes.sh OUTPUT_FILE" >&2 + exit 2 +fi + +output_file="$1" + +runtime=false +docs=false +python_sdk=false +sdk_drift=false +snapshot_ci=false + +# Keep path ownership explicit. SDK-only changes are covered by sdk-checks and +# the Rust SDK e2e workflow; they should not force clone-upgrade or SDK drift. +while IFS= read -r path; do + case "$path" in + common/*|node/*|pallets/*|precompiles/*|primitives/*|runtime/*|support/*|chain-extensions/*|src/*|vendor/*|Cargo.toml|build.rs|rust-toolchain.toml) + runtime=true + sdk_drift=true + ;; + clones/*|website/apps/bittensor-website/scripts/*) + runtime=true + ;; + .github/workflows/runtime-checks.yml|.github/workflows/refresh-mainnet-snapshot.yml|.github/actions/rust-setup/*|.github/actions/sccache-setup/*|.github/scripts/sccache-configure.sh) + runtime=true + ;; + .github/scripts/classify-runtime-changes.sh|.github/scripts/test-runtime-change-filter.sh|.github/scripts/snapshot-artifact.sh|.github/scripts/test-snapshot-artifact.sh) + runtime=true + snapshot_ci=true + ;; + esac + + case "$path" in + website/*|sdk/python/*|.github/workflows/runtime-checks.yml) docs=true ;; + esac + + case "$path" in + sdk/python/*|sdk/bittensor-core/*|sdk/bittensor-core-py/*|sdk/bittensor-core-wasm/*|Cargo.lock|.github/workflows/runtime-checks.yml) + python_sdk=true + ;; + esac + + case "$path" in + .github/workflows/runtime-checks.yml|.github/workflows/refresh-mainnet-snapshot.yml) + snapshot_ci=true + ;; + esac +done + +echo "runtime=$runtime, docs=$docs, python_sdk=$python_sdk, sdk_drift=$sdk_drift, snapshot_ci=$snapshot_ci" +{ + echo "runtime=$runtime" + echo "docs=$docs" + echo "python_sdk=$python_sdk" + echo "sdk_drift=$sdk_drift" + echo "snapshot_ci=$snapshot_ci" +} >> "$output_file" diff --git a/.github/scripts/deploy/README.md b/.github/scripts/deploy/README.md index a7b5f341b1..1589b767a3 100644 --- a/.github/scripts/deploy/README.md +++ b/.github/scripts/deploy/README.md @@ -9,10 +9,11 @@ member of the sudo multisig (the "triumvirate"). | ------ | ------- | | `deploy-wasm.js` | Direct sudo upgrade for chains where CI holds the sudo key (devnet, testnet, local mainnet clones). Used by `release-train.yml`. | | `propose-upgrade-multisig.js` | CI's half of the mainnet deployment multisig proposal. Used by `release-train.yml`. | -| `approve-upgrade-multisig.js` | Library for triumvirate first-signer approvals; not run directly. | -| `prod-approval.js` | Runs `approve-upgrade-multisig` with the production triumvirate signatories. | +| `approve-upgrade-multisig.js` | Library for triumvirate first-signer approvals (legacy path); not run directly. | +| `prod-approval.js` | Runs `approve-upgrade-multisig` with the production triumvirate signatories (legacy path). | | `samvps-approval.js` | Same, for the sam-vps multisig. | -| `test-wasm.py` | Verifies a WASM's hash and that its bytes are embedded in the call data. | +| `sudo-signatories.json` | The triumvirate signer set + threshold; consumed by `prod-approval.js` and embedded into `upgrade-manifest.json` by the release train. | +| `test-wasm.py` | Verifies a WASM's hash and that its bytes are embedded in the call data (legacy path; `btcli upgrade check` supersedes it). | ## Background: the two multisig layers @@ -28,54 +29,62 @@ A production runtime upgrade is gated by two nested multisigs: `sudo.key()`. It is the *second* party of the deployment multisig, so the triumvirate has to produce that second approval among themselves. -The first triumvirate signer runs `prod-approval.js`. That script builds the -deployment multisig's finalizing call **and** submits the first triumvirate -approval of it in one transaction. The remaining triumvirate signers then -approve (and the last one executes) from PolkadotJS Apps — no script needed. +When the proposal lands, the release train also publishes a **proposal +pre-release** — `v`, tagged at the exact commit being deployed — +whose page carries the signer instructions and these assets: -Once the final approval lands and the upgrade executes on chain, the release -watcher (`watch-mainnet-release.yml`) cuts the GitHub release and publishes the -artifacts. - -> You may have been a *second* signer before. That part is done in the Polkadot -> UI. Being the **first** signer is different: it is the script run documented -> below, because only the first signer has to assemble the call data. +- `subtensor.wasm` and `subtensor-digest.json` — CI's deterministic srtool build +- `proxy_proxy_blob.hex` — the call data being signed +- `pending-release.json` — machine-readable proposal record (timepoint etc.) +- `upgrade-manifest.json` — everything btcli needs (call hash, commit, signer + set, asset URLs) -## First signer: step by step +The **URL of that pre-release page is the one thing signers need**. Once the +final approval lands and the upgrade executes on chain, the release watcher +(`watch-mainnet-release.yml`) promotes the pre-release to the final release +and publishes the rest of the artifacts. -Only the **first** signer runs the script. Everyone else approves in the UI. +## Signing an upgrade (the btcli path) -### 0. One-time setup +Every triumvirate signer — first, interior, or last — runs the same command: ``` -cd .github/scripts/deploy -npm ci +btcli upgrade sign --url https://github.com//subtensor/releases/tag/v -w ``` -You also need `python3` (for the verification step) and your triumvirate -mnemonic. +Before submitting anything, btcli verifies: -### 1. Find the CI proposal run +- the call data is *exactly* + `proxy.proxy(sudo_key, None, sudo.sudoUncheckedWeight(system.setCode(), ))` + — no batches, no extra calls (re-encoded byte-for-byte against live chain + metadata); +- the embedded runtime matches the release's srtool digest; +- the proxied account is the chain's live `sudo.key()`; +- a pending on-chain deployment-multisig proposal carries `blake2_256(call data)`; +- the resolved signer set derives the on-chain sudo key. -Open the **Release Train** GitHub Action run that proposed this upgrade -(Actions tab). You need its artifact and one timepoint from it. +It then reads your position from chain state — whether the sudo multisig +operation is not yet opened (you are the first signer), underway (interior +approval), or one approval short (your `as_multi` executes the upgrade) — and +submits the matching extrinsic. No signer numbers, no timepoints, no +PolkadotJS. -### 2. Build the runtime yourself with srtool +Related commands: -Do not sign a WASM you were simply handed. Build the runtime from source with -srtool and confirm your output matches CI's — this is the step that makes the -later check meaningful. srtool builds are deterministic, so an identical source -at the same toolchain produces a byte-identical, identically-hashed runtime. +``` +btcli upgrade pending # discover pending proposals from chain state alone +btcli upgrade check --url ... # run every verification without signing +btcli multisig pending -w # pending sudo-multisig ops; also lists pending upgrades +``` -Check out this repo at the exact ref being deployed (the commit on `main` -that triggered the run) and build with the **same parameters CI uses**. The -authoritative recipe is the srtool build step in -[`.github/workflows/release-train.yml`](../../workflows/release-train.yml); -the key parameters are package `node-subtensor-runtime`, profile `production`, -and build option `--features=metadata-hash`. +### Verifying against code you built yourself (recommended) + +Do not sign a WASM you were simply handed. srtool builds are deterministic: +identical source at the same toolchain produces a byte-identical runtime. The +pre-release tag *is* the code being deployed, so: ``` -git checkout +git fetch origin && git checkout v # srtool expects the runtime crate in a directory named after the package: ln -s . runtime/node-subtensor @@ -90,108 +99,63 @@ docker run --rm --user root --platform=linux/amd64 \ ``` Use the srtool image whose Rust version matches subtensor's pinned toolchain -(CI pins this in `scripts/srtool/build-srtool-image.sh`, currently `1.89.0`). -If no prebuilt `paritytech/srtool:` image exists for that -version, build one from source with `scripts/srtool/build-srtool-image.sh` — -that is what CI does. The [`srtool-cli`](https://github.com/chevdor/srtool-cli) -wrapper auto-selects the image and is an easier alternative to the raw -`docker run`. - -When the build finishes, srtool prints the path to the generated -`...node_subtensor_runtime.compact.compressed.wasm` and a one-line JSON digest -as its final output line. - -### 3. Confirm your build matches CI, then assemble the files +(CI pins this in `scripts/srtool/build-srtool-image.sh`; the release's +`upgrade-manifest.json` records the version used). If no prebuilt +`paritytech/srtool:` image exists for that version, build one from +source with `scripts/srtool/build-srtool-image.sh` — that is what CI does. The +[`srtool-cli`](https://github.com/chevdor/srtool-cli) wrapper auto-selects the +image and is an easier alternative to the raw `docker run`. -Download CI's artifact from the proposal run (Actions → the run → -**Artifacts** → `mainnet-upgrade-`). It contains: - -- `subtensor.wasm` and `subtensor-digest.json` — CI's build and digest -- `proxy_proxy_blob.hex` — the call data you will sign -- `pending-release.json` — machine-readable proposal record (timepoint etc.) - -Compare your local srtool output against CI's — the SHA256 must be identical: +Then pin the call data to *your* build: ``` -# your local build: -shasum -a 256 .../node_subtensor_runtime.compact.compressed.wasm -# CI's digest (sha256 field, minus the 0x): -cat subtensor-digest.json +btcli upgrade check --url --wasm ./runtime/target/srtool/production/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm +btcli upgrade sign --url --wasm -w ``` -If they differ, **stop** — your source/toolchain does not match what CI built, -and you must resolve that before signing. - -Once they match, put these three files in this directory -(`.github/scripts/deploy`, next to `package.json`). Use **your locally built** -runtime as `subtensor.wasm` and your srtool digest as `subtensor-digest.json`, -plus CI's `proxy_proxy_blob.hex`: - -- `subtensor.wasm` (your build) -- `subtensor-digest.json` (your build) -- `proxy_proxy_blob.hex` (from CI) - -Then verify the call data embeds that exact runtime: - -``` -python3 test-wasm.py -``` - -This confirms `subtensor.wasm` hashes to `subtensor-digest.json` and that -`proxy_proxy_blob.hex` is, byte for byte, the expected -`proxy.proxy(sudo_key, None, sudo.sudoUncheckedWeight(system.setCode(), ))` -call — the WASM must be the `code` argument of `setCode`, and the call data can -contain nothing else (no batches, no extra calls). It must print -`WASM is correct` before you sign anything. Because the `subtensor.wasm` here is -the one *you* built, a passing check proves the call data executes `setCode` -with exactly the runtime you compiled from source. The one part the script -cannot pin offline is the 32-byte proxied account, which must be the chain's -sudo key — `prod-approval.js` verifies the sudo key separately before -submitting, and the proxy call fails on chain if that account is not the sudo -key. - -### 4. Note the deployment timepoint - -Read `blockHeight` and `extrinsicIndex` from CI's `pending-release.json` -(under `proposal`). Those two numbers are the timepoint of CI's -deployment-multisig approval — you pass them to the script in the next step as -`` and ``. The same numbers appear in the **propose** job's -summary table (Block Height / Extrinsic Index). - -### 5. Run the first-signer approval - -``` -npm run prod-approval -- 0 proxy_proxy_blob.hex -``` - -- `` — the target chain, e.g. `wss://entrypoint-finney.opentensor.ai:443` -- `` — `5FW3gUUAnWZFTG3QWijcGV9ji3iySsuCj12TQi2eDtgGvxej` -- `0` — your signer number; the first signer is always `0` -- `` `` — the timepoint from step 4 -- `proxy_proxy_blob.hex` — the CI call-data file from step 3 - -You will be prompted for your `mnemonic:` (input is hidden). The script first -checks that the triumvirate 2-of-3 derives the on-chain sudo key and aborts if -it does not, then submits your approval. - -### 6. Hand off to the other signers - -The script writes `deployment-multisig-proposal.hex` and prints a summary -containing a **Call Hash** and the **Block Height / Extrinsic Index** of *your* -approval. Send the other triumvirate signers: - -- the **Call Hash**, -- the **timepoint** (your Block Height + Extrinsic Index), and -- the `deployment-multisig-proposal.hex` file (the final signer needs the call - data). - -They then complete it from PolkadotJS Apps (Developer → Multisig, or via the -sudo multisig account) — interior signers `approveAsMulti` with the call hash + -your timepoint, and the last signer `asMulti` with the call data + your -timepoint. When the final approval lands, the spec version bumps and the -release watcher takes over. - -## Argument reference +A passing check then proves the call data executes `setCode` with exactly the +runtime you compiled from source. Without `--wasm`, `check`/`sign` still verify +the published artifacts against each other and the chain, but the runtime bytes +are trusted from the release. + +This structure — a URL anyone can fetch, call data anyone can re-derive from +source, and an on-chain hash anyone can compare — is the template for later +decentralized governance: any holder can `btcli upgrade check --url ...` and +assert the proposal on chain matches the code they are looking at. + +## Legacy path: the node scripts + +The pre-btcli flow still works and is kept as a fallback. It is documented +here in abbreviated form; the scripts are unchanged. + +1. **Find the CI proposal run** (Actions → Release Train) or the proposal + pre-release; download `subtensor.wasm`, `subtensor-digest.json`, + `proxy_proxy_blob.hex`, and `pending-release.json`. +2. **Build the runtime yourself with srtool** (see above) and confirm your + sha256 matches CI's digest. If they differ, **stop**. +3. **Verify the call data** — place `subtensor.wasm` (your build), + `subtensor-digest.json` (your digest), and `proxy_proxy_blob.hex` (CI's) in + this directory and run `python3 test-wasm.py`. It must print + `WASM is correct`. +4. **Note the deployment timepoint** — `blockHeight` / `extrinsicIndex` under + `proposal` in `pending-release.json`. +5. **First signer only** — one-time `npm ci` in this directory, then: + + ``` + npm run prod-approval -- 0 proxy_proxy_blob.hex + ``` + + You are prompted for your mnemonic (hidden). The script checks the + triumvirate 2-of-3 derives the on-chain sudo key, then submits the first + approval and writes `deployment-multisig-proposal.hex`. +6. **Hand off** — send the other signers the printed **Call Hash**, the + **timepoint** of your approval, and `deployment-multisig-proposal.hex`. + Interior signers `approveAsMulti` (call hash + timepoint) and the last + signer `asMulti` (call data + timepoint) from PolkadotJS Apps — or they + simply run `btcli upgrade sign --url ...`, which interoperates with + approvals made either way. + +### Argument reference (legacy scripts) `prod-approval.js` (and `samvps-approval.js`) take: @@ -208,6 +172,6 @@ release watcher takes over. The mnemonic is always read interactively, never passed as an argument. -Triumvirate members and the threshold are defined in `prod-approval.js` -(`SUDO_SIGNATORIES`, `SUDO_THRESHOLD`). `samvps-approval.js` is the equivalent -for the sam-vps multisig. +Triumvirate members and the threshold are defined in `sudo-signatories.json` +(read by `prod-approval.js` and embedded into the release manifest). +`samvps-approval.js` is the equivalent for the sam-vps multisig. diff --git a/.github/scripts/deploy/prod-approval.js b/.github/scripts/deploy/prod-approval.js index 2c9571b119..0c2a90ce3d 100644 --- a/.github/scripts/deploy/prod-approval.js +++ b/.github/scripts/deploy/prod-approval.js @@ -1,13 +1,12 @@ const { main } = require("./approve-upgrade-multisig"); // New triumvirate (June 22, 2026). 2-of-3 derives sudo multisig -// 5DcSqBNqCmfdJZRGFSwwcRb2dZdJHZuKK8Tb1Gx8gbmF5E8s. -const SUDO_SIGNATORIES = [ - "5E7RCRrPVS8TckCDjr92B5ciGziwz2kfvxe4URy3L7AgirGJ", // A - "5FevFjov8435t5XC2MUSRpFYxtthE8pZy1toHpgAAia3ZphG", // B - "5GRCukV2rZmSVfJhAXoLjcrU1pMVCf2Ra1ydbiCFZdaQXDXo", // C -]; -const SUDO_THRESHOLD = 2; // 2 of 3 +// 5DcSqBNqCmfdJZRGFSwwcRb2dZdJHZuKK8Tb1Gx8gbmF5E8s. The signer set lives in +// sudo-signatories.json so the release-train manifest embeds the same list. +const { + signatories: SUDO_SIGNATORIES, + threshold: SUDO_THRESHOLD, +} = require("./sudo-signatories.json"); main(SUDO_SIGNATORIES, SUDO_THRESHOLD).catch((error) => { console.error(error.stack); diff --git a/.github/scripts/deploy/propose-upgrade-multisig.js b/.github/scripts/deploy/propose-upgrade-multisig.js index 174d8a9387..3787da5513 100644 --- a/.github/scripts/deploy/propose-upgrade-multisig.js +++ b/.github/scripts/deploy/propose-upgrade-multisig.js @@ -52,6 +52,7 @@ async function main() { const keyring = new Keyring({ type: "sr25519" }); const pair = keyring.addFromUri(seedPhrase); // CI's signing key const ciKeyAddress = pair.address; + console.log(`CI key address (fee payer + multisig depositor): ${ciKeyAddress}`); // Grab the sudo key from the chain const sudoKey = (await api.query.sudo.key()).toString(); @@ -81,6 +82,24 @@ async function main() { ); } + // The CI key pays the extrinsic fee and the multisig deposit + // (depositBase + threshold * depositFactor), so an unfunded key fails with + // an opaque 1010 at submission time. Check up front and name the account. + const account = await api.query.system.account(ciKeyAddress); + const free = account.data.free.toBigInt(); + const depositBase = api.consts.multisig.depositBase.toBigInt(); + const depositFactor = api.consts.multisig.depositFactor.toBigInt(); + const required = depositBase + 2n * depositFactor; + console.log( + `CI key free balance: ${free} rao; multisig deposit needed: ${required} rao (+ fee)` + ); + if (free < required) { + throw Error( + `CI key ${ciKeyAddress} has ${free} rao free but needs at least ` + + `${required} rao for the multisig deposit plus fees — fund it and re-run` + ); + } + // Read the WASM file and convert it to hex string const wasm = fs.readFileSync(wasmPath).toString("hex"); console.log(`WASM file size (hex): ${wasm.length / 2} bytes`); @@ -197,13 +216,15 @@ blob artifact provided by the CI. fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); } - // Machine-readable record for the release watcher + // Machine-readable record for the release watcher and upgrade manifest const pendingRelease = { callHash, blockHash, blockHeight, extrinsicIndex, multisigAddress, + ciAddress: ciKeyAddress, + sudoKey, specVersionBefore, }; fs.writeFileSync( diff --git a/.github/scripts/deploy/sudo-signatories.json b/.github/scripts/deploy/sudo-signatories.json new file mode 100644 index 0000000000..6fce246ed7 --- /dev/null +++ b/.github/scripts/deploy/sudo-signatories.json @@ -0,0 +1,9 @@ +{ + "_note": "Production sudo multisig (triumvirate) signer set. 2-of-3 derives the on-chain sudo key 5DcSqBNqCmfdJZRGFSwwcRb2dZdJHZuKK8Tb1Gx8gbmF5E8s. Consumed by prod-approval.js and embedded into upgrade-manifest.json by release-train.yml; btcli verifies the derived address against the live sudo key before signing, so a stale entry fails loudly.", + "signatories": [ + "5E7RCRrPVS8TckCDjr92B5ciGziwz2kfvxe4URy3L7AgirGJ", + "5FevFjov8435t5XC2MUSRpFYxtthE8pZy1toHpgAAia3ZphG", + "5GRCukV2rZmSVfJhAXoLjcrU1pMVCf2Ra1ydbiCFZdaQXDXo" + ], + "threshold": 2 +} diff --git a/.github/scripts/snapshot-artifact.sh b/.github/scripts/snapshot-artifact.sh index 0edaa9e0b2..d2c977ecf6 100755 --- a/.github/scripts/snapshot-artifact.sh +++ b/.github/scripts/snapshot-artifact.sh @@ -6,13 +6,11 @@ usage() { cat >&2 <<'EOF' Usage: snapshot-artifact.sh mode EVENT_NAME LABELS_JSON MANUAL_FRESH OUTPUT_FILE - snapshot-artifact.sh select ARTIFACT_NAME BRANCH REPOSITORY_ID MAX_AGE_HOURS OUTPUT_FILE [required|optional] - snapshot-artifact.sh download ARTIFACT_ID DIGEST DESTINATION - snapshot-artifact.sh validate MANIFEST_FILE SNAPSHOT_FILE NETWORK GENESIS_HASH CLI_VERSION + snapshot-artifact.sh select ARTIFACT_NAME BRANCH REPOSITORY_ID WORKFLOW_PATH MAX_AGE_HOURS OUTPUT_FILE [required|optional] + snapshot-artifact.sh validate MANIFEST_FILE SNAPSHOT_FILE NETWORK GENESIS_HASH CLI_VERSION PRODUCER_SHA -For tests, set ARTIFACTS_JSON_FILE instead of calling the GitHub API, -ARTIFACT_ZIP_FILE instead of downloading an artifact, and NOW_EPOCH to -override the current time. +For tests, set ARTIFACTS_JSON_FILE and WORKFLOW_RUNS_JSON_FILE instead of +calling the GitHub API and NOW_EPOCH to override the current time. EOF exit 2 } @@ -62,16 +60,21 @@ resolve_mode() { } select_artifact() { - [[ $# -eq 6 ]] || usage + [[ $# -eq 7 ]] || usage local artifact_name="$1" local branch="$2" local repository_id="$3" - local max_age_hours="$4" - local output_file="$5" - local requirement="$6" - local payload now candidate created_epoch age_seconds age_hours + local workflow_path="$4" + local max_age_hours="$5" + local output_file="$6" + local requirement="$7" + local payload workflow_runs workflow_file now candidate created_epoch age_seconds age_hours [[ "$repository_id" =~ ^[0-9]+$ ]] || { echo "invalid repository id: $repository_id" >&2; exit 2; } + [[ "$workflow_path" =~ ^\.github/workflows/[A-Za-z0-9._-]+\.ya?ml$ ]] || { + echo "invalid workflow path: $workflow_path" >&2 + exit 2 + } [[ "$max_age_hours" =~ ^[0-9]+$ ]] || { echo "invalid maximum age: $max_age_hours" >&2; exit 2; } [[ "$requirement" == required || "$requirement" == optional ]] || usage @@ -82,30 +85,58 @@ select_artifact() { payload=$(gh api \ "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$artifact_name&per_page=100") fi + if [[ -n "${WORKFLOW_RUNS_JSON_FILE:-}" ]]; then + workflow_runs=$(<"$WORKFLOW_RUNS_JSON_FILE") + else + workflow_file="${workflow_path##*/}" + workflow_runs=$(gh api --method GET \ + "repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/runs" \ + -f branch="$branch" -F per_page=100 -F exclude_pull_requests=true \ + --jq '{workflow_runs: [.workflow_runs[] | { + id, path, head_branch, head_sha, + repository: {id: .repository.id}, + head_repository: {id: .head_repository.id} + }]}') + fi now="${NOW_EPOCH:-$(date -u +%s)}" [[ "$now" =~ ^[0-9]+$ ]] || { echo "invalid NOW_EPOCH: $now" >&2; exit 2; } - candidate=$(jq -cer \ + candidate=$(jq -ncer \ --arg name "$artifact_name" \ --arg branch "$branch" \ + --arg workflow_path "$workflow_path" \ --argjson repository_id "$repository_id" \ --argjson now "$now" \ - --argjson max_age_seconds "$((max_age_hours * 3600))" ' + --argjson max_age_seconds "$((max_age_hours * 3600))" \ + --argjson artifacts "$payload" \ + --argjson workflow_runs "$workflow_runs" ' + ($workflow_runs.workflow_runs + | map(select( + .path == $workflow_path and + .head_branch == $branch and + .repository.id == $repository_id and + .head_repository.id == $repository_id and + (.head_sha | test("^[0-9a-f]{40}$")) + )) + | map({key: (.id | tostring), value: .head_sha}) + | from_entries) as $trusted_runs + | [ - .artifacts[] + $artifacts.artifacts[] | select(.name == $name) | select(.expired == false) | select(.workflow_run.head_branch == $branch) | select(.workflow_run.repository_id == $repository_id) | select(.workflow_run.head_repository_id == $repository_id) + | select($trusted_runs[(.workflow_run.id | tostring)] == .workflow_run.head_sha) | .created_epoch = (.created_at | fromdateiso8601) | select(.created_epoch <= $now) | select(($now - .created_epoch) <= $max_age_seconds) ] | sort_by(.created_epoch) | last // empty - ' <<<"$payload" 2>/dev/null || true) + ' 2>/dev/null || true) if [[ -z "$candidate" ]]; then set_output "$output_file" found false @@ -124,57 +155,23 @@ select_artifact() { set_output "$output_file" found true set_output "$output_file" artifact-id "$(jq -er '.id' <<<"$candidate")" set_output "$output_file" run-id "$(jq -er '.workflow_run.id' <<<"$candidate")" + set_output "$output_file" producer-sha "$(jq -er '.workflow_run.head_sha' <<<"$candidate")" set_output "$output_file" created-at "$(jq -er '.created_at' <<<"$candidate")" set_output "$output_file" age-hours "$age_hours" - set_output "$output_file" size-bytes "$(jq -er '.size_in_bytes' <<<"$candidate")" - set_output "$output_file" digest "$(jq -er '.digest // ""' <<<"$candidate")" - if ((age_seconds > 36 * 3600)); then echo "::warning::$artifact_name is ${age_hours}h old; refresh is expected daily and this artifact becomes unusable after ${max_age_hours}h." fi echo "Selected $artifact_name artifact $(jq -er '.id' <<<"$candidate") from run $(jq -er '.workflow_run.id' <<<"$candidate") (${age_hours}h old)." } -download_artifact() { - [[ $# -eq 3 ]] || usage - local artifact_id="$1" - local digest="$2" - local destination="$3" - local archive actual_digest - - [[ "$artifact_id" =~ ^[0-9]+$ ]] || { echo "invalid artifact id: $artifact_id" >&2; exit 2; } - [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "invalid artifact digest: $digest" >&2; exit 2; } - - archive=$(mktemp) - trap 'rm -f "$archive"' RETURN - if [[ -n "${ARTIFACT_ZIP_FILE:-}" ]]; then - cp "$ARTIFACT_ZIP_FILE" "$archive" - else - : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must be set}" - gh api \ - -H 'Accept: application/vnd.github+json' \ - "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" > "$archive" - fi - - actual_digest="sha256:$(sha256sum "$archive" | awk '{print $1}')" - [[ "$actual_digest" == "$digest" ]] || { - echo "artifact archive checksum mismatch: expected $digest, got $actual_digest" >&2 - exit 1 - } - mkdir -p "$destination" - unzip -q "$archive" -d "$destination" - rm -f "$archive" - trap - RETURN - echo "Downloaded and verified artifact $artifact_id." -} - validate_manifest() { - [[ $# -eq 5 ]] || usage + [[ $# -eq 6 ]] || usage local manifest_file="$1" local snapshot_file="$2" local network="$3" local genesis_hash="$4" local cli_version="$5" + local producer_sha="$6" local expected_file expected_size expected_sha actual_size actual_sha [[ -f "$manifest_file" ]] || { echo "missing snapshot manifest: $manifest_file" >&2; exit 1; } @@ -183,7 +180,8 @@ validate_manifest() { jq -e \ --arg network "$network" \ --arg genesis "$genesis_hash" \ - --arg cli "$cli_version" ' + --arg cli "$cli_version" \ + --arg producer_sha "$producer_sha" ' .schema_version == 1 and .kind == "try-runtime-state" and .network == $network and @@ -194,7 +192,7 @@ validate_manifest() { (.source_spec_name | type == "string" and length > 0) and (.source_spec_version | type == "number") and (.created_at | fromdateiso8601 | type == "number") and - (.producer_sha | test("^[0-9a-f]{40}$")) and + .producer_sha == $producer_sha and (.snapshot_file | type == "string" and length > 0) and (.snapshot_size_bytes | type == "number") and (.snapshot_sha256 | test("^[0-9a-f]{64}$")) @@ -231,7 +229,6 @@ shift || true case "$command" in mode) resolve_mode "$@" ;; select) select_artifact "$@" ;; - download) download_artifact "$@" ;; validate) validate_manifest "$@" ;; *) usage ;; esac diff --git a/.github/scripts/test-runtime-change-filter.sh b/.github/scripts/test-runtime-change-filter.sh new file mode 100755 index 0000000000..8ee778d3cb --- /dev/null +++ b/.github/scripts/test-runtime-change-filter.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +classifier="$script_dir/classify-runtime-changes.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +assert_classification() { + local path="$1" expected="$2" output="$tmp/output" + : > "$output" + printf '%s\n' "$path" | "$classifier" "$output" >/dev/null + diff -u <(printf '%s\n' "$expected") "$output" +} + +all_false=$'runtime=false\ndocs=false\npython_sdk=false\nsdk_drift=false\nsnapshot_ci=false' +runtime_only=$'runtime=true\ndocs=false\npython_sdk=false\nsdk_drift=false\nsnapshot_ci=false' +runtime_and_sdk=$'runtime=true\ndocs=false\npython_sdk=false\nsdk_drift=true\nsnapshot_ci=false' +runtime_and_docs=$'runtime=true\ndocs=true\npython_sdk=false\nsdk_drift=false\nsnapshot_ci=false' +runtime_and_snapshot_only=$'runtime=true\ndocs=false\npython_sdk=false\nsdk_drift=false\nsnapshot_ci=true' +runtime_and_snapshot=$'runtime=true\ndocs=true\npython_sdk=true\nsdk_drift=false\nsnapshot_ci=true' +docs_and_python=$'runtime=false\ndocs=true\npython_sdk=true\nsdk_drift=false\nsnapshot_ci=false' +python_only=$'runtime=false\ndocs=false\npython_sdk=true\nsdk_drift=false\nsnapshot_ci=false' + +assert_classification README.md "$all_false" +assert_classification .github/actions/rust-setup/action.yml "$runtime_only" +assert_classification .github/actions/sccache-setup/action.yml "$runtime_only" +assert_classification .github/scripts/classify-runtime-changes.sh "$runtime_and_snapshot_only" +assert_classification .github/scripts/test-runtime-change-filter.sh "$runtime_and_snapshot_only" +assert_classification clones/scripts/start-local-clone-and-wait.sh "$runtime_only" +assert_classification .github/workflows/refresh-mainnet-snapshot.yml "$runtime_and_snapshot_only" +assert_classification .github/workflows/runtime-checks.yml "$runtime_and_snapshot" +assert_classification website/apps/bittensor-website/scripts/generate-metadata.mjs "$runtime_and_docs" +assert_classification sdk/bittensor-core/src/lib.rs "$python_only" +assert_classification Cargo.lock "$python_only" +assert_classification rust-toolchain.toml "$runtime_and_sdk" +assert_classification $'README.md\nsdk/python/example.py' "$docs_and_python" +assert_classification $'README.md\nnode/src/renamed-service.rs' "$runtime_and_sdk" + +echo "runtime change filter tests passed" diff --git a/.github/scripts/test-snapshot-artifact.sh b/.github/scripts/test-snapshot-artifact.sh index 203270731b..a76d59f5d8 100755 --- a/.github/scripts/test-snapshot-artifact.sh +++ b/.github/scripts/test-snapshot-artifact.sh @@ -13,14 +13,34 @@ now=$(jq -nr '"2026-07-12T14:00:00Z" | fromdateiso8601') repo_id=608683796 write_artifacts() { - jq -n --argjson artifacts "$1" '{artifacts: $artifacts}' > "$tmp/artifacts.json" + local artifacts="$1" + jq -n --argjson artifacts "$artifacts" '{artifacts: $artifacts}' > "$tmp/artifacts.json" + jq -n --argjson artifacts "$artifacts" ' + { + workflow_runs: [ + $artifacts[] + | { + id: .workflow_run.id, + path: ".github/workflows/refresh-mainnet-snapshot.yml", + head_branch: .workflow_run.head_branch, + head_sha: .workflow_run.head_sha, + repository: {id: .workflow_run.repository_id}, + head_repository: {id: .workflow_run.head_repository_id}, + conclusion: (.producer_conclusion // "success") + } + ] | unique_by(.id) + } + ' > "$tmp/workflow-runs.json" } artifact() { local id="$1" name="$2" created="$3" branch="$4" repository_id="$5" expired="$6" run_id="$7" + local head_sha + printf -v head_sha '%040x' "$run_id" jq -nc \ --argjson id "$id" --arg name "$name" --arg created "$created" --arg branch "$branch" \ - --argjson repository_id "$repository_id" --argjson expired "$expired" --argjson run_id "$run_id" ' + --argjson repository_id "$repository_id" --argjson expired "$expired" --argjson run_id "$run_id" \ + --arg head_sha "$head_sha" ' { id: $id, name: $name, @@ -32,7 +52,8 @@ artifact() { id: $run_id, repository_id: $repository_id, head_repository_id: $repository_id, - head_branch: $branch + head_branch: $branch, + head_sha: $head_sha } } ' @@ -43,8 +64,11 @@ select_fixture() { local output="$tmp/output" local status=0 : > "$output" - ARTIFACTS_JSON_FILE="$tmp/artifacts.json" NOW_EPOCH="$now" \ - "$helper" select try-runtime-snap-v0.10.1-mainnet main "$repo_id" 72 "$output" "$requirement" || status=$? + ARTIFACTS_JSON_FILE="$tmp/artifacts.json" \ + WORKFLOW_RUNS_JSON_FILE="$tmp/workflow-runs.json" \ + NOW_EPOCH="$now" \ + "$helper" select try-runtime-snap-v0.10.1-mainnet main "$repo_id" \ + .github/workflows/refresh-mainnet-snapshot.yml 72 "$output" "$requirement" || status=$? cat "$output" return "$status" } @@ -76,26 +100,19 @@ valid_new=$(artifact 12 try-runtime-snap-v0.10.1-mainnet 2026-07-12T02:00:00Z ma wrong_branch=$(artifact 13 try-runtime-snap-v0.10.1-mainnet 2026-07-12T13:00:00Z feature "$repo_id" false 103) wrong_repo=$(artifact 14 try-runtime-snap-v0.10.1-mainnet 2026-07-12T13:30:00Z main 999 false 104) expired=$(artifact 15 try-runtime-snap-v0.10.1-mainnet 2026-07-12T13:45:00Z main "$repo_id" true 105) -write_artifacts "$(jq -nc --argjson a "$valid_old" --argjson b "$valid_new" --argjson c "$wrong_branch" --argjson d "$wrong_repo" --argjson e "$expired" '[ $a, $b, $c, $d, $e ]')" +wrong_workflow=$(artifact 16 try-runtime-snap-v0.10.1-mainnet 2026-07-12T13:50:00Z main "$repo_id" false 106) +wrong_sha=$(artifact 17 try-runtime-snap-v0.10.1-mainnet 2026-07-12T13:55:00Z main "$repo_id" false 107) +write_artifacts "$(jq -nc --argjson a "$valid_old" --argjson b "$valid_new" --argjson c "$wrong_branch" --argjson d "$wrong_repo" --argjson e "$expired" --argjson f "$wrong_workflow" --argjson g "$wrong_sha" '[ $a, $b, $c, $d, $e, $f, $g ]')" +jq '(.workflow_runs[] | select(.id == 106) | .path) = ".github/workflows/untrusted-producer.yml"' \ + "$tmp/workflow-runs.json" > "$tmp/workflow-runs-updated.json" +mv "$tmp/workflow-runs-updated.json" "$tmp/workflow-runs.json" +jq '(.workflow_runs[] | select(.id == 107) | .head_sha) = "ffffffffffffffffffffffffffffffffffffffff"' \ + "$tmp/workflow-runs.json" > "$tmp/workflow-runs-updated.json" +mv "$tmp/workflow-runs-updated.json" "$tmp/workflow-runs.json" selected=$(select_fixture) grep -qx 'artifact-id=12' <<<"$selected" grep -qx 'run-id=102' <<<"$selected" - -# The selected immutable artifact ID is downloaded as its archive, verified -# against the API digest, and only then extracted. -mkdir -p "$tmp/archive-input" -printf 'artifact payload' > "$tmp/archive-input/payload.txt" -(cd "$tmp/archive-input" && zip -q "$tmp/artifact.zip" payload.txt) -archive_digest="sha256:$(sha256sum "$tmp/artifact.zip" | awk '{print $1}')" -ARTIFACT_ZIP_FILE="$tmp/artifact.zip" \ - "$helper" download 12 "$archive_digest" "$tmp/downloaded" >/dev/null -grep -qx 'artifact payload' "$tmp/downloaded/payload.txt" -if ARTIFACT_ZIP_FILE="$tmp/artifact.zip" \ - "$helper" download 12 sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ - "$tmp/bad-download" >/dev/null 2>&1; then - echo "expected artifact archive checksum mismatch to fail" >&2 - exit 1 -fi +grep -qx 'producer-sha=0000000000000000000000000000000000000066' <<<"$selected" # Exactly 72 hours is accepted, one second older fails, and optional lookup # reports a miss. An artifact older than 36 hours remains usable with a warning. @@ -108,8 +125,10 @@ warning_age=$(artifact 18 try-runtime-snap-v0.10.1-mainnet 2026-07-11T01:59:59Z write_artifacts "[$warning_age]" warning_output="$tmp/warning-output" : > "$warning_output" -warning_log=$(ARTIFACTS_JSON_FILE="$tmp/artifacts.json" NOW_EPOCH="$now" \ - "$helper" select try-runtime-snap-v0.10.1-mainnet main "$repo_id" 72 "$warning_output" required) +warning_log=$(ARTIFACTS_JSON_FILE="$tmp/artifacts.json" \ + WORKFLOW_RUNS_JSON_FILE="$tmp/workflow-runs.json" NOW_EPOCH="$now" \ + "$helper" select try-runtime-snap-v0.10.1-mainnet main "$repo_id" \ + .github/workflows/refresh-mainnet-snapshot.yml 72 "$warning_output" required) grep -q '^::warning::' <<<"$warning_log" too_old=$(artifact 20 try-runtime-snap-v0.10.1-mainnet 2026-07-09T13:59:59Z main "$repo_id" false 200) @@ -146,17 +165,27 @@ jq -n \ ' > "$tmp/mainnet.manifest.json" "$helper" validate "$tmp/mainnet.manifest.json" "$tmp/mainnet.snap" mainnet \ - 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 0.10.1 >/dev/null + 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 \ + 0.10.1 647ca2b0493ed5c74399b73f2595643ba785c1b8 >/dev/null + +if "$helper" validate "$tmp/mainnet.manifest.json" "$tmp/mainnet.snap" mainnet \ + 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 \ + 0.10.1 ffffffffffffffffffffffffffffffffffffffff >/dev/null 2>&1; then + echo "expected producer SHA mismatch to fail" >&2 + exit 1 +fi if "$helper" validate "$tmp/mainnet.manifest.json" "$tmp/mainnet.snap" testnet \ - 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 0.10.1 >/dev/null 2>&1; then + 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 \ + 0.10.1 647ca2b0493ed5c74399b73f2595643ba785c1b8 >/dev/null 2>&1; then echo "expected network mismatch to fail" >&2 exit 1 fi printf 'corrupt' >> "$tmp/mainnet.snap" if "$helper" validate "$tmp/mainnet.manifest.json" "$tmp/mainnet.snap" mainnet \ - 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 0.10.1 >/dev/null 2>&1; then + 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 \ + 0.10.1 647ca2b0493ed5c74399b73f2595643ba785c1b8 >/dev/null 2>&1; then echo "expected checksum/size mismatch to fail" >&2 exit 1 fi diff --git a/.github/workflows/check-bittensor-e2e-tests.yml b/.github/workflows/check-bittensor-e2e-tests.yml index f720171683..c3c475f4af 100644 --- a/.github/workflows/check-bittensor-e2e-tests.yml +++ b/.github/workflows/check-bittensor-e2e-tests.yml @@ -347,7 +347,15 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Pull Docker Image - run: docker pull "$LOCALNET_IMAGE_CI" + # GHCR intermittently returns permission_denied/timeouts under the + # ~120-job pull fan-out, so retry with backoff instead of failing the shard. + run: | + for i in 1 2 3; do + docker pull "$LOCALNET_IMAGE_CI" && exit 0 + echo "docker pull failed (attempt $i), retrying in $((i * 15))s..." + sleep $((i * 15)) + done + docker pull "$LOCALNET_IMAGE_CI" - name: Retag Docker Image run: docker tag "$LOCALNET_IMAGE_CI" "$LOCALNET_IMAGE" diff --git a/.github/workflows/check-rust.yml b/.github/workflows/check-rust.yml index 4fb0863d70..d6c4f37658 100644 --- a/.github/workflows/check-rust.yml +++ b/.github/workflows/check-rust.yml @@ -173,6 +173,39 @@ jobs: - name: cargo test --doc --workspace --all-features run: cargo test --doc --workspace --all-features + # The `jemalloc-allocator` feature is off by default, so the default build + # never compiles it. Without an explicit gate it rots silently: a + # `tikv-jemallocator` bump, a `#[global_allocator]` typo, or a `cfg` mistake + # would break the opt-in allocator path with nothing failing. This job + # compiles+links the node with the feature (build gate) and runs the + # activation test proving jemalloc is the *live* global allocator, not merely + # linked - the failure mode a plain `cargo check` cannot see. + jemalloc-feature: + name: jemalloc-allocator feature gate + needs: [trusted-pr, changes] + if: github.event_name != 'pull_request' || needs.changes.outputs.rust == 'true' + runs-on: [self-hosted, fireactions-turbo-8] + environment: + name: ${{ github.event_name == 'workflow_dispatch' && 'sccache-reader' || 'sccache-writer' }} + deployment: false + env: + SKIP_WASM_BUILD: 1 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-setup + with: + cache-key: jemalloc-feature + sccache-credential-mode: auto + sccache-writer-access-key-id: ${{ secrets.SCCACHE_R2_WRITE_ACCESS_KEY_ID }} + sccache-writer-secret-access-key: ${{ secrets.SCCACHE_R2_WRITE_SECRET_ACCESS_KEY }} + - name: Build gate (node compiles + links the feature) + run: cargo check -p node-subtensor --features jemalloc-allocator --all-targets + # No test-name filter: a filtered `cargo test` exits 0 if the test is ever + # renamed (0 run), so the gate would pass silently. Running the node's + # full test target with the feature guarantees the activation test runs. + - name: Activation test (jemalloc is the live global allocator) + run: cargo test -p node-subtensor --features jemalloc-allocator + # The browser seam: bittensor-core without its `host` feature must keep # compiling for wasm32 (the future wasm-bindgen/TS binding target). This # check is what keeps "browser-compatible" an invariant instead of an diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index d1d8834c4f..a7c247019c 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -11,6 +11,11 @@ on: - "docs/**" - "website/**" workflow_dispatch: + inputs: + production: + description: "Deploy to production (bittensor.com) instead of staging" + type: boolean + default: false concurrency: group: deploy-docs @@ -20,6 +25,11 @@ jobs: deploy: name: Deploy staging docs to Vercel runs-on: [self-hosted, fireactions-turbo-8] + # Production deploys only run from main (a dispatch on any other ref would + # otherwise ship unreviewed code) and are gated behind the same environment + # approval as the post-upgrade promotion in watch-mainnet-release.yml. + if: ${{ !inputs.production || github.ref == 'refs/heads/main' }} + environment: ${{ inputs.production && 'mainnet' || null }} env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} @@ -43,9 +53,16 @@ jobs: # CLI resolves it against the cwd (running from website/ doubles the # path and fails with ENOENT). run: | - npx --yes vercel pull --yes --environment=preview --token "$VERCEL_TOKEN" - npx --yes vercel build --token "$VERCEL_TOKEN" - url=$(npx --yes vercel deploy --prebuilt --token "$VERCEL_TOKEN") - echo "Deployed: $url" - npx --yes vercel alias set "$url" "staging.$DOCS_DOMAIN" --token "$VERCEL_TOKEN" - echo "Aliased to https://staging.$DOCS_DOMAIN" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.production }}" = "true" ]; then + npx --yes vercel pull --yes --environment=production --token "$VERCEL_TOKEN" + npx --yes vercel build --prod --token "$VERCEL_TOKEN" + npx --yes vercel deploy --prebuilt --prod --token "$VERCEL_TOKEN" + echo "Deployed to https://$DOCS_DOMAIN (production)" >> $GITHUB_STEP_SUMMARY + else + npx --yes vercel pull --yes --environment=preview --token "$VERCEL_TOKEN" + npx --yes vercel build --token "$VERCEL_TOKEN" + url=$(npx --yes vercel deploy --prebuilt --token "$VERCEL_TOKEN") + echo "Deployed: $url" + npx --yes vercel alias set "$url" "staging.$DOCS_DOMAIN" --token "$VERCEL_TOKEN" + echo "Aliased to https://staging.$DOCS_DOMAIN" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/refresh-mainnet-snapshot.yml b/.github/workflows/refresh-mainnet-snapshot.yml index 0e48e72a7a..cc98c77aa6 100644 --- a/.github/workflows/refresh-mainnet-snapshot.yml +++ b/.github/workflows/refresh-mainnet-snapshot.yml @@ -24,7 +24,7 @@ on: workflow_dispatch: inputs: try_runtime_only: - description: "Generate only try-runtime snapshots (measurement/debugging)" + description: "Generate only try-runtime snapshots (targeted recovery)" type: boolean default: false @@ -32,12 +32,16 @@ permissions: contents: read concurrency: + # Serialize every ref globally: diagnostic dispatches must not cancel a + # running production refresh or consume a second RPC/runner slot alongside it. group: refresh-mainnet-snapshot - cancel-in-progress: true + cancel-in-progress: false env: CARGO_TERM_COLOR: always TRY_RUNTIME_VERSION: "0.10.1" + # v0.10.1 exposes parallelism only through the number of URI values. + SNAPSHOT_RPC_CLIENTS: "4" jobs: snapshot: @@ -71,31 +75,11 @@ jobs: - name: Genesis-init the local clone database run: | - nohup ./clones/scripts/start-local-clone.sh > clone-node.log 2>&1 & # Genesis init from the ~2 GB chainspec takes several minutes before - # the RPC server comes up. - up=false - for _ in $(seq 1 450); do - if curl -sf -H "Content-Type: application/json" \ - -d '{"id":1,"jsonrpc":"2.0","method":"system_health","params":[]}' \ - http://127.0.0.1:9944 > /dev/null; then - up=true - break - fi - sleep 2 - done - if [ "$up" != true ]; then - echo "Clone node failed to start:" - cat clone-node.log - exit 1 - fi + # the RPC server comes up. Preserve the node's default consensus mode + # and only require RPC health before packaging. + ./clones/scripts/start-local-clone-and-wait.sh node-default ./clones/scripts/stop-local-clone.sh - # pkill returns before the node finishes flushing the db; wait for - # the process to actually exit before packaging. - for _ in $(seq 1 60); do - pgrep -f "node-subtensor.*--base-path clones/mainnet-clone" > /dev/null || break - sleep 2 - done - name: Package snapshot run: | @@ -120,13 +104,15 @@ jobs: # consumer pin in runtime-checks.yml. try-runtime-snapshot: name: try-runtime snapshot ${{ matrix.network.name }} - runs-on: [self-hosted, fireactions-tryruntime] + # Snapshot assembly exceeds the memory available on ubuntu-latest. This is + # scheduled, off-critical-path work, so use the shared 8-core pool instead + # of retaining a dedicated runner host. + runs-on: [self-hosted, fireactions-turbo-8] timeout-minutes: 90 strategy: fail-fast: false - # The shared pool has enough capacity for the three PR consumers. Keep - # the off-critical-path producer to one slot so a refresh cannot crowd - # those consumers out. + # Snapshot production is off the PR critical path. Serialize the network + # jobs to bound archive RPC load and shared-runner usage. max-parallel: 1 matrix: network: @@ -197,9 +183,20 @@ jobs: snapshot="${{ matrix.network.name }}.snap" manifest="${{ matrix.network.name }}.manifest.json" # The positional path must precede --uri because --uri is variadic. + # Each client creates four remote-externalities value-fetch workers; + # the archive endpoint is dedicated to this workload. + rpc_uri="${{ matrix.network.uri }}" + [[ "$SNAPSHOT_RPC_CLIENTS" =~ ^[1-9][0-9]*$ ]] || { + echo "invalid SNAPSHOT_RPC_CLIENTS: $SNAPSHOT_RPC_CLIENTS" >&2 + exit 2 + } + rpc_uris=() + for ((client = 0; client < SNAPSHOT_RPC_CLIENTS; client++)); do + rpc_uris+=("$rpc_uri") + done "$TRY_RUNTIME_BIN" create-snapshot \ "$snapshot" \ - --uri "${{ matrix.network.uri }}" \ + --uri "${rpc_uris[@]}" \ --at "${{ steps.source.outputs.block-hash }}" snapshot_size=$(stat -c '%s' "$snapshot") diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index 7869eb3bf5..95d69bd113 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -190,7 +190,7 @@ jobs: check-devnet: name: Smoke-check devnet needs: deploy-devnet - runs-on: [self-hosted, fireactions-tryruntime] + runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -278,7 +278,7 @@ jobs: check-testnet: name: Smoke-check testnet needs: deploy-testnet - runs-on: [self-hosted, fireactions-tryruntime] + runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -360,10 +360,14 @@ jobs: - name: Build SDK wheel and sdist working-directory: sdk/python run: uv build --out-dir ../../dist + # --check-url makes a re-run after a partial upload idempotent: files + # already on the index are skipped instead of failing with a 400 + # duplicate (e.g. the SDK wheel landed but bittensor-core didn't). - name: Publish to PyPI run: | ls -la dist - uv publish --trusted-publishing always dist/* + uv publish --trusted-publishing always \ + --check-url https://pypi.org/simple/ dist/* # ------------------------------------------------------------------ # Stage 3: mainnet — the human gate. @@ -378,6 +382,8 @@ jobs: runs-on: [self-hosted, fireactions-turbo-8] environment: mainnet timeout-minutes: 30 + permissions: + contents: write # publish the proposal pre-release + tag steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 @@ -449,6 +455,49 @@ jobs: > wasm/pending-release.json cat wasm/pending-release.json + # Everything a signer (or btcli) needs to locate, verify, and sign the + # proposal, in one machine-readable file. `btcli upgrade check/sign` + # consumes it via the release asset URL. + - name: Build upgrade manifest + if: steps.guard.outputs.should_propose == 'true' + run: | + spec="${{ needs.build.outputs.spec_version }}" + tag="v${spec}" + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}" + jq -n \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg tag "$tag" \ + --arg base "$base" \ + --arg network "finney" \ + --slurpfile pending wasm/pending-release.json \ + --slurpfile digest wasm/subtensor-digest.json \ + --slurpfile sudo .github/scripts/deploy/sudo-signatories.json \ + '{ + kind: "runtime-upgrade-proposal", + network: $network, + repo: $repo, + tag: $tag, + spec_version: $pending[0].expected_spec_version, + commit: $pending[0].commit, + call_hash: $pending[0].proposal.callHash, + proposal: $pending[0].proposal, + ci_address: $pending[0].proposal.ciAddress, + deployment_multisig: $pending[0].proposal.multisigAddress, + sudo_key: $pending[0].proposal.sudoKey, + sudo: {signatories: $sudo[0].signatories, threshold: $sudo[0].threshold}, + wasm_sha256: $digest[0].sha256, + srtool_rustc: ($digest[0].rustc // null), + max_weight: {ref_time: 50000000000, proof_size: 0}, + assets: { + wasm: ($base + "/subtensor.wasm"), + digest: ($base + "/subtensor-digest.json"), + call_data: ($base + "/proxy_proxy_blob.hex"), + pending_release: ($base + "/pending-release.json"), + manifest: ($base + "/upgrade-manifest.json") + } + }' > wasm/upgrade-manifest.json + cat wasm/upgrade-manifest.json + - name: Upload runtime + call data artifacts if: steps.guard.outputs.should_propose == 'true' uses: actions/upload-artifact@v4 @@ -459,5 +508,109 @@ jobs: wasm/subtensor-digest.json wasm/proxy_proxy_blob.hex wasm/pending-release.json + wasm/upgrade-manifest.json if-no-files-found: error retention-days: 90 + + # The single URL to share with the other signers. A *pre-release* at the + # deployed commit: the release page carries the signer instructions and + # the artifacts as stable-URL assets; watch-mainnet-release.yml promotes + # it to a final release once the upgrade executes on chain. + - name: Publish proposal pre-release + if: steps.guard.outputs.should_propose == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + spec="${{ needs.build.outputs.spec_version }}" + tag="v${spec}" + url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${tag}" + + # A published (non-prerelease) v means this spec already + # shipped; never touch it. A pre-release for the same spec is a + # respun proposal (e.g. the first train failed mid-way): replace it. + if existing=$(gh release view "$tag" --json isPrerelease --jq '.isPrerelease' 2>/dev/null); then + if [ "$existing" != "true" ]; then + echo "release $tag already published as a final release; refusing to overwrite" + exit 1 + fi + echo "replacing existing proposal pre-release $tag" + gh release delete "$tag" --cleanup-tag --yes + fi + + commit=$(jq -r '.commit' wasm/pending-release.json) + call_hash=$(jq -r '.proposal.callHash' wasm/pending-release.json) + height=$(jq -r '.proposal.blockHeight' wasm/pending-release.json) + index=$(jq -r '.proposal.extrinsicIndex' wasm/pending-release.json) + ci_address=$(jq -r '.proposal.ciAddress' wasm/pending-release.json) + deploy_ms=$(jq -r '.proposal.multisigAddress' wasm/pending-release.json) + sha256=$(jq -r '.sha256' wasm/subtensor-digest.json) + + cat > release-body.md < + \`\`\` + + btcli verifies the call data against the chain (structure, sudo key, + on-chain call hash) before submitting, and picks the right approval + (first / interior / final) from chain state automatically. + + ### Verify it independently (recommended) + + Build the runtime from this tag with srtool and compare byte-for-byte + (see \`.github/scripts/deploy/README.md\` for the full recipe): + + \`\`\` + git fetch origin && git checkout ${tag} + # srtool build, then: + btcli upgrade check --url ${url} --wasm + \`\`\` + + Without \`--wasm\`, \`btcli upgrade check --url ${url}\` still verifies + the published artifacts against each other and the chain, but trusts + the released wasm; a local srtool build closes that gap. + + ### Proposal details + + | | | + | --- | --- | + | spec_version | \`${spec}\` | + | commit | \`${commit}\` | + | call hash | \`${call_hash}\` | + | deployment timepoint | \`${height}:${index}\` | + | CI key | \`${ci_address}\` | + | deployment multisig | \`${deploy_ms}\` | + | wasm sha256 | \`${sha256}\` | + EOF + + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$commit" \ + --prerelease \ + --title "Runtime ${spec} (proposed)" \ + --notes-file release-body.md \ + wasm/subtensor.wasm \ + wasm/subtensor-digest.json \ + wasm/proxy_proxy_blob.hex \ + wasm/pending-release.json \ + wasm/upgrade-manifest.json + + { + echo "## Share this with the other signers" + echo "" + echo "**${url}**" + echo "" + echo '```' + echo "btcli upgrade sign --url ${url} -w " + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/runtime-checks.yml b/.github/workflows/runtime-checks.yml index 33038f1d35..a5e9f100e6 100644 --- a/.github/workflows/runtime-checks.yml +++ b/.github/workflows/runtime-checks.yml @@ -16,19 +16,10 @@ on: description: "Bypass cached try-runtime snapshots and scrape all networks live" type: boolean default: false - snapshot_source_branch: - description: "Trusted snapshot artifact branch (manual measurement only)" - type: string - default: main try_runtime_only: description: "Run only the try-runtime build and cached network checks" type: boolean default: false - skip_devnet: - description: "Skip devnet when measuring a partially completed producer run" - type: boolean - default: false - concurrency: group: runtime-checks-${{ github.ref }} cancel-in-progress: true @@ -58,6 +49,7 @@ jobs: name: detect relevant changes runs-on: ubuntu-latest permissions: + contents: read pull-requests: read outputs: runtime: ${{ steps.filter.outputs.runtime }} @@ -66,6 +58,16 @@ jobs: sdk_drift: ${{ steps.filter.outputs.sdk_drift }} snapshot_ci: ${{ steps.filter.outputs.snapshot_ci }} steps: + - name: Checkout trusted path classifier + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + sparse-checkout: .github/scripts/classify-runtime-changes.sh + sparse-checkout-cone-mode: false + path: .trusted-runtime-filter + persist-credentials: false + # Plain gh-api file listing instead of a marketplace action: the org's # Actions allowlist rejects unlisted third-party actions (startup_failure). - name: Filter changed paths @@ -74,30 +76,35 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} run: | set -euo pipefail - files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') - # 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|refresh-mainnet-snapshot)\.yml|actions/(rust-setup|sccache-setup)/|scripts/(sccache-configure|snapshot-artifact|test-snapshot-artifact)\.sh$)' - docs_pattern='^website/|^sdk/python/|^\.github/workflows/runtime-checks\.yml$' - 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)$' - snapshot_pattern='^\.github/(workflows/(runtime-checks|refresh-mainnet-snapshot)\.yml|scripts/(snapshot-artifact|test-snapshot-artifact)\.sh)$' - runtime=false; docs=false; python_sdk=false; sdk_drift=false; snapshot_ci=false - grep -qE "$runtime_pattern" <<< "$files" && runtime=true - grep -qE "$docs_pattern" <<< "$files" && docs=true - grep -qE "$python_sdk_pattern" <<< "$files" && python_sdk=true - grep -qE "$sdk_drift_pattern" <<< "$files" && sdk_drift=true - grep -qE "$snapshot_pattern" <<< "$files" && snapshot_ci=true - echo "runtime=$runtime, docs=$docs, python_sdk=$python_sdk, sdk_drift=$sdk_drift, snapshot_ci=$snapshot_ci" - { - echo "runtime=$runtime" - echo "docs=$docs" - echo "python_sdk=$python_sdk" - echo "sdk_drift=$sdk_drift" - echo "snapshot_ci=$snapshot_ci" - } >> "$GITHUB_OUTPUT" + enable_all() { + { + echo "runtime=true" + echo "docs=true" + echo "python_sdk=true" + echo "sdk_drift=true" + echo "snapshot_ci=true" + } >> "$GITHUB_OUTPUT" + } + classifier=.trusted-runtime-filter/.github/scripts/classify-runtime-changes.sh + if [[ ! -f "$classifier" ]]; then + echo "::warning::Trusted base predates the path classifier; enabling every check." + enable_all + exit 0 + fi + pages=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --slurp) + fetched_files=$(jq '[.[][]] | length' <<< "$pages") + if [[ "$fetched_files" -ne "$CHANGED_FILES" ]]; then + echo "::warning::PR file listing was incomplete ($fetched_files/$CHANGED_FILES); enabling every check." + enable_all + exit 0 + fi + # A rename can move a runtime file outside a matched directory, so + # classify both the current and previous paths. + jq -r '.[][] | .filename, (.previous_filename // empty)' <<< "$pages" \ + | bash "$classifier" "$GITHUB_OUTPUT" snapshot-artifact-tests: name: snapshot artifact contract tests @@ -107,6 +114,7 @@ jobs: steps: - uses: actions/checkout@v4 - run: .github/scripts/test-snapshot-artifact.sh + - run: .github/scripts/test-runtime-change-filter.sh # Build the try-runtime wasm independently so its consumers do not wait for # the unrelated release node build. The source check also makes the @@ -217,7 +225,8 @@ jobs: try-runtime: name: try-runtime ${{ matrix.network.name }} needs: build-try-runtime - runs-on: [self-hosted, fireactions-tryruntime] + runs-on: ubuntu-latest + timeout-minutes: 90 permissions: contents: read # Cross-run artifact download (the nightly try-runtime state snapshot). @@ -225,33 +234,21 @@ jobs: strategy: fail-fast: false matrix: - selected: [devnet, testnet, mainnet] - exclude: - - selected: ${{ github.event_name == 'workflow_dispatch' && inputs.skip_devnet && 'devnet' || '__none__' }} - include: + network: # NOTE: the hostnames are misleading — archive.dev.opentensor.ai # serves the TESTNET archive on :8443 and the MAINNET archive on # :443 (verified by genesis hash; try-runtime wants archive nodes, # which is why we don't use the entrypoint hostnames). The # "Verify endpoint" step below enforces each URI's identity. - - selected: devnet - network: - name: devnet - uri: wss://dev.chain.opentensor.ai:443 - genesis: "0x077899043eb684c5277b6814a39161f4ce072b45e782e12c81a521c63fb4f3e5" - - selected: testnet - network: - name: testnet - uri: wss://archive.dev.opentensor.ai:8443 - genesis: "0x8f9cf856bf558a14440e75569c9e58594757048d7b3a84b5d25f6bd978263105" - - selected: mainnet - network: - name: mainnet - uri: wss://archive.dev.opentensor.ai:443 - genesis: "0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03" - # Keep endpoint-heavy checks near the archive RPCs. Measurements - # from BHS showed materially higher per-request latency, which - # compounds during try-runtime's state traversal. + - name: devnet + uri: wss://dev.chain.opentensor.ai:443 + genesis: "0x077899043eb684c5277b6814a39161f4ce072b45e782e12c81a521c63fb4f3e5" + - name: testnet + uri: wss://archive.dev.opentensor.ai:8443 + genesis: "0x8f9cf856bf558a14440e75569c9e58594757048d7b3a84b5d25f6bd978263105" + - name: mainnet + uri: wss://archive.dev.opentensor.ai:443 + genesis: "0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03" steps: - uses: actions/checkout@v4 @@ -309,8 +306,9 @@ jobs: set -euo pipefail .github/scripts/snapshot-artifact.sh select \ "try-runtime-snap-v${TRY_RUNTIME_VERSION}-${{ matrix.network.name }}" \ - "${{ github.event_name == 'workflow_dispatch' && inputs.snapshot_source_branch || github.event.repository.default_branch }}" \ + "${{ github.event.repository.default_branch }}" \ "${{ github.repository_id }}" \ + .github/workflows/refresh-mainnet-snapshot.yml \ 72 "$GITHUB_OUTPUT" required - name: Mark snapshot restore start @@ -319,13 +317,14 @@ jobs: - name: Download selected try-runtime state snapshot if: steps.state-mode.outputs.fresh-state != 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - .github/scripts/snapshot-artifact.sh download \ - "${{ steps.snapshot.outputs.artifact-id }}" \ - "${{ steps.snapshot.outputs.digest }}" \ - snap + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ steps.snapshot.outputs.artifact-id }} + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.snapshot.outputs.run-id }} + path: snap + merge-multiple: true - name: Validate try-runtime state snapshot if: steps.state-mode.outputs.fresh-state != 'true' @@ -336,7 +335,7 @@ jobs: .github/scripts/snapshot-artifact.sh validate \ "$manifest" "$snapshot" \ "${{ matrix.network.name }}" "${{ matrix.network.genesis }}" \ - "$TRY_RUNTIME_VERSION" + "$TRY_RUNTIME_VERSION" "${{ steps.snapshot.outputs.producer-sha }}" echo "TRY_RUNTIME_SNAP=$snapshot" >> "$GITHUB_ENV" restore_seconds=$(($(date -u +%s) - SNAPSHOT_RESTORE_STARTED)) block=$(jq -er '.finalized_block_number' "$manifest") @@ -458,12 +457,14 @@ jobs: # label is set). Auto-skips (via the `build-node-release` need) when the PR touches # nothing runtime-relevant. # - # The regression tests are wall-clock-bound (they wait on 12-second blocks) - # and mutate chain state, so they can't share one node concurrently. - # Instead each shard boots its own clone from the snapshot and runs a - # subset, preserving the suite's original relative order within each shard. + # Interval sealing advances runtime time by one 12-second slot every 250ms, + # so the block-driven regressions can run sequentially on one runner. The + # issuance-invariant test gets a pristine clone; the already-downloaded + # checkpoint is then restored locally for the remaining tests, before + # forceSetBalance-based fixtures make the issuance mirrors diverge. This + # avoids four runners and four downloads without sharing destructive state. clone-upgrade: - name: clone-upgrade (${{ matrix.shard.name }}) + name: clone-upgrade needs: [trusted-pr, changes, build-node-release] runs-on: [self-hosted, fireactions-turbo-8] if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-clone-upgrade') }} @@ -472,35 +473,6 @@ jobs: contents: read # Cross-run artifact download (the nightly mainnet snapshot). actions: read - strategy: - fail-fast: false - matrix: - shard: - - name: balancer - tests: >- - test-balancer-operation.ts - test-balancer-edge-emission-issuance.ts - sdk: false - - name: locks - tests: >- - test-locks-conviction.ts - test-lock-dust-cleanup.ts - test-proxy-filter-security-regressions.ts - sdk: false - - name: stake-sdk - tests: >- - test-hotkey-swap-and-proxy-stake.ts - test-alpha-deprecated-stake-histogram.ts - sdk: true - # test-total-issuance-trackers legitimately waits ~6 min on chain - # state (see CLONE_REGRESSION_TIMEOUT_MS below), so the two - # emission/issuance tests get their own shard instead of - # serializing behind the stake-sdk pair. - - name: issuance - tests: >- - test-net-tao-flow-emission-allocation.ts - test-total-issuance-trackers.ts - sdk: false steps: - uses: actions/checkout@v4 @@ -528,21 +500,23 @@ jobs: set -euo pipefail .github/scripts/snapshot-artifact.sh select \ mainnet-snapshot \ - "${{ github.event_name == 'workflow_dispatch' && inputs.snapshot_source_branch || github.event.repository.default_branch }}" \ + "${{ github.event.repository.default_branch }}" \ "${{ github.repository_id }}" \ + .github/workflows/refresh-mainnet-snapshot.yml \ 168 "$GITHUB_OUTPUT" optional - name: Download selected mainnet clone snapshot id: download-clone-snapshot if: steps.clone-snapshot.outputs.found == 'true' continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - .github/scripts/snapshot-artifact.sh download \ - "${{ steps.clone-snapshot.outputs.artifact-id }}" \ - "${{ steps.clone-snapshot.outputs.digest }}" \ - /tmp/mainnet-snapshot + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ steps.clone-snapshot.outputs.artifact-id }} + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.clone-snapshot.outputs.run-id }} + path: /tmp/mainnet-snapshot + merge-multiple: true - name: Restore mainnet clone snapshot id: restore-clone-snapshot @@ -550,12 +524,13 @@ jobs: continue-on-error: true run: | set -euo pipefail - tar -xzf /tmp/mainnet-snapshot/mainnet-snapshot.tar.gz - rm -rf /tmp/mainnet-snapshot + checkpoint=/tmp/mainnet-snapshot/mainnet-snapshot.tar.gz + ./clones/scripts/local-clone-checkpoint.sh restore "$checkpoint" echo "Restored artifact ${{ steps.clone-snapshot.outputs.artifact-id }} from run ${{ steps.clone-snapshot.outputs.run-id }}." # Tells start-local-clone.sh to keep the pre-initialized database # instead of wiping it and re-running genesis init. echo "KEEP_CLONE_DATA=1" >> "$GITHUB_ENV" + echo "CLONE_CHECKPOINT=$checkpoint" >> "$GITHUB_ENV" - name: Fall back after an unusable clone snapshot if: >- @@ -564,7 +539,8 @@ jobs: steps.restore-clone-snapshot.outcome != 'success') run: | echo "::warning::selected clone snapshot was unusable; falling back to a live mainnet scrape" - rm -rf /tmp/mainnet-snapshot clones/mainnet-clone clones/mainnet-clone-chainspec.json + ./clones/scripts/local-clone-checkpoint.sh clear + rm -rf /tmp/mainnet-snapshot - name: Fall back after clone snapshot lookup failure if: steps.clone-snapshot.outcome == 'failure' @@ -591,65 +567,76 @@ jobs: done exit 1 - - name: Start local clone + - name: Prepare a reusable checkpoint after live fallback + if: steps.restore-clone-snapshot.outcome != 'success' run: | - nohup ./clones/scripts/start-local-clone.sh > clone-node.log 2>&1 & - # Genesis init from the ~2 GB mainnet-clone chainspec takes several - # minutes before the RPC server comes up, so allow up to 15 minutes. - # (With a restored snapshot the db is pre-initialized and this is - # nearly instant.) - for i in $(seq 1 450); do - if curl -sf -H "Content-Type: application/json" \ - -d '{"id":1,"jsonrpc":"2.0","method":"system_health","params":[]}' \ - http://127.0.0.1:9944 > /dev/null; then - echo "Clone node is up." - exit 0 - fi - sleep 2 - done - echo "Clone node failed to start:" - cat clone-node.log - exit 1 + set -euo pipefail + ./clones/scripts/start-local-clone-and-wait.sh manual + checkpoint=/tmp/mainnet-clone-pristine.tar + ./clones/scripts/local-clone-checkpoint.sh create "$checkpoint" + echo "KEEP_CLONE_DATA=1" >> "$GITHUB_ENV" + echo "CLONE_CHECKPOINT=$checkpoint" >> "$GITHUB_ENV" + + - name: Install clone test dependencies + working-directory: clones/js-tests + run: npm ci + + - name: Start isolated issuance clone + run: ./clones/scripts/start-local-clone-and-wait.sh accelerated - - name: Sudo-upgrade clone with proposed runtime + - name: Sudo-upgrade issuance clone with proposed runtime working-directory: clones/js-tests run: | - npm ci # Right after genesis the node occasionally rejects the first - # extrinsic with "bad signature" (observed: 1 of 3 shards failed at - # +8s while the identical tx succeeded on the others seconds later). + # extrinsic with "bad signature" (observed in the former sharded + # workflow while identical transactions succeeded seconds later). # Setting the same runtime code twice is idempotent, so retry once. npm run runtime:update:alice || { sleep 15; npm run runtime:update:alice; } + - name: Run isolated issuance regression + working-directory: clones/js-tests + env: + CLONE_REGRESSION_PHASE: pristine + CLONE_REGRESSION_TIMEOUT_MS: 1800000 + run: npm run test:clone-regressions + + - name: Stop isolated issuance clone + run: ./clones/scripts/stop-local-clone.sh + + - name: Restore pristine clone for remaining regressions + run: ./clones/scripts/local-clone-checkpoint.sh restore "$CLONE_CHECKPOINT" + + - name: Start regression clone + run: ./clones/scripts/start-local-clone-and-wait.sh accelerated + + - name: Sudo-upgrade regression clone with proposed runtime + working-directory: clones/js-tests + run: npm run runtime:update:alice || { sleep 15; npm run runtime:update:alice; } + - name: Run clone smoke tests working-directory: clones/js-tests run: npm test - - name: Run clone regression tests (shard subset) + - name: Run remaining clone regression tests working-directory: clones/js-tests env: - CLONE_REGRESSION_TESTS: ${{ matrix.shard.tests }} - # test-total-issuance-trackers legitimately waits minutes for the - # queued replacement registration: register_network at the subnet - # limit prunes a subnet and only emits NetworkAdded from on_idle - # after the pruned subnet's chunked storage cleanup finishes - # (~6 min on mainnet-scale state). - CLONE_REGRESSION_TIMEOUT_MS: ${{ matrix.shard.name == 'issuance' && '1800000' || '900000' }} + CLONE_REGRESSION_PHASE: remaining + CLONE_REGRESSION_TIMEOUT_MS: 1800000 run: npm run test:clone-regressions - name: Install uv - if: ${{ matrix.shard.sdk }} + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk_drift == 'true' }} run: | curl -LsSf https://astral.sh/uv/0.11.28/install.sh | sh echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Sync SDK environment - if: ${{ matrix.shard.sdk }} + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk_drift == 'true' }} working-directory: sdk/python run: uv sync --locked --all-extras --dev - name: Metadata drift gate (committed _generated vs upgraded clone) - if: ${{ matrix.shard.sdk && (github.event_name != 'pull_request' || needs.changes.outputs.sdk_drift == 'true') }} + if: ${{ 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 }} @@ -669,34 +656,33 @@ jobs: run: ./clones/scripts/stop-local-clone.sh # Branch protection requires a check named exactly "Sudo-upgrade mainnet - # clone and test"; the shard matrix above produces differently-named jobs, - # so this fan-in preserves the required-check name. Passes when every shard - # succeeded, or when the whole suite legitimately skipped (docs-only PR or - # the skip-clone-upgrade label). Anything else — including a build failure - # cascading into skipped shards — fails. + # clone and test"; this fan-in preserves the required-check name. It passes + # when the clone job succeeded, or when the whole suite legitimately skipped + # (docs-only PR or the skip-clone-upgrade label). Anything else — including + # a build failure cascading into a skipped clone job — fails. clone-upgrade-gate: name: Sudo-upgrade mainnet clone and test needs: [changes, build-node-release, clone-upgrade] if: always() runs-on: ubuntu-latest steps: - - name: Evaluate shard results + - name: Evaluate clone result env: CHANGES: ${{ needs.changes.result }} - SHARDS: ${{ needs.clone-upgrade.result }} + CLONE: ${{ needs.clone-upgrade.result }} BUILD: ${{ needs.build-node-release.result }} LABEL_SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-clone-upgrade') }} run: | - echo "changes=$CHANGES shards=$SHARDS build=$BUILD label-skip=$LABEL_SKIP" + echo "changes=$CHANGES clone=$CLONE build=$BUILD label-skip=$LABEL_SKIP" # A failed path-filter job must not read as "nothing runtime-related - # changed" — that would let build+shards skip and the gate pass. + # changed" — that would let build+clone skip and the gate pass. if [ "$CHANGES" != "success" ]; then exit 1 fi - if [ "$SHARDS" = "success" ]; then + if [ "$CLONE" = "success" ]; then exit 0 fi - if [ "$SHARDS" = "skipped" ] && { [ "$LABEL_SKIP" = "true" ] || [ "$BUILD" = "skipped" ]; }; then + if [ "$CLONE" = "skipped" ] && { [ "$LABEL_SKIP" = "true" ] || [ "$BUILD" = "skipped" ]; }; then exit 0 fi exit 1 diff --git a/.github/workflows/sccache-warm.yml b/.github/workflows/sccache-warm.yml index a30f0cc3e7..9ee10b4d21 100644 --- a/.github/workflows/sccache-warm.yml +++ b/.github/workflows/sccache-warm.yml @@ -2,7 +2,7 @@ name: Warm R2 sccache # Normal same-repository PR and main CI writes through while it compiles. This # workflow is maintenance only: refresh deployed-state branches when they move, -# repair expired/missing main entries at Amsterdam noon, or recover manually. +# repair expired/missing main entries at 12:00 UTC, or recover manually. on: push: @@ -11,7 +11,6 @@ on: - testnet schedule: - cron: "0 12 * * *" - timezone: "Europe/Amsterdam" workflow_dispatch: inputs: source_ref: diff --git a/.github/workflows/scheduled-smoke-tests.yml b/.github/workflows/scheduled-smoke-tests.yml index 7f436bb8d2..4d4ff45393 100644 --- a/.github/workflows/scheduled-smoke-tests.yml +++ b/.github/workflows/scheduled-smoke-tests.yml @@ -15,7 +15,10 @@ permissions: jobs: smoke: name: smoke-e2e-${{ matrix.test }} - runs-on: [self-hosted, fireactions-tryruntime] + # Periodic monitoring must not compete with state-heavy PR checks for the + # small try-runtime runner pool. This public repository can use standard + # GitHub-hosted Linux runners for these read-only public-RPC suites. + runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index b46dbcb541..45d76274c2 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -45,7 +45,7 @@ jobs: files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') # 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$' + 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|actions/(run-typescript-e2e|rust-setup|sccache-setup)/|scripts/sccache-configure\.sh$)' if grep -qE "$pattern" <<< "$files"; then echo "e2e=true" >> "$GITHUB_OUTPUT" else @@ -69,12 +69,8 @@ jobs: with: node-version-file: ts-tests/.nvmrc - - name: Install system dependencies - run: | - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ - -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ - build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config + - name: Validate E2E configuration + run: node ts-tests/scripts/validate-e2e-config.mjs - name: Install e2e dependencies working-directory: ts-tests @@ -91,13 +87,13 @@ jobs: environment: name: sccache-writer deployment: false - needs: [trusted-pr, typescript-formatting, changes] + needs: [trusted-pr, changes] if: needs.changes.outputs.e2e == 'true' timeout-minutes: 60 strategy: matrix: include: - - variant: release + - variant: release flags: "" - variant: fast flags: "--features fast-runtime" @@ -107,29 +103,14 @@ jobs: - name: Check-out repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Install system dependencies - run: | - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ - -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ - build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config - - - name: Install Rust - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - - - name: Shared R2 compiler cache - uses: ./.github/actions/sccache-setup - with: - credential-mode: auto - writer-access-key-id: ${{ secrets.SCCACHE_R2_WRITE_ACCESS_KEY_ID }} - writer-secret-access-key: ${{ secrets.SCCACHE_R2_WRITE_SECRET_ACCESS_KEY }} - - - name: Cache Cargo registry and git data - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: Set up Rust build environment + uses: ./.github/actions/rust-setup with: - key: e2e-${{ matrix.variant }} - cache-on-failure: true - cache-targets: false + cache-key: e2e-${{ matrix.variant }} + fast-linker: "false" + sccache-credential-mode: auto + sccache-writer-access-key-id: ${{ secrets.SCCACHE_R2_WRITE_ACCESS_KEY_ID }} + sccache-writer-secret-access-key: ${{ secrets.SCCACHE_R2_WRITE_SECRET_ACCESS_KEY }} - name: Build node-subtensor (${{ matrix.variant }}) run: cargo build --profile release ${{ matrix.flags }} -p node-subtensor @@ -141,6 +122,10 @@ jobs: path: target/release/node-subtensor if-no-files-found: error + # State-focused suites do not exercise multi-validator consensus. They use a + # single immediately-finalized node; Shield retains the production-like + # multi-node topology below. Two workers here plus three Shield workers peak + # at five self-hosted runners while the E2E phase is active. run-e2e-tests: needs: [trusted-pr, build] runs-on: [self-hosted, fireactions-turbo-8] @@ -148,20 +133,19 @@ jobs: strategy: fail-fast: false + max-parallel: 2 matrix: include: - - test: dev - binary: release - - test: zombienet_shield - binary: release + - test: zombienet_evm + binary: fast - test: zombienet_staking binary: fast - test: zombienet_coldkey_swap binary: fast + - test: dev + binary: release - test: zombienet_subnets binary: fast - - test: zombienet_evm - binary: fast name: "typescript-e2e-${{ matrix.test }}" @@ -169,36 +153,47 @@ jobs: - name: Check-out repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Download binary - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - name: Run E2E suite + uses: ./.github/actions/run-typescript-e2e with: - name: node-subtensor-${{ matrix.binary }} - path: target/release - - - name: Make binary executable - run: chmod +x target/release/node-subtensor + binary: ${{ matrix.binary }} + test: ${{ matrix.test }} - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version-file: ts-tests/.nvmrc + # Shield validates consensus, key rotation, timing, and transaction + # mortality, so preserve its six-node release topology and parallelize only + # by running measured-time-balanced file groups on independent networks. + run-shield-tests: + needs: [trusted-pr, build] + runs-on: [self-hosted, fireactions-turbo-8] + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + test: [zombienet_shield_a, zombienet_shield_b, zombienet_shield_c] + name: "typescript-e2e-${{ matrix.test }}" + steps: + - name: Check-out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Setup pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - name: Run Shield shard + uses: ./.github/actions/run-typescript-e2e with: - version: 10 + binary: release + test: ${{ matrix.test }} - - name: Install e2e dependencies - working-directory: ts-tests - run: pnpm install --frozen-lockfile - - - name: Install lsof (dev foundation RPC port discovery) - if: matrix.test == 'dev' - run: | - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends lsof - - - name: Run tests + shield-result: + name: typescript-e2e-zombienet_shield + if: always() + needs: [build, run-shield-tests] + runs-on: ubuntu-latest + steps: + - name: Check Shield shard results + env: + BUILD_RESULT: ${{ needs.build.result }} + SHIELD_RESULT: ${{ needs.run-shield-tests.result }} run: | - cd ts-tests - pnpm moonwall test ${{ matrix.test }} + if [[ "$BUILD_RESULT" == "failure" || "$BUILD_RESULT" == "cancelled" || \ + "$SHIELD_RESULT" == "failure" || "$SHIELD_RESULT" == "cancelled" ]]; then + echo "Shield E2E prerequisites failed: build=$BUILD_RESULT shards=$SHIELD_RESULT" >&2 + exit 1 + fi diff --git a/.github/workflows/watch-mainnet-release.yml b/.github/workflows/watch-mainnet-release.yml index 28d2cce2e8..22f8bac488 100644 --- a/.github/workflows/watch-mainnet-release.yml +++ b/.github/workflows/watch-mainnet-release.yml @@ -72,9 +72,12 @@ jobs: # Match both this workflow's tags (v424) and the legacy scheme # inherited from upstream (v3.4.9-424) so an already-released - # runtime is never released twice. + # runtime is never released twice. Pre-releases don't count: the + # release train publishes the proposal as a pre-release v, + # which this workflow promotes to the final release below. if gh release list --repo "$GITHUB_REPOSITORY" --limit 300 \ - --json tagName --jq '.[].tagName' \ + --json tagName,isPrerelease \ + --jq '.[] | select(.isPrerelease | not) | .tagName' \ | grep -Eq "^v${local_spec}$|-${local_spec}$"; then echo "A release for spec_version $local_spec already exists; nothing to do." echo "ready=false" >> $GITHUB_OUTPUT @@ -136,21 +139,51 @@ jobs: unzip -o artifact.zip -d wasm ls -la wasm - - name: Create release with runtime assets + - name: Create or promote release with runtime assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SPEC_VERSION: ${{ needs.check.outputs.spec_version }} RELEASE_SHA: ${{ needs.check.outputs.sha }} run: | - gh release create "v${SPEC_VERSION}" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$RELEASE_SHA" \ - --title "Runtime ${SPEC_VERSION}" \ - --generate-notes \ - wasm/subtensor.wasm \ - wasm/subtensor-digest.json \ - wasm/proxy_proxy_blob.hex \ + tag="v${SPEC_VERSION}" + assets=( + wasm/subtensor.wasm + wasm/subtensor-digest.json + wasm/proxy_proxy_blob.hex wasm/pending-release.json + ) + [ -f wasm/upgrade-manifest.json ] && assets+=(wasm/upgrade-manifest.json) + + # The release train published the proposal as a pre-release at this + # tag; the upgrade has now executed on chain, so promote it to the + # final release. Assets are re-uploaded from the provenance-gated + # workflow artifact so the release always carries the trusted bytes. + # Fall back to creating the release for trains that predate the + # proposal pre-release flow. + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + tag_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.sha' || true) + if [ -n "$tag_sha" ] && [ "$tag_sha" != "$RELEASE_SHA" ]; then + echo "tag $tag points at $tag_sha but the released commit is $RELEASE_SHA;" + echo "the proposal pre-release does not match the executed upgrade — resolve manually." + exit 1 + fi + notes=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$tag" -f target_commitish="$RELEASE_SHA" --jq '.body' || true) + gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber "${assets[@]}" + gh release edit "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Runtime ${SPEC_VERSION}" \ + ${notes:+--notes "$notes"} \ + --prerelease=false \ + --latest + else + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$RELEASE_SHA" \ + --title "Runtime ${SPEC_VERSION}" \ + --generate-notes \ + "${assets[@]}" + fi # Mirror: the mainnet branch always points at the code now running on # mainnet (the release-train commit, not HEAD of main). Pushed over @@ -221,10 +254,14 @@ jobs: working-directory: sdk/python run: uv build --out-dir ../../dist + # --check-url makes a re-run after a partial upload idempotent: files + # already on the index are skipped instead of failing with a 400 + # duplicate (e.g. the SDK wheel landed but bittensor-core didn't). - name: Publish to PyPI run: | ls -la dist - uv publish --trusted-publishing always dist/* + uv publish --trusted-publishing always \ + --check-url https://pypi.org/simple/ dist/* publish-crates: name: Publish Rust crates to crates.io diff --git a/Cargo.lock b/Cargo.lock index 97661d59cc..debfc4538a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8754,6 +8754,8 @@ dependencies = [ "subtensor-custom-rpc-runtime-api", "subtensor-macros", "subtensor-runtime-common", + "tikv-jemalloc-ctl", + "tikv-jemallocator", "tokio", ] @@ -19552,6 +19554,16 @@ dependencies = [ "libc", ] +[[package]] +name = "tikv-jemallocator" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965fe0c26be5c56c94e38ba547249074803efd52adfb66de62107d95aab3eaca" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" version = "0.3.53" diff --git a/clones/js-tests/scripts/run-clone-regressions.ts b/clones/js-tests/scripts/run-clone-regressions.ts index 1328bb2a3c..f0205022e3 100644 --- a/clones/js-tests/scripts/run-clone-regressions.ts +++ b/clones/js-tests/scripts/run-clone-regressions.ts @@ -14,35 +14,63 @@ const testsDir = path.join(__dirname, "..", "tests"); // beats burning half an hour of runner time per stuck test. const TEST_TIMEOUT_MS = Number(process.env.CLONE_REGRESSION_TIMEOUT_MS ?? 15 * 60 * 1000); +const CLONE_REGRESSION_PHASES = ["pristine", "remaining"] as const; +type CloneRegressionPhase = (typeof CLONE_REGRESSION_PHASES)[number]; + const CLONE_REGRESSIONS = [ - "test-balancer-operation.ts", - "test-balancer-edge-emission-issuance.ts", - "test-locks-conviction.ts", - "test-lock-dust-cleanup.ts", - "test-proxy-filter-security-regressions.ts", - "test-hotkey-swap-and-proxy-stake.ts", - "test-net-tao-flow-emission-allocation.ts", - "test-total-issuance-trackers.ts", - "test-alpha-deprecated-stake-histogram.ts", -]; + // This test asserts the global issuance mirrors before and after each + // scenario, so it owns the pristine phase before forceSetBalance-based tests + // mutate the clone. + { name: "test-total-issuance-trackers.ts", phase: "pristine" }, + { name: "test-balancer-operation.ts", phase: "remaining" }, + { name: "test-balancer-edge-emission-issuance.ts", phase: "remaining" }, + { name: "test-locks-conviction.ts", phase: "remaining" }, + { name: "test-lock-dust-cleanup.ts", phase: "remaining" }, + { name: "test-proxy-filter-security-regressions.ts", phase: "remaining" }, + { name: "test-hotkey-swap-and-proxy-stake.ts", phase: "remaining" }, + { name: "test-net-tao-flow-emission-allocation.ts", phase: "remaining" }, + { name: "test-alpha-deprecated-stake-histogram.ts", phase: "remaining" }, +] as const satisfies ReadonlyArray<{ name: string; phase: CloneRegressionPhase }>; + +const regressionNames = new Set(CLONE_REGRESSIONS.map(({ name }) => name)); +const regressionPhases = new Set(CLONE_REGRESSION_PHASES); -// CI shards the suite across parallel clones; CLONE_REGRESSION_TESTS selects -// a subset (whitespace/comma separated). Unknown names fail loudly so a typo -// in a shard definition can't silently skip a regression test. +// CI selects a named phase, keeping suite membership canonical here. Explicit +// filename selection remains available for targeted local/debug runs. +const requestedPhase = (process.env.CLONE_REGRESSION_PHASE ?? "").trim(); const requested = (process.env.CLONE_REGRESSION_TESTS ?? "").split(/[\s,]+/).filter(Boolean); -const unknown = requested.filter((name) => !CLONE_REGRESSIONS.includes(name)); +const cliArgs = process.argv.slice(2); +const unknownArgs = cliArgs.filter((arg) => arg !== "--list"); +if (unknownArgs.length > 0) { + console.error(`Unknown argument(s): ${unknownArgs.join(", ")}`); + process.exit(1); +} +if (requestedPhase && requested.length > 0) { + console.error("Set either CLONE_REGRESSION_PHASE or CLONE_REGRESSION_TESTS, not both"); + process.exit(1); +} +if (requestedPhase && !regressionPhases.has(requestedPhase)) { + console.error(`Unknown regression phase: ${requestedPhase}`); + process.exit(1); +} +const unknown = requested.filter((name) => !regressionNames.has(name)); if (unknown.length > 0) { console.error(`Unknown regression test(s): ${unknown.join(", ")}`); process.exit(1); } const selected = requested.length > 0 - ? CLONE_REGRESSIONS.filter((name) => requested.includes(name)) - : CLONE_REGRESSIONS; + ? CLONE_REGRESSIONS.filter(({ name }) => requested.includes(name)) + : requestedPhase + ? CLONE_REGRESSIONS.filter(({ phase }) => phase === requestedPhase) + : CLONE_REGRESSIONS; -let failed = false; +if (cliArgs.includes("--list")) { + console.log(selected.map(({ name }) => name).join("\n")); + process.exit(0); +} -for (const name of selected) { +for (const { name } of selected) { const script = path.join(testsDir, name); console.log(`\n=== clone regression: ${name} (timeout ${TEST_TIMEOUT_MS}ms) ===`); const result = spawnSync(process.execPath, ["--import", "tsx", script], { @@ -51,14 +79,11 @@ for (const name of selected) { timeout: TEST_TIMEOUT_MS, }); if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") { - failed = true; console.error(`TIMEOUT: ${name} exceeded ${TEST_TIMEOUT_MS}ms`); - continue; + process.exit(1); } if (result.status !== 0) { - failed = true; console.error(`FAILED: ${name}`); + process.exit(1); } } - -process.exit(failed ? 1 : 0); diff --git a/clones/js-tests/tests/test-hotkey-swap-and-proxy-stake.ts b/clones/js-tests/tests/test-hotkey-swap-and-proxy-stake.ts index 1ce7ab8954..94282212f5 100644 --- a/clones/js-tests/tests/test-hotkey-swap-and-proxy-stake.ts +++ b/clones/js-tests/tests/test-hotkey-swap-and-proxy-stake.ts @@ -7,6 +7,7 @@ import { connectApi } from "../lib/api.js"; import { createTempLogger } from "../lib/file-log.js"; const WS_ENDPOINT = process.env.WS_ENDPOINT ?? "ws://127.0.0.1:9944"; +const BLOCK_PRODUCTION_POLL_MS = 1_000; const RUN_ID = process.env.HOTKEY_PROXY_RUN_ID ?? `run${Date.now()}p${process.pid}`; const FUND_SOURCE_URI = process.env.HOTKEY_PROXY_FUND_SOURCE_URI ?? "//Alice"; const FUND_AMOUNT = BigInt(process.env.HOTKEY_PROXY_FUND_AMOUNT ?? "5000000000000"); @@ -104,7 +105,7 @@ async function waitForBlockProduction() { const deadline = Date.now() + 180_000; while (Date.now() < deadline && observed.length < 2) { - await sleep(6_000); + await sleep(BLOCK_PRODUCTION_POLL_MS); const current = (await api.rpc.chain.getHeader()).number.toBigInt(); if (current > previous) { observed.push(current); diff --git a/clones/js-tests/tests/test-proxy-filter-security-regressions.ts b/clones/js-tests/tests/test-proxy-filter-security-regressions.ts index 2e3768d46e..1736632b13 100644 --- a/clones/js-tests/tests/test-proxy-filter-security-regressions.ts +++ b/clones/js-tests/tests/test-proxy-filter-security-regressions.ts @@ -6,6 +6,7 @@ import { connectApi } from "../lib/api.js"; import { createTempLogger } from "../lib/file-log.js"; const WS_ENDPOINT = process.env.WS_ENDPOINT ?? "ws://127.0.0.1:9944"; +const BLOCK_PRODUCTION_POLL_MS = 1_000; const RUN_ID = process.env.PROXY_FILTER_RUN_ID ?? `run${Date.now()}p${process.pid}`; const FUND_SOURCE_URI = process.env.PROXY_FILTER_FUND_SOURCE_URI ?? "//Alice"; const FUND_AMOUNT = BigInt(process.env.PROXY_FILTER_FUND_AMOUNT ?? "5000000000000"); @@ -115,7 +116,7 @@ async function waitForBlockProduction() { const deadline = Date.now() + 180_000; while (Date.now() < deadline && observed.length < 2) { - await sleep(6_000); + await sleep(BLOCK_PRODUCTION_POLL_MS); const current = (await api.rpc.chain.getHeader()).number.toBigInt(); if (current > previous) { observed.push(current); diff --git a/clones/js-tests/tests/test-total-issuance-trackers.ts b/clones/js-tests/tests/test-total-issuance-trackers.ts index 59f6f91e16..4b92eda849 100644 --- a/clones/js-tests/tests/test-total-issuance-trackers.ts +++ b/clones/js-tests/tests/test-total-issuance-trackers.ts @@ -53,7 +53,7 @@ async function main() { console.log("run id:", RUN_ID); assertMetadataAvailable(); - await repairIssuanceMirrorIfNeeded("pre-test setup"); + await assertIssuanceMatch("pre-test setup"); await fundTestAccounts(); await assertIssuanceMatch("initial"); @@ -408,30 +408,6 @@ async function ensureEvmWhitelistDisabled() { console.log("EVM whitelist check disabled"); } -async function repairIssuanceMirrorIfNeeded(label) { - for (let attempt = 1; attempt <= 3; attempt++) { - const balances = (await api.query.balances.totalIssuance()).toBigInt(); - const subtensor: bigint = (await api.query.subtensorModule.totalIssuance()).toBigInt(); - const diff = balances - subtensor; - if (diff === 0n) { - console.log(`${label}: issuance matched`, balances.toString()); - return; - } - - const target = balances + diff; - assert.ok(target > 0n, `cannot repair issuance mirror: computed target ${target}`); - await submitAndWait( - fundSource, - api.tx.sudo.sudo(api.tx.system.setStorage([ - [api.query.subtensorModule.totalIssuance.key(), storageValueHex("u64", target)], - ])), - `sudo repair Subtensor TotalIssuance mirror attempt ${attempt}` - ); - } - - await assertIssuanceMatch(`${label} repaired`); -} - async function assertIssuanceMatch(label) { const [balancesIssuance, subtensorIssuance] = await Promise.all([ api.query.balances.totalIssuance(), diff --git a/clones/scripts/local-clone-checkpoint.sh b/clones/scripts/local-clone-checkpoint.sh new file mode 100755 index 0000000000..6a09322665 --- /dev/null +++ b/clones/scripts/local-clone-checkpoint.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +CLONE_DIR="clones/mainnet-clone" +CHAIN_SPEC="clones/mainnet-clone-chainspec.json" + +usage() { + echo "usage: local-clone-checkpoint.sh create ARCHIVE | restore ARCHIVE | clear" >&2 + exit 2 +} + +stop_clone() { + "$SCRIPT_DIR/stop-local-clone.sh" +} + +clear_clone() { + rm -rf "$CLONE_DIR" "$CHAIN_SPEC" +} + +cd "$REPO_ROOT" +command="${1:-}" +shift || true + +case "$command" in + create) + [[ $# -eq 1 ]] || usage + archive="$1" + [[ -d "$CLONE_DIR" ]] || { echo "missing clone directory: $CLONE_DIR" >&2; exit 1; } + [[ -f "$CHAIN_SPEC" ]] || { echo "missing clone chainspec: $CHAIN_SPEC" >&2; exit 1; } + stop_clone + tar -cf "$archive" "$CHAIN_SPEC" "$CLONE_DIR" + echo "Created clone checkpoint $archive." + ;; + restore) + [[ $# -eq 1 ]] || usage + archive="$1" + [[ -f "$archive" ]] || { echo "missing clone checkpoint: $archive" >&2; exit 1; } + stop_clone + clear_clone + # GNU tar detects gzip automatically while reading, so callers do not need + # to carry the producer's archive format through the workflow. + tar -xf "$archive" + [[ -d "$CLONE_DIR" ]] || { echo "checkpoint did not contain $CLONE_DIR" >&2; exit 1; } + [[ -f "$CHAIN_SPEC" ]] || { echo "checkpoint did not contain $CHAIN_SPEC" >&2; exit 1; } + echo "Restored clone checkpoint $archive." + ;; + clear) + [[ $# -eq 0 ]] || usage + stop_clone + clear_clone + ;; + *) usage ;; +esac diff --git a/clones/scripts/start-local-clone-and-wait.sh b/clones/scripts/start-local-clone-and-wait.sh new file mode 100755 index 0000000000..c1e03108cd --- /dev/null +++ b/clones/scripts/start-local-clone-and-wait.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" + +log_file="clone-node.log" +ready_attempts=450 +advance_timeout_seconds=60 + +usage() { + cat >&2 <<'EOF' +Usage: start-local-clone-and-wait.sh MODE + +Modes: + accelerated Seal every 250ms and require 20 blocks of advancement + manual Use manual sealing and require RPC health only + node-default Preserve the node's default sealing and require RPC health only +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +mode="$1" + +cd "$REPO_ROOT" +case "$mode" in + accelerated) + nohup ./clones/scripts/start-local-clone.sh --sealing 250 > "$log_file" 2>&1 & + ;; + manual) + nohup ./clones/scripts/start-local-clone.sh --sealing manual > "$log_file" 2>&1 & + ;; + node-default) + nohup ./clones/scripts/start-local-clone.sh > "$log_file" 2>&1 & + ;; + *) usage ;; +esac +node_pid=$! + +cleanup_on_failure() { + local status=$? + (( status != 0 )) || return + echo "Clone node startup failed; last log lines:" + tail -n 200 "$log_file" 2>/dev/null || true + "$SCRIPT_DIR/stop-local-clone.sh" || true +} +trap cleanup_on_failure EXIT + +rpc() { + local method="$1" + curl -fsS -H "Content-Type: application/json" \ + -d "{\"id\":1,\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":[]}" \ + http://127.0.0.1:9944 +} + +ready=false +for ((attempt = 1; attempt <= ready_attempts; attempt++)); do + if rpc system_health > /dev/null; then + ready=true + break + fi + kill -0 "$node_pid" 2>/dev/null || break + sleep 2 +done +[[ "$ready" == true ]] || exit 1 + +if [[ "$mode" != accelerated ]]; then + echo "Clone node is healthy." + trap - EXIT + exit 0 +fi + +height() { + local hex + hex=$(rpc chain_getHeader | jq -er '.result.number') + echo $((16#${hex#0x})) +} + +first=$(height) +deadline=$(($(date +%s) + advance_timeout_seconds)) +while (( $(date +%s) < deadline )); do + current=$(height) + if (( current >= first + 20 )); then + echo "Clone advanced from block $first to $current." + trap - EXIT + exit 0 + fi + sleep 1 +done + +exit 1 diff --git a/clones/scripts/stop-local-clone.sh b/clones/scripts/stop-local-clone.sh index 1b8f47937f..8abac74a91 100755 --- a/clones/scripts/stop-local-clone.sh +++ b/clones/scripts/stop-local-clone.sh @@ -1,6 +1,38 @@ #!/usr/bin/env bash set -euo pipefail -pkill -f "node-subtensor.*--base-path clones/mainnet-clone" \ - || pkill -f "node-subtensor.*--base-path ../clones/mainnet-clone" \ - || true +timeout_seconds="${CLONE_STOP_TIMEOUT_SECONDS:-60}" +[[ "$timeout_seconds" =~ ^[0-9]+$ ]] || { + echo "invalid CLONE_STOP_TIMEOUT_SECONDS: $timeout_seconds" >&2 + exit 2 +} + +patterns=( + "node-subtensor.*--base-path clones/mainnet-clone" + "node-subtensor.*--base-path \.\./clones/mainnet-clone" +) + +clone_running() { + local pattern + for pattern in "${patterns[@]}"; do + pgrep -f "$pattern" > /dev/null && return 0 + done + return 1 +} + +for pattern in "${patterns[@]}"; do + pkill -f "$pattern" || true +done + +deadline=$(($(date +%s) + timeout_seconds)) +while clone_running && (( $(date +%s) < deadline )); do + sleep 1 +done + +if clone_running; then + echo "Clone node did not stop within ${timeout_seconds}s." >&2 + for pattern in "${patterns[@]}"; do + pgrep -af "$pattern" >&2 || true + done + exit 1 +fi diff --git a/docs/concepts/network.mdx b/docs/concepts/network.mdx index 0134d04b76..191b346add 100644 --- a/docs/concepts/network.mdx +++ b/docs/concepts/network.mdx @@ -20,6 +20,7 @@ The SDK's network names resolve to public endpoints: - `finney` — mainnet, `wss://entrypoint-finney.opentensor.ai:443` - `test` — public testnet, `wss://test.finney.opentensor.ai:443` +- `devnet` — the development chain, `wss://dev.chain.opentensor.ai:443` - `local` — a dev node at `ws://127.0.0.1:9944` Mainnet also has a public **lite** node at diff --git a/docs/meta.json b/docs/meta.json index 0839c055d9..15e19926b0 100644 --- a/docs/meta.json +++ b/docs/meta.json @@ -8,6 +8,7 @@ "concepts", "guides", "migration", + "[Releases](/releases)", "---Reference---", "tx", "query", diff --git a/docs/tx/add-stake.mdx b/docs/tx/add-stake.mdx index c8eb47c2e3..a35d539c74 100644 --- a/docs/tx/add-stake.mdx +++ b/docs/tx/add-stake.mdx @@ -8,17 +8,21 @@ description: "Stake TAO from the coldkey onto a hotkey." Swaps TAO from the coldkey's free balance into the subnet's alpha at the current pool price and credits the result to your stake on the hotkey; on netuid 0 (root) the stake stays TAO-denominated. The swap moves the pool, -so large amounts incur slippage — use `add_stake_limit` to bound the -price. The position's value then follows the pool price and the validator's -performance, and can be exited later with `remove_stake`. Fails if the -coldkey's free balance cannot cover the amount plus the transaction fee, -and with `AmountTooLow` when the amount is below the chain minimum of -0.002 TAO plus the swap fee. Dynamic subnets also reject a single swap -larger than 1000x the pool's TAO reserve (`InsufficientLiquidity`). +so large amounts incur slippage. By default the call is slippage-protected: +it fails (`SlippageTooHigh`) instead of filling once the price rises more +than `rate_tolerance` (5%) above the price at submission — raise the +tolerance or set `slippage_protection` to False to execute at any price, +or use `add_stake_limit` to set an explicit limit price. The position's +value then follows the pool price and the validator's performance, and can +be exited later with `remove_stake`. Fails if the coldkey's free balance +cannot cover the amount plus the transaction fee, and with `AmountTooLow` +when the amount is below the chain minimum of 0.002 TAO plus the swap fee. +Dynamic subnets also reject a single swap larger than 1000x the pool's TAO +reserve (`InsufficientLiquidity`). | Signer | Pallet | Wraps | | --- | --- | --- | -| `coldkey` | SubtensorModule | `SubtensorModule.add_stake` | +| `coldkey` | SubtensorModule | `SubtensorModule.add_stake`, `SubtensorModule.add_stake_limit` | ## Parameters @@ -27,6 +31,8 @@ larger than 1000x the pool's TAO reserve (`InsufficientLiquidity`). | `hotkey_ss58` | string | yes | Hotkey the stake is added to (the validator you are backing). | | `netuid` | integer | yes | Subnet the stake lives on (netuid 0 is the root network). | | `amount_tao` | number \| `"all"` | yes | How much of the coldkey's free balance to stake. | +| `slippage_protection` | boolean | no | Bound the price the swap may execute at (on by default): the call fails (`SlippageTooHigh`) instead of filling once the pool price moves more than `rate_tolerance` from the price at submission. Disable to execute at any price. | +| `rate_tolerance` | number | no | Maximum price move slippage protection accepts, as a fraction (0.05 = 5%). Ignored when slippage protection is disabled. | Address parameters (`--hotkey`, `--coldkey`, `--dest`, ...) accept a raw ss58 address, an address-book or proxy-book name, or a local wallet/hotkey name. diff --git a/docs/tx/remove-stake.mdx b/docs/tx/remove-stake.mdx index 793db06552..e7f1812804 100644 --- a/docs/tx/remove-stake.mdx +++ b/docs/tx/remove-stake.mdx @@ -9,7 +9,11 @@ Swaps the alpha position back to TAO at the current pool price and credits it to the signing coldkey's free balance. Pass `all` to exit the entire position on that hotkey and subnet (the build fails if nothing is staked there). Like staking, the swap moves the pool, so large amounts incur -slippage — use `remove_stake_limit` to bound the price. The hotkey and +slippage. By default the call is slippage-protected: it fails +(`SlippageTooHigh`) instead of filling once the price falls more than +`rate_tolerance` (5%) below the price at submission — raise the tolerance +or set `slippage_protection` to False to execute at any price, or use +`remove_stake_limit` to set an explicit limit price. The hotkey and netuid must match where the stake is actually held, and the subnet must have subtoken trading enabled. The requested amount is capped to the stake currently available. A partial unstake must leave a remainder @@ -18,7 +22,7 @@ position instead of leaving dust (`AmountTooLow`). | Signer | Pallet | Wraps | | --- | --- | --- | -| `coldkey` | SubtensorModule | `SubtensorModule.remove_stake` | +| `coldkey` | SubtensorModule | `SubtensorModule.remove_stake`, `SubtensorModule.remove_stake_limit` | ## Parameters @@ -27,6 +31,8 @@ position instead of leaving dust (`AmountTooLow`). | `hotkey_ss58` | string | yes | Hotkey the stake is held on (the validator backing the position). | | `netuid` | integer | yes | Subnet the stake lives on (netuid 0 is the root network). | | `amount_alpha` | number \| `"all"` | yes | How much to unstake from this position, or ``all``. | +| `slippage_protection` | boolean | no | Bound the price the swap may execute at (on by default): the call fails (`SlippageTooHigh`) instead of filling once the pool price moves more than `rate_tolerance` from the price at submission. Disable to execute at any price. | +| `rate_tolerance` | number | no | Maximum price move slippage protection accepts, as a fraction (0.05 = 5%). Ignored when slippage protection is disabled. | Address parameters (`--hotkey`, `--coldkey`, `--dest`, ...) accept a raw ss58 address, an address-book or proxy-book name, or a local wallet/hotkey name. diff --git a/docs/tx/swap-stake.mdx b/docs/tx/swap-stake.mdx index fe4f27deb5..2f821a2e79 100644 --- a/docs/tx/swap-stake.mdx +++ b/docs/tx/swap-stake.mdx @@ -8,13 +8,17 @@ description: "Swap stake on one hotkey between two subnets." Moves part of a position from the origin subnet to the destination subnet while staying on the same hotkey: the alpha is swapped to TAO in the origin pool and then to alpha in the destination pool, so both legs can -incur slippage. The two netuids must differ (`SameNetuid`). Use -`move_stake` when the hotkey should change too, and `remove_stake` -plus `add_stake` only if you want to control each leg separately. +incur slippage. By default the call is slippage-protected: it fails +(`SlippageTooHigh`) instead of filling once the origin/destination price +ratio falls more than `rate_tolerance` (5%) below the ratio at submission +— raise the tolerance or set `slippage_protection` to False to execute at +any price. The two netuids must differ (`SameNetuid`). Use `move_stake` +when the hotkey should change too, and `remove_stake` plus `add_stake` +only if you want to control each leg separately. | Signer | Pallet | Wraps | | --- | --- | --- | -| `coldkey` | SubtensorModule | `SubtensorModule.swap_stake` | +| `coldkey` | SubtensorModule | `SubtensorModule.swap_stake`, `SubtensorModule.swap_stake_limit` | ## Parameters @@ -24,6 +28,8 @@ plus `add_stake` only if you want to control each leg separately. | `origin_netuid` | integer | yes | Subnet the stake currently sits on. | | `dest_netuid` | integer | yes | Subnet the stake ends up on. When it differs from the origin, the position is swapped through both subnet pools, which can incur slippage on each leg. | | `amount_alpha` | number \| `"all"` | yes | How much of the origin position to swap across (an explicit amount; ``all`` is not accepted). | +| `slippage_protection` | boolean | no | Bound the price the swap may execute at (on by default): the call fails (`SlippageTooHigh`) instead of filling once the pool price moves more than `rate_tolerance` from the price at submission. Disable to execute at any price. | +| `rate_tolerance` | number | no | Maximum price move slippage protection accepts, as a fraction (0.05 = 5%). Ignored when slippage protection is disabled. | Address parameters (`--hotkey`, `--coldkey`, `--dest`, ...) accept a raw ss58 address, an address-book or proxy-book name, or a local wallet/hotkey name. diff --git a/node/Cargo.toml b/node/Cargo.toml index 333fc4a093..da20b81641 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -132,6 +132,19 @@ pallet-subtensor-swap-rpc = { workspace = true, features = ["std"] } pallet-subtensor-swap-runtime-api = { workspace = true, features = ["std"] } subtensor-macros.workspace = true +# Optional global allocator (off by default). Archive nodes serving sustained RPC load show +# unbounded anonymous-memory growth (~3-4 GB/h) that fits the glibc-malloc arena-fragmentation +# profile across the node's hundreds of threads; linking jemalloc as the global allocator (as the +# polkadot binary does) is the proposed mitigation (issue #2724). Gated behind a feature so the +# default release binary is unchanged until an operator opts in. +tikv-jemallocator = { version = "0.5", optional = true, default-features = false } +# Companion to `tikv-jemallocator`, used by the `jemalloc-allocator` feature's +# activation test to read allocator stats over `mallctl`. It already resolves +# transitively and shares the single compiled `tikv-jemalloc-sys` with +# `tikv-jemallocator` (see Cargo.lock), so enabling it adds no second copy of +# jemalloc - only this small ctl FFI shim. +tikv-jemalloc-ctl = { version = "0.5", optional = true } + [build-dependencies] substrate-build-script-utils.workspace = true @@ -145,6 +158,9 @@ rocksdb = [ "sc-cli/rocksdb", ] default = ["rocksdb", "sql", "txpool"] +# Use tikv-jemallocator as the global allocator in the release binary (off by default). +# See the `tikv-jemallocator` dependency note and issue #2724. +jemalloc-allocator = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"] fast-runtime = [ "node-subtensor-runtime/fast-runtime", "subtensor-runtime-common/fast-runtime", diff --git a/node/src/main.rs b/node/src/main.rs index a6aa15038f..cd23935b24 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -1,6 +1,14 @@ //! Substrate Node Subtensor CLI library. #![warn(missing_docs)] +// Use jemalloc as the global allocator when the `jemalloc-allocator` feature is enabled (off by +// default). Archive nodes serving sustained RPC load exhibit unbounded anonymous-memory growth that +// matches the glibc-malloc arena-fragmentation profile across the node's hundreds of threads; +// jemalloc (the allocator the polkadot binary ships with) avoids it. See issue #2724. +#[cfg(all(unix, feature = "jemalloc-allocator"))] +#[global_allocator] +static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + #[cfg(feature = "runtime-benchmarks")] mod benchmarking; mod chain_spec; @@ -18,3 +26,71 @@ mod service; fn main() -> sc_cli::Result<()> { command::run() } + +// Regression guard for the optional `jemalloc-allocator` feature. +// +// The feature is off by default, so the default build never compiles it, and a +// `tikv-jemallocator` bump, a `#[global_allocator]` typo, or a `cfg` mistake can +// leave the binary *compiling* while jemalloc is no longer the active global +// allocator (glibc silently stays in charge). A compile check cannot catch that: +// only bytes routed *through* jemalloc are observable, so this test drives the +// global allocator with a large live allocation and asserts jemalloc tracked it. +// If the `#[global_allocator]`/`cfg` wiring is broken the Vec goes to glibc and +// the delta stays at zero - well below the threshold - and the test fails. +// +// The delta is read from jemalloc's *thread-local* "bytes ever allocated by this +// thread" counter, not the process-global `stats.allocated`. `cargo test` runs the +// node's tests in parallel inside one process, and the global stat is perturbed by +// every other test's allocations and frees, so a before/after delta around it is +// racy: a concurrent free can drop it below threshold (false failure) and a +// concurrent allocation can lift it (false pass). The thread-local counter only +// ever counts bytes the *calling* thread routes through the global allocator, so +// it is immune to concurrent tests; it is also monotonic (a free cannot decrease +// it), so the only way it stays flat across a large live allocation is if that +// allocation went to glibc because jemalloc is linked but not active. +#[cfg(all(test, unix, feature = "jemalloc-allocator"))] +mod jemalloc_allocator_feature { + use tikv_jemalloc_ctl::thread; + + // A live allocation routed through the *global* allocator. Large enough to + // dwarf any bookkeeping the stat reads do themselves. + const PAYLOAD: usize = 16 * 1024 * 1024; + + // The workspace denies `expect_used`/`unwrap_used`, and `tikv_jemalloc_ctl`'s + // Error is a transparent wrapper that does not implement std::error::Error, + // so the Result cannot be propagated with `?` either. Resolve the stat handle + // by hand instead. + fn allocated_pointer() -> thread::ThreadLocal { + match thread::allocatedp::read() { + Ok(pointer) => pointer, + Err(error) => panic!("read thread.allocatedp: {error:?}"), + } + } + + #[test] + fn jemalloc_is_the_active_global_allocator() { + // `read()` returns a thread-local pointer to the cumulative byte count + // for *this* thread; `get()` dereferences it (safe: the raw-pointer read + // is encapsulated in `tikv_jemalloc_ctl`). Thread stats are live, so - + // unlike `stats::allocated` - no epoch refresh is needed before a read. + let pointer = allocated_pointer(); + let before = pointer.get(); + + // Held across the second read so the allocator cannot reclaim it first. + let hold = vec![0u8; PAYLOAD]; + + let after = pointer.get(); + + assert!( + after.saturating_sub(before) >= PAYLOAD as u64, + "jemalloc did not track a {}-byte global-allocator allocation \ + (before={}, after={}): it is linked but not the active global \ + allocator, so the jemalloc-allocator feature wiring is broken", + PAYLOAD, + before, + after, + ); + + drop(hold); + } +} diff --git a/node/src/service.rs b/node/src/service.rs index 624f63b968..f7d277eed2 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -24,9 +24,9 @@ use sp_runtime::key_types; use sp_runtime::traits::{Block as BlockT, NumberFor}; use stc_shield::{self, MemoryShieldKeystore}; use std::collections::HashSet; +use std::path::Path; use std::str::FromStr; -use std::sync::atomic::AtomicBool; -use std::{cell::RefCell, path::Path}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::{sync::Arc, time::Duration}; use stp_shield::ShieldKeystorePtr; use substrate_prometheus_endpoint::Registry; @@ -758,11 +758,20 @@ fn run_manual_seal_authorship( shield_keystore.clone(), ); - thread_local!(static TIMESTAMP: RefCell = const { RefCell::new(0) }); - - /// Provide a mock duration starting at 0 in millisecond for timestamp inherent. - /// Each call will increment timestamp by slot_duration making the consensus logic think time has passed. - struct MockTimestampInherentDataProvider; + let timestamp = Arc::new(AtomicU64::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64, + )); + + /// Provide a synthetic timestamp starting after the current wall-clock slot. + /// Each call advances by one slot, allowing interval sealing to run faster + /// than real time while remaining ahead of cloned consensus state. + struct MockTimestampInherentDataProvider { + timestamp: Arc, + } #[async_trait::async_trait] impl sp_inherents::InherentDataProvider for MockTimestampInherentDataProvider { @@ -770,11 +779,15 @@ fn run_manual_seal_authorship( &self, inherent_data: &mut sp_inherents::InherentData, ) -> Result<(), sp_inherents::Error> { - TIMESTAMP.with(|x| { - let mut x_ref = x.borrow_mut(); - *x_ref = x_ref.saturating_add(subtensor_runtime_common::time::SLOT_DURATION); - inherent_data.put_data(sp_timestamp::INHERENT_IDENTIFIER, &*x_ref) - }) + let slot_duration = subtensor_runtime_common::time::SLOT_DURATION; + let timestamp = self + .timestamp + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + Some(current.saturating_add(slot_duration)) + }) + .map(|previous| previous.saturating_add(slot_duration)) + .unwrap_or(u64::MAX); + inherent_data.put_data(sp_timestamp::INHERENT_IDENTIFIER, ×tamp) } async fn try_handle_error( @@ -789,9 +802,10 @@ fn run_manual_seal_authorship( let create_inherent_data_providers = move |_, ()| { let keystore = shield_keystore.clone(); + let timestamp = timestamp.clone(); async move { Ok(( - MockTimestampInherentDataProvider, + MockTimestampInherentDataProvider { timestamp }, stc_shield::InherentDataProvider::new(keystore), )) } diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index b831ba085c..3907c29e01 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -1390,7 +1390,7 @@ mod dispatches { /// * `limit_price`: The limit price expressed in units of RAO per one Alpha. /// /// * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes - /// fill or kill type or order. + /// fill or kill type of order. /// /// # Events /// * `StakeAdded`: On the successfully adding stake to a global account. @@ -1443,7 +1443,7 @@ mod dispatches { /// * `limit_price`: The limit price expressed in units of RAO per one Alpha. /// /// * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes - /// fill or kill type or order. + /// fill or kill type of order. /// /// # Events /// * `StakeRemoved`: On the successfully removing stake from the hotkey account. @@ -1484,7 +1484,7 @@ mod dispatches { /// * `destination_netuid`: The network/subnet ID to which stake is added. /// * `alpha_amount`: The amount of stake to swap. /// * `limit_price`: The limit price expressed in units of RAO per one Alpha. - /// * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type or order. + /// * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type of order. /// /// # Errors /// * `BadOrigin`: The transaction is not signed. diff --git a/pallets/subtensor/src/staking/add_stake.rs b/pallets/subtensor/src/staking/add_stake.rs index 936d162f73..1d500d6a1b 100644 --- a/pallets/subtensor/src/staking/add_stake.rs +++ b/pallets/subtensor/src/staking/add_stake.rs @@ -78,7 +78,7 @@ impl Pallet { /// * `limit_price`: The limit price expressed in units of RAO per one Alpha. /// /// * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes - /// fill or kill type or order. + /// fill or kill type of order. /// /// # Events /// * `StakeAdded`: On the successfully adding stake to a global account. diff --git a/pallets/subtensor/src/staking/remove_stake.rs b/pallets/subtensor/src/staking/remove_stake.rs index 239f124943..0b36dbf224 100644 --- a/pallets/subtensor/src/staking/remove_stake.rs +++ b/pallets/subtensor/src/staking/remove_stake.rs @@ -283,7 +283,7 @@ impl Pallet { /// * `limit_price`: The limit price expressed in units of RAO per one Alpha. /// /// * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes - /// fill or kill type or order. + /// fill or kill type of order. /// /// # Events /// * `StakeRemoved`: On the successfully removing stake from the hotkey account. diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 8e81447fbc..85eb1290c8 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -1253,6 +1253,7 @@ fn test_per_u16_encodes_identically_to_u16() { assert_eq!(PerU16::zero().encode(), 0u16.encode()); } +#[allow(deprecated)] #[test] fn test_migrate_last_tx_block_delegate_take() { new_test_ext(1).execute_with(|| { diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 017a71a855..399d60658c 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -235,7 +235,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 429, + spec_version: 430, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/sdk/bittensor-core/tests/e2e.rs b/sdk/bittensor-core/tests/e2e.rs index ec822e09a8..25e913c624 100644 --- a/sdk/bittensor-core/tests/e2e.rs +++ b/sdk/bittensor-core/tests/e2e.rs @@ -9,6 +9,8 @@ mod e2e_support; use std::collections::BTreeSet; +use std::thread; +use std::time::{Duration, Instant}; use bittensor_core::client::{as_str, as_u128, field, value_bytes, variant_name}; use bittensor_core::codec::Value; @@ -262,11 +264,26 @@ fn test_policy_aggregates_spend_across_batch() { #[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); + // NextKey is None until every localnet authority has authored a block and + // announced its key (the rotation inherent kills NextKey whenever the + // next-next author has no AuthorKeys entry yet), so poll instead of + // reading once right after startup. + let deadline = Instant::now() + Duration::from_secs(30); + let key = loop { + let key = ctx + .client + .query("MevShield", "NextKey", &[], None) + .expect("NextKey read"); + if let Some(bytes) = value_bytes(&key) { + break bytes; + } + assert!( + Instant::now() < deadline, + "NextKey not announced within 30s" + ); + thread::sleep(Duration::from_millis(250)); + }; + assert_eq!(key.len(), 1_184); } #[test] diff --git a/sdk/python/bittensor/__init__.py b/sdk/python/bittensor/__init__.py index 67bf9138b4..c7f7c81788 100644 --- a/sdk/python/bittensor/__init__.py +++ b/sdk/python/bittensor/__init__.py @@ -31,6 +31,7 @@ async def main(): logging.getLogger("bittensor").setLevel(logging.DEBUG) # just the SDK """ +import importlib.metadata as _importlib_metadata import logging as _logging from . import evm, http_auth, intents, reads, timelock, wallets @@ -194,7 +195,13 @@ async def main(): *sorted(_INTENT_EXPORTS), ] -__version__ = "11.0.0.dev0" +# The single source of truth for the version is pyproject.toml (which the +# release workflows stamp with the rc/dev suffix at build time); read it from +# the installed distribution so wheels report what they were published as. +try: + __version__ = _importlib_metadata.version("bittensor") +except _importlib_metadata.PackageNotFoundError: # uninstalled source tree + __version__ = "0.0.0.dev0+unknown" # Removed v10 API names raise with a pointer to the replacement instead of a # bare AttributeError, so an un-migrated script fails with instructions diff --git a/sdk/python/bittensor/_generated/calls.py b/sdk/python/bittensor/_generated/calls.py index 8d1ab2e64f..31084b8998 100644 --- a/sdk/python/bittensor/_generated/calls.py +++ b/sdk/python/bittensor/_generated/calls.py @@ -212,7 +212,7 @@ def add_stake_burn(hotkey: 'AccountId32', netuid: 'NetUid', amount: 'TaoBalance' @staticmethod def add_stake_limit(hotkey: 'AccountId32', netuid: 'NetUid', amount_staked: 'TaoBalance', limit_price: 'TaoBalance', allow_partial: 'bool') -> Call: - "Adds stake to a hotkey on a subnet with a price limit. This extrinsic allows to specify the limit price for alpha token at which or better (lower) the staking should execute. In case if slippage occurs and the price shall move beyond the limit price, the staking order may execute only partially or not execute at all. # Arguments * `origin`: The signature of the caller's coldkey. * `hotkey`: The associated hotkey account. * `netuid`: Subnetwork UID. * `amount_staked`: The amount of stake to be added to the hotkey staking account. * `limit_price`: The limit price expressed in units of RAO per one Alpha. * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type or order. # Events * `StakeAdded`: On the successfully adding stake to a global account. # Errors * `NotEnoughBalanceToStake`: Not enough balance on the coldkey to add onto the global account. * `NonAssociatedColdKey`: The calling coldkey is not associated with this hotkey. * `BalanceWithdrawalError`: Errors stemming from transaction pallet." + "Adds stake to a hotkey on a subnet with a price limit. This extrinsic allows to specify the limit price for alpha token at which or better (lower) the staking should execute. In case if slippage occurs and the price shall move beyond the limit price, the staking order may execute only partially or not execute at all. # Arguments * `origin`: The signature of the caller's coldkey. * `hotkey`: The associated hotkey account. * `netuid`: Subnetwork UID. * `amount_staked`: The amount of stake to be added to the hotkey staking account. * `limit_price`: The limit price expressed in units of RAO per one Alpha. * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type of order. # Events * `StakeAdded`: On the successfully adding stake to a global account. # Errors * `NotEnoughBalanceToStake`: Not enough balance on the coldkey to add onto the global account. * `NonAssociatedColdKey`: The calling coldkey is not associated with this hotkey. * `BalanceWithdrawalError`: Errors stemming from transaction pallet." return Call('SubtensorModule', 'add_stake_limit', {'hotkey': hotkey, 'netuid': netuid, 'amount_staked': amount_staked, 'limit_price': limit_price, 'allow_partial': allow_partial}) @staticmethod @@ -372,7 +372,7 @@ def remove_stake_full_limit(hotkey: 'AccountId32', netuid: 'NetUid', limit_price @staticmethod def remove_stake_limit(hotkey: 'AccountId32', netuid: 'NetUid', amount_unstaked: 'AlphaBalance', limit_price: 'TaoBalance', allow_partial: 'bool') -> Call: - "Removes stake from a hotkey on a subnet with a price limit. This extrinsic allows to specify the limit price for alpha token at which or better (higher) the staking should execute. In case if slippage occurs and the price shall move beyond the limit price, the staking order may execute only partially or not execute at all. # Arguments * `origin`: The signature of the caller's coldkey. * `hotkey`: The associated hotkey account. * `netuid`: Subnetwork UID. * `amount_unstaked`: The amount of stake to be added to the hotkey staking account. * `limit_price`: The limit price expressed in units of RAO per one Alpha. * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type or order. # Events * `StakeRemoved`: On the successfully removing stake from the hotkey account. # Errors * `NotRegistered`: Thrown if the account we are attempting to unstake from is non existent. * `NonAssociatedColdKey`: Thrown if the coldkey does not own the hotkey we are unstaking from. * `NotEnoughStakeToWithdraw`: Thrown if there is not enough stake on the hotkey to withdwraw this amount." + "Removes stake from a hotkey on a subnet with a price limit. This extrinsic allows to specify the limit price for alpha token at which or better (higher) the staking should execute. In case if slippage occurs and the price shall move beyond the limit price, the staking order may execute only partially or not execute at all. # Arguments * `origin`: The signature of the caller's coldkey. * `hotkey`: The associated hotkey account. * `netuid`: Subnetwork UID. * `amount_unstaked`: The amount of stake to be added to the hotkey staking account. * `limit_price`: The limit price expressed in units of RAO per one Alpha. * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type of order. # Events * `StakeRemoved`: On the successfully removing stake from the hotkey account. # Errors * `NotRegistered`: Thrown if the account we are attempting to unstake from is non existent. * `NonAssociatedColdKey`: Thrown if the coldkey does not own the hotkey we are unstaking from. * `NotEnoughStakeToWithdraw`: Thrown if there is not enough stake on the hotkey to withdwraw this amount." return Call('SubtensorModule', 'remove_stake_limit', {'hotkey': hotkey, 'netuid': netuid, 'amount_unstaked': amount_unstaked, 'limit_price': limit_price, 'allow_partial': allow_partial}) @staticmethod @@ -552,7 +552,7 @@ def swap_stake(hotkey: 'AccountId32', origin_netuid: 'NetUid', destination_netui @staticmethod def swap_stake_limit(hotkey: 'AccountId32', origin_netuid: 'NetUid', destination_netuid: 'NetUid', alpha_amount: 'AlphaBalance', limit_price: 'TaoBalance', allow_partial: 'bool') -> Call: - 'Swaps a specified amount of stake from one subnet to another, while keeping the same coldkey and hotkey. # Arguments * `origin`: The origin of the transaction, which must be signed by the coldkey that owns the `hotkey`. * `hotkey`: The hotkey whose stake is being swapped. * `origin_netuid`: The network/subnet ID from which stake is removed. * `destination_netuid`: The network/subnet ID to which stake is added. * `alpha_amount`: The amount of stake to swap. * `limit_price`: The limit price expressed in units of RAO per one Alpha. * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type or order. # Errors * `BadOrigin`: The transaction is not signed. * `SameNetuid`: `origin_netuid` and `destination_netuid` are the same. * `SubnetNotExists`: Either `origin_netuid` or `destination_netuid` does not exist. * `SubtokenDisabled`: The subtoken is disabled on the origin or destination subnet. * `HotKeyAccountNotExists`: The `hotkey` account does not exist. * `NotEnoughStakeToWithdraw`: The `(coldkey, hotkey, origin_netuid)` position has less stake than `alpha_amount`. * `InsufficientLiquidity`: The swap simulation on the origin subnet fails. * `AmountTooLow`: The TAO-equivalent of the swap is below the minimum stake requirement. * `SlippageTooHigh`: `allow_partial` is false and the amount would cross the limit price. * `StakeUnavailable`: The remaining stake would not cover the locked amount on the origin subnet. # Events May emit a `StakeSwapped` event on success.' + 'Swaps a specified amount of stake from one subnet to another, while keeping the same coldkey and hotkey. # Arguments * `origin`: The origin of the transaction, which must be signed by the coldkey that owns the `hotkey`. * `hotkey`: The hotkey whose stake is being swapped. * `origin_netuid`: The network/subnet ID from which stake is removed. * `destination_netuid`: The network/subnet ID to which stake is added. * `alpha_amount`: The amount of stake to swap. * `limit_price`: The limit price expressed in units of RAO per one Alpha. * `allow_partial`: Allows partial execution of the amount. If set to false, this becomes fill or kill type of order. # Errors * `BadOrigin`: The transaction is not signed. * `SameNetuid`: `origin_netuid` and `destination_netuid` are the same. * `SubnetNotExists`: Either `origin_netuid` or `destination_netuid` does not exist. * `SubtokenDisabled`: The subtoken is disabled on the origin or destination subnet. * `HotKeyAccountNotExists`: The `hotkey` account does not exist. * `NotEnoughStakeToWithdraw`: The `(coldkey, hotkey, origin_netuid)` position has less stake than `alpha_amount`. * `InsufficientLiquidity`: The swap simulation on the origin subnet fails. * `AmountTooLow`: The TAO-equivalent of the swap is below the minimum stake requirement. * `SlippageTooHigh`: `allow_partial` is false and the amount would cross the limit price. * `StakeUnavailable`: The remaining stake would not cover the locked amount on the origin subnet. # Events May emit a `StakeSwapped` event on success.' return Call('SubtensorModule', 'swap_stake_limit', {'hotkey': hotkey, 'origin_netuid': origin_netuid, 'destination_netuid': destination_netuid, 'alpha_amount': alpha_amount, 'limit_price': limit_price, 'allow_partial': allow_partial}) @staticmethod diff --git a/sdk/python/bittensor/cli/commands/multisig.py b/sdk/python/bittensor/cli/commands/multisig.py index 3115cbc3ed..18e36e3f0e 100644 --- a/sdk/python/bittensor/cli/commands/multisig.py +++ b/sdk/python/bittensor/cli/commands/multisig.py @@ -14,9 +14,11 @@ from ... import config as cfg from ... import storage, wallets from .. import multisig_helpers as ms_helpers +from .. import upgrade_helpers as uh from ..context import AppContext, ctx_of from ..globals import with_globals from ..prompt import confirm_wallet +from .upgrade import load_pending_upgrades, render_upgrade_records app = typer.Typer( no_args_is_help=True, @@ -277,7 +279,7 @@ def multisig_pending( async def _load(client): ms = await client.multisig(signatories_resolved, threshold) - return await ms_helpers.list_pending_with_commands( + records = await ms_helpers.list_pending_with_commands( client, app_ctx, ms=ms, @@ -288,6 +290,25 @@ async def _load(client): call_hash_filter=call_hash, call_data=call_data, ) + # When this multisig IS the chain's sudo key, pending runtime-upgrade + # proposals (held one layer out, on the CI deployment multisig) are + # part of what its signers are waiting to act on — surface them here. + upgrades: list = [] + sudo_key = await client.query(storage.Sudo.Key) + if str(sudo_key) == ms.address and not call_hash: + upgrades = await load_pending_upgrades(client, app_ctx, uh.DEFAULT_UPGRADE_REPO) + return records, upgrades - records = app_ctx.run(_load) + records, upgrades = app_ctx.run(_load) + if app_ctx.output.json_mode and upgrades: + app_ctx.output.value( + { + "pending_multisigs": records, + "count": len(records), + "pending_upgrades": upgrades, + } + ) + return app_ctx.output.pending_multisigs(records, title=f"pending multisig — {label}") + if upgrades: + render_upgrade_records(app_ctx, upgrades) diff --git a/sdk/python/bittensor/cli/commands/upgrade.py b/sdk/python/bittensor/cli/commands/upgrade.py new file mode 100644 index 0000000000..5426668270 --- /dev/null +++ b/sdk/python/bittensor/cli/commands/upgrade.py @@ -0,0 +1,610 @@ +"""`btcli upgrade`: runtime-upgrade proposals — discover, verify, sign. + +The release train proposes a mainnet runtime upgrade as the CI half of a +2-of-2 deployment multisig and publishes a proposal pre-release whose URL is +the one thing signers need: + + btcli upgrade pending # what is waiting, from chain state + btcli upgrade check --url # verify the call data end to end + btcli upgrade sign --url -w # approve it + +`sign` re-runs every check first and refuses on any mismatch, then picks the +right approval (first / interior / final) from chain state — no signer +numbers, timepoints, or PolkadotJS required. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Optional + +import typer + +from ... import calls +from .. import multisig_helpers as ms_helpers +from .. import upgrade_helpers as uh +from ..context import AppContext, ctx_of +from ..globals import with_globals, with_tx_globals +from ..prompt import confirm_wallet + +app = typer.Typer( + no_args_is_help=True, + help="Discover, verify, and sign runtime-upgrade proposals.", +) + + +# --- shared rendering -------------------------------------------------------------------- + + +def _approval_summary(approvals: list[str], threshold: int) -> str: + return f"{len(approvals)} of {threshold} approvals" + + +def upgrade_record_fields(record: dict[str, Any]) -> dict[str, Any]: + """Human key/value view of one discovered pending upgrade.""" + fields: dict[str, Any] = {} + release = record.get("release") + if release: + if release.get("spec_version") is not None: + fields["spec_version"] = release["spec_version"] + fields["release"] = release.get("url") + if release.get("tag"): + fields["source"] = f"{release.get('repo')}@{release['tag']} ({release.get('commit')})" + fields["call_hash"] = record["call_hash"] + fields["proposed at"] = record["timepoint_display"] + fields["CI key"] = record["ci_address"] + fields["deployment multisig"] = record["deployment_multisig"] + fields["sudo key"] = record["sudo_key"] + fields["deployment layer"] = _approval_summary(record["deployment_approvals"], 2) + " (CI)" + sudo_layer = record.get("sudo_layer") + threshold = record.get("sudo_threshold") + if sudo_layer: + summary = _approval_summary(sudo_layer["approvals"], threshold or 0) + if not threshold: + summary = f"{len(sudo_layer['approvals'])} approvals so far" + fields["sudo multisig layer"] = ( + f"{summary} — opened at " + f"{sudo_layer['timepoint']['height']}:{sudo_layer['timepoint']['index']}" + ) + elif record.get("release"): + fields["sudo multisig layer"] = "not opened yet — the next signer opens it" + if record.get("sign_command"): + fields["sign with"] = record["sign_command"] + elif not release: + fields["note"] = ( + "no matching release manifest found — pass the proposal's release URL " + "to `btcli upgrade check/sign` directly" + ) + return fields + + +def render_upgrade_records(app_ctx: AppContext, records: list[dict[str, Any]]) -> None: + out = app_ctx.output + if out.json_mode: + out.value({"pending_upgrades": records, "count": len(records)}) + return + if not records: + out.detail("pending runtime upgrades", {}) + return + for index, record in enumerate(records): + title = "pending runtime upgrade" if index == 0 else None + out.detail(title, upgrade_record_fields(record)) + + +async def _enrich_upgrades(client, app_ctx: AppContext, records: list[dict], repo: str) -> None: + """Attach release/manifest info and sudo-layer status to discovered upgrades. + + Enrichment is advisory: GitHub being unreachable degrades the output but + never fails the command. Fetches run in a worker thread so the websocket + stays serviced. + """ + manifests = await asyncio.to_thread(uh.fetch_release_manifests, repo) + for record in records: + manifest = uh.find_manifest_for_call_hash(manifests, record["call_hash"]) + if not manifest: + continue + record["release"] = { + "url": manifest.get("release_url"), + "repo": manifest.get("repo"), + "tag": manifest.get("tag"), + "commit": manifest.get("commit"), + "spec_version": manifest.get("spec_version"), + "prerelease": manifest.get("prerelease"), + } + if manifest.get("release_url"): + record["sign_command"] = uh.sign_command(app_ctx.network, manifest["release_url"]) + sudo = manifest.get("sudo") or {} + if sudo.get("threshold"): + record["sudo_threshold"] = int(sudo["threshold"]) + call_data_url = (manifest.get("assets") or {}).get("call_data") + if not call_data_url: + continue + try: + blob = uh.parse_hex_blob(await asyncio.to_thread(uh.fetch_bytes, call_data_url)) + except ValueError: + continue + if uh.call_hash_hex(blob) != record["call_hash"]: + continue # release asset does not match this on-chain op; don't trust it + finalizing = await uh.compose_finalizing_call( + client, + blob=blob, + ci_address=record["ci_address"], + deploy_timepoint=record["timepoint"], + ) + record["sudo_layer"] = await uh.sudo_layer_status( + client, + sudo_key=record["sudo_key"], + finalizing_call_hash=ms_helpers.hex_bytes(finalizing.call_hash), + ) + + +async def load_pending_upgrades(client, app_ctx: AppContext, repo: str) -> list[dict]: + records = await uh.discover_pending_upgrades(client) + if records: + await _enrich_upgrades(client, app_ctx, records, repo) + return records + + +# --- commands ---------------------------------------------------------------------------- + + +@app.command("pending") +@with_globals +def upgrade_pending( + ctx: typer.Context, + repo: str = typer.Option( + uh.DEFAULT_UPGRADE_REPO, + "--repo", + help="GitHub repo whose releases carry the upgrade manifests.", + ), +): + """List pending runtime-upgrade proposals, discovered from chain state. + + Walks sudo.key() -> its SudoUncheckedSetCode proxy -> pending multisig + operations, then matches each against the repo's release manifests to show + the spec version, source commit, both approval layers, and the exact sign + command. + """ + app_ctx = ctx_of(ctx) + records = app_ctx.run(lambda client: load_pending_upgrades(client, app_ctx, repo)) + render_upgrade_records(app_ctx, records) + + +def _bundle_or_exit( + app_ctx: AppContext, + *, + url: Optional[str], + hex_url: Optional[str], + hex_file: Optional[str], + wasm: Optional[str], +) -> uh.ProposalBundle: + app_ctx.output.message("[dim]fetching proposal artifacts…[/dim]") + try: + bundle = uh.fetch_proposal_bundle( + release_url=url, hex_url=hex_url, hex_file=hex_file, wasm_path=wasm + ) + except (ValueError, OSError) as error: + app_ctx.output.error(str(error)) + raise typer.Exit(1) + for note in bundle.notes: + app_ctx.output.message(f"[dim]note: {note}[/dim]") + return bundle + + +def _render_checks(app_ctx: AppContext, outcome: uh.CheckOutcome) -> None: + out = app_ctx.output + if out.json_mode: + out.value( + { + "ok": outcome.ok, + "checks": [ + {"name": c.name, "ok": c.ok, "detail": c.detail} for c in outcome.checks + ], + "data": outcome.data, + } + ) + return + glyphs = {True: "✓", False: "✗ FAIL:", None: "– skipped:"} + fields = {c.name: f"{glyphs[c.ok]} {c.detail}" for c in outcome.checks} + out.detail("upgrade check", fields) + + +def _print_reproduce_recipe(app_ctx: AppContext, bundle: uh.ProposalBundle) -> None: + """When the wasm came from the release (or nowhere), point at self-building.""" + if bundle.wasm_source == "local" or app_ctx.output.json_mode: + return + out = app_ctx.output + out.message("") + out.message( + "to verify against code you compiled yourself, build the runtime with " + "srtool and re-run with --wasm:" + ) + for line in uh.srtool_recipe(bundle.manifest): + out.message(f" [dim]{line}[/dim]") + + +@app.command( + "check", + epilog=( + "Example: btcli upgrade check " + "--url https://github.com/RaoFoundation/subtensor/releases/tag/v426 " + "--wasm ./my-srtool-build.wasm" + ), +) +@with_globals +def upgrade_check( + ctx: typer.Context, + url: Optional[str] = typer.Option( + None, + "--url", + help="Proposal release page (https://github.com/O/R/releases/tag/TAG or O/R@TAG).", + ), + hex_url: Optional[str] = typer.Option( + None, "--hex-url", help="URL of the raw call-data hex (proxy_proxy_blob.hex)." + ), + hex_file: Optional[str] = typer.Option( + None, "--hex-file", help="Local path to the call-data hex file." + ), + wasm: Optional[str] = typer.Option( + None, + "--wasm", + help="Runtime wasm you built yourself (srtool); pins the call data to your build.", + ), +): + """Verify a proposed runtime upgrade end to end without signing anything. + + Checks that the call data is exactly proxy.proxy(sudo_key, None, + sudoUncheckedWeight(setCode(wasm), )), that re-encoding it + against live chain metadata reproduces it byte-for-byte, that the embedded + runtime matches the wasm and srtool digest, that the proxied account is + the chain's sudo key, and that a pending on-chain proposal carries its + exact call hash. + """ + app_ctx = ctx_of(ctx) + bundle = _bundle_or_exit(app_ctx, url=url, hex_url=hex_url, hex_file=hex_file, wasm=wasm) + outcome = app_ctx.run(lambda client: uh.run_proposal_checks(client, bundle)) + _render_checks(app_ctx, outcome) + _print_reproduce_recipe(app_ctx, bundle) + if not outcome.ok: + app_ctx.output.error( + "verification failed: " + "; ".join(c.name for c in outcome.failed()), + help="do not sign this proposal until every check passes", + ) + raise typer.Exit(1) + + +def _resolve_sudo_signer_set( + app_ctx: AppContext, + *, + multisig: Optional[str], + multisig_threshold: Optional[int], + signatories: Optional[str], + other_signatories: Optional[str], + manifest: Optional[dict], +) -> tuple[int, list[str]]: + """The sudo multisig's (threshold, resolved signatories) for signing. + + Explicit flags win; otherwise the release manifest's published signer set + is used. Either way the derived address is verified against the live sudo + key before anything is submitted. + """ + threshold, sigs, _preset, _refs = ms_helpers.resolve_multisig( + app_ctx, + multisig_name=multisig, + threshold=multisig_threshold, + signatories=signatories, + other_signatories=other_signatories, + signer="coldkey", + wallet_default=None, + ) + if threshold is not None: + return threshold, sigs + sudo = (manifest or {}).get("sudo") or {} + manifest_sigs = [str(s) for s in sudo.get("signatories") or []] + manifest_threshold = sudo.get("threshold") + if manifest_sigs and manifest_threshold: + return int(manifest_threshold), list(dict.fromkeys(manifest_sigs)) + raise ValueError( + "cannot determine the sudo multisig signer set: the release manifest has " + "no sudo block — pass `--multisig NAME` (a saved multisig) or " + "`--multisig-threshold N --signatories a,b,c`" + ) + + +@app.command( + "sign", + epilog=( + "Example: btcli upgrade sign " + "--url https://github.com/RaoFoundation/subtensor/releases/tag/v426 -w trium-a" + ), +) +@with_tx_globals +def upgrade_sign( + ctx: typer.Context, + url: Optional[str] = typer.Option( + None, + "--url", + help="Proposal release page (https://github.com/O/R/releases/tag/TAG or O/R@TAG).", + ), + hex_url: Optional[str] = typer.Option( + None, "--hex-url", help="URL of the raw call-data hex (proxy_proxy_blob.hex)." + ), + hex_file: Optional[str] = typer.Option( + None, "--hex-file", help="Local path to the call-data hex file." + ), + wasm: Optional[str] = typer.Option( + None, + "--wasm", + help="Runtime wasm you built yourself (srtool); pins the call data to your build.", + ), + multisig: Optional[str] = typer.Option( + None, "--multisig", help="Saved sudo-multisig name (see `btcli multisig list`)." + ), + multisig_threshold: Optional[int] = typer.Option( + None, "--multisig-threshold", help="Sudo multisig threshold (with --signatories)." + ), + signatories: Optional[str] = typer.Option( + None, + "--signatories", + help="Full sudo signer set: ss58, address-book names, or wallet names.", + ), + other_signatories: Optional[str] = typer.Option( + None, + "--other-signatories", + help="Other sudo signers only; your -w wallet coldkey is added.", + ), +): + """Verify a proposed runtime upgrade, then submit your multisig approval. + + Runs every `upgrade check` verification first and refuses to sign on any + mismatch. Your position is read from chain state: if the sudo multisig has + not acted yet you open the operation (first approval), if it is underway + you approve, and if yours is the final approval the upgrade executes in + the same extrinsic. + """ + app_ctx = ctx_of(ctx) + bundle = _bundle_or_exit(app_ctx, url=url, hex_url=hex_url, hex_file=hex_file, wasm=wasm) + + confirm_wallet( + app_ctx, + help_text="Wallet whose coldkey is a sudo multisig signatory.", + require_coldkey=True, + ) + try: + sudo_threshold, sudo_signatories = _resolve_sudo_signer_set( + app_ctx, + multisig=multisig, + multisig_threshold=multisig_threshold, + signatories=signatories, + other_signatories=other_signatories, + manifest=bundle.manifest, + ) + except ValueError as error: + app_ctx.output.error(str(error)) + raise typer.Exit(1) + + signer_address = app_ctx.wallet().coldkeypub.ss58_address + if signer_address not in sudo_signatories: + app_ctx.output.error( + f"wallet {app_ctx.wallet_name!r} coldkey {signer_address} is not in the " + "sudo multisig signer set", + note="signatories: " + ", ".join(sudo_signatories), + ) + raise typer.Exit(1) + + # Phase 1: verify everything and read the current signing position. + async def _verify(client): + outcome = await uh.run_proposal_checks(client, bundle) + plan: dict[str, Any] = {} + if outcome.ok: + derived = await client.multisig(sudo_signatories, sudo_threshold) + plan["multisig_address"] = derived.address + plan["matches_sudo"] = derived.address == outcome.data["sudo_key"] + if plan["matches_sudo"]: + pending = outcome.data["pending"] + finalizing = await uh.compose_finalizing_call( + client, + blob=bundle.blob, + ci_address=pending["ci_address"], + deploy_timepoint=pending["timepoint"], + ) + plan["finalizing_call_hash"] = ms_helpers.hex_bytes(finalizing.call_hash) + plan["sudo_layer"] = await uh.sudo_layer_status( + client, + sudo_key=outcome.data["sudo_key"], + finalizing_call_hash=plan["finalizing_call_hash"], + ) + return outcome, plan + + outcome, plan = app_ctx.run(_verify) + _render_checks(app_ctx, outcome) + if not outcome.ok: + app_ctx.output.error( + "verification failed: " + "; ".join(c.name for c in outcome.failed()), + help="refusing to sign; resolve every failed check first", + ) + raise typer.Exit(1) + if not plan["matches_sudo"]: + app_ctx.output.error( + f"the resolved signer set derives {plan['multisig_address']}, which is not " + f"the chain's sudo key {outcome.data['sudo_key']}", + help="check the threshold and signatory list (or the saved multisig entry)", + ) + raise typer.Exit(1) + + position, prompt = _signing_position( + plan.get("sudo_layer"), sudo_threshold, signer_address, outcome.data + ) + if position == "signed": + app_ctx.output.message(prompt) + _print_share_hint(app_ctx, url, plan.get("sudo_layer"), sudo_signatories) + return + + if bundle.wasm_source != "local": + app_ctx.output.message( + "[yellow]note:[/yellow] the runtime was taken from the release, not a local " + "srtool build — anyone reproducing the build should pass --wasm" + ) + + if app_ctx.dry_run: + app_ctx.output.detail( + "dry run: upgrade sign", + { + "position": position, + "action": prompt, + "sudo_multisig": plan["multisig_address"], + "finalizing_call_hash": plan["finalizing_call_hash"], + }, + ) + return + + app_ctx.confirm(prompt + "?") + signing = app_ctx.signer("coldkey") + + # Phase 2: re-read the position from live state and submit the matching + # approval (state may have advanced since the prompt — later is only ever + # *more* approvals, and the submission adapts). + async def _submit(client): + pending = await uh.find_pending_upgrade(client, bundle.call_hash) + if pending is None: + raise ValueError( + "the deployment-multisig proposal disappeared from chain state " + "(executed or cancelled since verification)" + ) + finalizing = await uh.compose_finalizing_call( + client, + blob=bundle.blob, + ci_address=pending["ci_address"], + deploy_timepoint=pending["timepoint"], + ) + sudo_layer = await uh.sudo_layer_status( + client, + sudo_key=pending["sudo_key"], + finalizing_call_hash=ms_helpers.hex_bytes(finalizing.call_hash), + ) + approvals = (sudo_layer or {}).get("approvals") or [] + if signer_address in approvals: + return None, sudo_layer + others = uh.sorted_other_signatories(sudo_signatories, signer_address) + if sudo_layer is None: + call = calls.Multisig.approve_as_multi( + threshold=sudo_threshold, + other_signatories=others, + maybe_timepoint=None, + call_hash=finalizing.call_hash, + max_weight=uh.FINALIZE_WEIGHT, + ) + elif len(approvals) < sudo_threshold - 1: + call = calls.Multisig.approve_as_multi( + threshold=sudo_threshold, + other_signatories=others, + maybe_timepoint=sudo_layer["timepoint"], + call_hash=finalizing.call_hash, + max_weight=uh.FINALIZE_WEIGHT, + ) + else: + call = calls.Multisig.as_multi( + threshold=sudo_threshold, + other_signatories=others, + maybe_timepoint=sudo_layer["timepoint"], + call=finalizing, + max_weight=uh.FINALIZE_WEIGHT, + ) + result = await client.submit_call( + call, signing, signer="coldkey", wait_for_finalization=False + ) + updated = await uh.sudo_layer_status( + client, + sudo_key=pending["sudo_key"], + finalizing_call_hash=ms_helpers.hex_bytes(finalizing.call_hash), + ) + return result, updated + + result, sudo_layer = app_ctx.run(_submit) + if result is None: + app_ctx.output.message("your approval is already recorded on-chain; nothing to submit") + _print_share_hint(app_ctx, url, sudo_layer, sudo_signatories) + return + if not app_ctx.output.result(result, "runtime-upgrade approval submitted"): + raise typer.Exit(1) + _report_after_sign(app_ctx, url, sudo_layer, sudo_signatories, sudo_threshold) + + +def _signing_position( + sudo_layer: Optional[dict], + threshold: int, + signer_address: str, + check_data: dict[str, Any], +) -> tuple[str, str]: + """(position, human action line) for the confirmation prompt.""" + spec = ((check_data.get("manifest") or {}).get("spec_version")) or "?" + if sudo_layer is None: + return ( + "first", + f"open the sudo-multisig approval for runtime upgrade {spec} " + f"(approval 1 of {threshold})", + ) + approvals = sudo_layer.get("approvals") or [] + if signer_address in approvals: + return ( + "signed", + f"your approval is already recorded ({len(approvals)} of {threshold} so far)", + ) + if len(approvals) >= threshold - 1: + return ( + "final", + f"submit the FINAL approval for runtime upgrade {spec} — " + "the upgrade executes in this extrinsic", + ) + return ( + "interior", + f"add approval {len(approvals) + 1} of {threshold} for runtime upgrade {spec}", + ) + + +def _print_share_hint( + app_ctx: AppContext, + url: Optional[str], + sudo_layer: Optional[dict], + sudo_signatories: list[str], +) -> None: + if app_ctx.output.json_mode or not url: + return + approvals = (sudo_layer or {}).get("approvals") or [] + remaining = [s for s in sudo_signatories if s not in approvals] + if remaining: + app_ctx.output.message("waiting on: " + ", ".join(remaining)) + app_ctx.output.message(f"they can run: {uh.sign_command(app_ctx.network, url)}") + + +def _report_after_sign( + app_ctx: AppContext, + url: Optional[str], + sudo_layer: Optional[dict], + sudo_signatories: list[str], + threshold: int, +) -> None: + if sudo_layer is None: + # The op is gone from pending state: the threshold was reached and the + # finalizing call executed — the upgrade is done (or the pending read + # lagged; `upgrade pending` will confirm). + app_ctx.output.message( + "the sudo multisig operation is complete — the upgrade should now " + "execute; verify with `btcli upgrade pending` and the chain's spec version" + ) + return + approvals = sudo_layer.get("approvals") or [] + fields: dict[str, Any] = { + "approvals": f"{len(approvals)} of {threshold}", + "opened at": (f"{sudo_layer['timepoint']['height']}:{sudo_layer['timepoint']['index']}"), + "call_hash": sudo_layer["call_hash"], + } + remaining = [s for s in sudo_signatories if s not in approvals] + if remaining: + fields["waiting on"] = ", ".join(remaining) + if url: + fields["they run"] = uh.sign_command(app_ctx.network, url) + app_ctx.output.detail("sudo multisig status", fields) + + +__all__ = ["app", "load_pending_upgrades", "render_upgrade_records", "upgrade_record_fields"] diff --git a/sdk/python/bittensor/cli/main.py b/sdk/python/bittensor/cli/main.py index ddc8cbd5de..a5bf86f889 100644 --- a/sdk/python/bittensor/cli/main.py +++ b/sdk/python/bittensor/cli/main.py @@ -43,6 +43,7 @@ subnets, sudo, timelock, + upgrade, utils, wallet, weights, @@ -107,6 +108,7 @@ def list_commands(self, ctx) -> list[str]: app.add_typer(proxy.app, name="proxy", rich_help_panel=PANEL_PROXY_MULTISIG) app.add_typer(multisig.app, name="multisig", rich_help_panel=PANEL_PROXY_MULTISIG) +app.add_typer(upgrade.app, name="upgrade", rich_help_panel=PANEL_PROXY_MULTISIG) # Hidden group aliases carried over from the v9 btcli (`btcli w list`, etc.). for _sub_app, _aliases in ( diff --git a/sdk/python/bittensor/cli/upgrade_helpers.py b/sdk/python/bittensor/cli/upgrade_helpers.py new file mode 100644 index 0000000000..1b81c87454 --- /dev/null +++ b/sdk/python/bittensor/cli/upgrade_helpers.py @@ -0,0 +1,700 @@ +"""Runtime-upgrade proposal helpers: discovery, verification, signing support. + +A mainnet runtime upgrade is proposed by CI as the first half of a 2-of-2 +deployment multisig (CI key + sudo multisig) holding a ``SudoUncheckedSetCode`` +proxy on the chain's sudo key (see ``.github/scripts/deploy`` in the subtensor +repo). Everything about a pending proposal is discoverable from chain state: + + sudo.key() -> proxy.proxies(sudo_key) # the deployment multisig + -> Multisig.Multisigs(deploy) # the pending proposal (hash only) + -> depositor == CI key # multi([CI, sudo], 2) == delegate + +The call *data* is not on-chain (CI approves by hash); it ships as the +``proxy_proxy_blob.hex`` asset of the proposal pre-release that the release +train publishes, alongside ``upgrade-manifest.json`` (spec version, commit, +signer set, asset URLs). These helpers fetch that bundle, verify the call data +is exactly ``proxy.proxy(sudo_key, None, sudoUncheckedWeight(setCode(wasm), +W))`` and matches the on-chain hash, and build the finalizing call the sudo +multisig signs. +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from hashlib import blake2b, sha256 +from typing import Any, Optional + +from .._generated import calls +from .._generated import storage as st +from ..sp_core import ss58_decode, ss58_encode +from . import multisig_helpers as ms_helpers + +DEFAULT_UPGRADE_REPO = "RaoFoundation/subtensor" + +SUDO_PROXY_TYPE = "SudoUncheckedSetCode" + +# Weight literals of the CI deployment pipeline (.github/scripts/deploy). +# PROPOSAL_WEIGHT is an argument of the proposed call itself +# (sudoUncheckedWeight) — changing it changes the call hash CI registered. +# FINALIZE_WEIGHT is an argument of the finalizing as_multi call, whose hash +# the sudo multisig operates on, so every triumvirate approval must repeat it +# exactly. Both must stay in lockstep with propose-upgrade-multisig.js / +# approve-upgrade-multisig.js. +PROPOSAL_WEIGHT = {"ref_time": 50_000_000_000, "proof_size": 0} +FINALIZE_WEIGHT = {"ref_time": 60_000_000_000, "proof_size": 10_000} + +_MAX_FETCH_BYTES = 64 * 1024 * 1024 +_FETCH_TIMEOUT = 60.0 + +MANIFEST_ASSET = "upgrade-manifest.json" +CALL_DATA_ASSET = "proxy_proxy_blob.hex" +WASM_ASSET = "subtensor.wasm" +DIGEST_ASSET = "subtensor-digest.json" + + +# --- releases and assets --------------------------------------------------------------- + + +@dataclass(frozen=True) +class ReleaseRef: + """A GitHub release, addressed as ``owner/repo`` + tag.""" + + repo: str + tag: str + + @property + def url(self) -> str: + return f"https://github.com/{self.repo}/releases/tag/{self.tag}" + + def asset_url(self, name: str) -> str: + return f"https://github.com/{self.repo}/releases/download/{self.tag}/{name}" + + +def parse_release_url(url: str) -> ReleaseRef: + """Parse a GitHub release page or asset URL into a :class:`ReleaseRef`. + + Accepts ``https://github.com/O/R/releases/tag/TAG``, + ``https://github.com/O/R/releases/download/TAG/asset``, and the short + ``O/R@TAG`` form. + """ + short = re.fullmatch(r"([\w.-]+/[\w.-]+)@([\w.-]+)", url.strip()) + if short: + return ReleaseRef(repo=short.group(1), tag=short.group(2)) + match = re.match( + r"https?://github\.com/([\w.-]+/[\w.-]+)/releases/(?:tag|download)/([^/?#]+)", + url.strip(), + ) + if not match: + raise ValueError( + f"cannot parse {url!r} as a GitHub release URL " + "(expected https://github.com/OWNER/REPO/releases/tag/TAG)" + ) + return ReleaseRef(repo=match.group(1), tag=match.group(2)) + + +def _github_headers(api: bool = False) -> dict[str, str]: + headers = {"User-Agent": "btcli-upgrade"} + if api: + headers["Accept"] = "application/vnd.github+json" + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def fetch_bytes(url: str, *, api: bool = False) -> bytes: + """GET a URL (following redirects), capped at ``_MAX_FETCH_BYTES``.""" + request = urllib.request.Request(url, headers=_github_headers(api=api)) + try: + with urllib.request.urlopen(request, timeout=_FETCH_TIMEOUT) as response: + data = response.read(_MAX_FETCH_BYTES + 1) + except urllib.error.HTTPError as error: + raise ValueError(f"fetching {url} failed: HTTP {error.code}") from error + except (urllib.error.URLError, OSError, TimeoutError) as error: + raise ValueError(f"fetching {url} failed: {error}") from error + if len(data) > _MAX_FETCH_BYTES: + raise ValueError(f"{url} exceeds the {_MAX_FETCH_BYTES // (1 << 20)} MiB fetch cap") + return data + + +def fetch_json(url: str, *, api: bool = False) -> Any: + return json.loads(fetch_bytes(url, api=api).decode("utf-8")) + + +def parse_hex_blob(raw: bytes | str) -> bytes: + """Decode a call-data hex payload (``proxy_proxy_blob.hex`` contents).""" + text = raw.decode("ascii") if isinstance(raw, bytes) else raw + text = text.strip().removeprefix("0x") + if not text or not re.fullmatch(r"[0-9a-fA-F]+", text) or len(text) % 2: + raise ValueError("call data is not a valid hex string") + return bytes.fromhex(text) + + +def call_hash_hex(blob: bytes) -> str: + """0x-hex blake2_256 of raw call bytes — the multisig pallet's call hash.""" + return "0x" + blake2b(blob, digest_size=32).hexdigest() + + +@dataclass +class ProposalBundle: + """The verification inputs for one proposal, however they were sourced.""" + + blob: bytes + release: Optional[ReleaseRef] = None + manifest: Optional[dict] = None + wasm: Optional[bytes] = None + digest: Optional[dict] = None + # "local" when --wasm supplied the runtime (the trust anchor), "release" + # when it was downloaded alongside the call data it is checked against. + wasm_source: Optional[str] = None + notes: list[str] = field(default_factory=list) + + @property + def call_hash(self) -> str: + return call_hash_hex(self.blob) + + +def fetch_proposal_bundle( + *, + release_url: Optional[str] = None, + hex_url: Optional[str] = None, + hex_file: Optional[str] = None, + wasm_path: Optional[str] = None, +) -> ProposalBundle: + """Assemble the call data + runtime + manifest from a release URL or raw parts.""" + sources = [s for s in (release_url, hex_url, hex_file) if s] + if len(sources) != 1: + raise ValueError("pass exactly one of --url, --hex-url, or --hex-file") + + release: Optional[ReleaseRef] = None + manifest: Optional[dict] = None + digest: Optional[dict] = None + notes: list[str] = [] + + if release_url: + release = parse_release_url(release_url) + blob = parse_hex_blob(fetch_bytes(release.asset_url(CALL_DATA_ASSET))) + try: + manifest = fetch_json(release.asset_url(MANIFEST_ASSET)) + except ValueError: + notes.append("release has no upgrade-manifest.json asset (older release train)") + try: + digest = fetch_json(release.asset_url(DIGEST_ASSET)) + except ValueError: + notes.append("release has no subtensor-digest.json asset") + elif hex_url: + blob = parse_hex_blob(fetch_bytes(hex_url)) + else: + assert hex_file is not None + with open(hex_file, "rb") as handle: + blob = parse_hex_blob(handle.read()) + + wasm: Optional[bytes] = None + wasm_source: Optional[str] = None + if wasm_path: + with open(wasm_path, "rb") as handle: + wasm = handle.read() + wasm_source = "local" + elif release: + try: + wasm = fetch_bytes(release.asset_url(WASM_ASSET)) + wasm_source = "release" + except ValueError: + notes.append("release has no subtensor.wasm asset") + + return ProposalBundle( + blob=blob, + release=release, + manifest=manifest, + wasm=wasm, + digest=digest, + wasm_source=wasm_source, + notes=notes, + ) + + +def fetch_release_manifests(repo: str, *, limit: int = 15) -> list[dict]: + """Upgrade manifests attached to the repo's recent releases. + + Each manifest gains ``release_url`` and ``prerelease``. Network failures + yield an empty list — manifest enrichment is always advisory. + """ + try: + releases = fetch_json( + f"https://api.github.com/repos/{repo}/releases?per_page={limit}", api=True + ) + except ValueError: + return [] + manifests: list[dict] = [] + for release in releases if isinstance(releases, list) else []: + assets = {a.get("name"): a for a in release.get("assets") or []} + if MANIFEST_ASSET not in assets: + continue + try: + manifest = fetch_json(assets[MANIFEST_ASSET]["browser_download_url"]) + except ValueError: + continue + manifest["release_url"] = release.get("html_url") + manifest["prerelease"] = bool(release.get("prerelease")) + manifests.append(manifest) + return manifests + + +def find_manifest_for_call_hash(manifests: list[dict], call_hash: str) -> Optional[dict]: + """The manifest whose ``call_hash`` matches, if any.""" + wanted = call_hash.lower() + for manifest in manifests: + if str(manifest.get("call_hash", "")).lower() == wanted: + return manifest + return None + + +# --- structural verification (offline port of test-wasm.py) ---------------------------- + +# The blob is the bare SCALE encoding of +# proxy.proxy(real=sudo_key, force_proxy_type=None, +# sudo.sudoUncheckedWeight(system.setCode(wasm), PROPOSAL_WEIGHT)) +# as built by propose-upgrade-multisig.js. Pallet/call indices come from the +# runtime (System=0, Sudo=12, Proxy=16); if they change, this parse fails +# loudly and must be updated in lockstep with test-wasm.py. The parse is only +# used to *extract* the embedded runtime and proxied account — byte-exactness +# is then proven by re-encoding against live chain metadata (see +# ``reconstruct_proxy_call``), which does not depend on these constants. +_PROXY_PROXY = bytes([16, 0]) +_MULTIADDRESS_ID = bytes([0]) +_OPTION_NONE = bytes([0]) +_SUDO_SUDO_UNCHECKED_WEIGHT = bytes([12, 1]) +_SYSTEM_SET_CODE = bytes([0, 2]) + + +def _compact_encode(n: int) -> bytes: + """SCALE compact encoding of a non-negative integer.""" + if n < 1 << 6: + return bytes([n << 2]) + if n < 1 << 14: + return ((n << 2) | 0b01).to_bytes(2, "little") + if n < 1 << 30: + return ((n << 2) | 0b10).to_bytes(4, "little") + data = n.to_bytes((n.bit_length() + 7) // 8, "little") + return bytes([((len(data) - 4) << 2) | 0b11]) + data + + +def _compact_decode(data: bytes) -> tuple[int, int]: + """Decode a SCALE compact integer; returns (value, bytes consumed).""" + if not data: + raise ValueError("empty compact integer") + mode = data[0] & 0b11 + if mode == 0b00: + return data[0] >> 2, 1 + if mode == 0b01: + return int.from_bytes(data[:2], "little") >> 2, 2 + if mode == 0b10: + return int.from_bytes(data[:4], "little") >> 2, 4 + length = (data[0] >> 2) + 4 + return int.from_bytes(data[1 : 1 + length], "little"), 1 + length + + +@dataclass +class ParsedBlob: + """The two variable parts of a well-formed proposal blob.""" + + real_ss58: str + code: bytes + + +def parse_proposal_blob(blob: bytes, *, ss58_format: int = 42) -> ParsedBlob: + """Parse a proposal blob, requiring the exact CI call shape around the wasm. + + Raises ``ValueError`` when the blob is anything other than + ``proxy.proxy(MultiAddress::Id(real), None, + sudoUncheckedWeight(setCode(code), PROPOSAL_WEIGHT))`` — a batch or any + extra call wrapped around honest wasm bytes fails here. + """ + head = _PROXY_PROXY + _MULTIADDRESS_ID + if blob[: len(head)] != head: + raise ValueError("call data is not proxy.proxy(MultiAddress::Id(...), ...)") + real = blob[len(head) : len(head) + 32] + if len(real) != 32: + raise ValueError("call data is truncated inside the proxied account") + rest = blob[len(head) + 32 :] + inner_head = _OPTION_NONE + _SUDO_SUDO_UNCHECKED_WEIGHT + _SYSTEM_SET_CODE + if rest[: len(inner_head)] != inner_head: + raise ValueError( + "call data after the proxied account is not sudoUncheckedWeight(setCode(...), ...)" + ) + rest = rest[len(inner_head) :] + code_len, consumed = _compact_decode(rest) + code = rest[consumed : consumed + code_len] + if len(code) != code_len: + raise ValueError("call data is truncated inside the runtime bytes") + weight = _compact_encode(PROPOSAL_WEIGHT["ref_time"]) + _compact_encode( + PROPOSAL_WEIGHT["proof_size"] + ) + tail = rest[consumed + code_len :] + if tail != weight: + raise ValueError( + "call data does not end with exactly the pinned sudoUncheckedWeight " + f"weight {PROPOSAL_WEIGHT} (or carries trailing bytes)" + ) + return ParsedBlob(real_ss58=ss58_encode(real, ss58_format), code=code) + + +async def reconstruct_proxy_call(client, *, wasm: bytes, sudo_key: str): + """Compose the proposal call from first principles against live metadata. + + Returns the composed call (``.data`` bytes, ``.call_hash``). Byte-equality + of ``.data`` with a proposal blob proves the blob dispatches exactly + ``setCode(wasm)`` on ``sudo_key`` and nothing else. + """ + set_code = await client.compose(calls.System.set_code(code="0x" + wasm.hex())) + unchecked = await client.compose( + calls.Sudo.sudo_unchecked_weight(call=set_code, weight=PROPOSAL_WEIGHT) + ) + return await client.compose( + calls.Proxy.proxy(real=sudo_key, force_proxy_type=None, call=unchecked) + ) + + +# --- on-chain discovery ----------------------------------------------------------------- + + +async def discover_pending_upgrades(client) -> list[dict[str, Any]]: + """Pending runtime-upgrade proposals, from chain state alone. + + Walks sudo.key() -> its ``SudoUncheckedSetCode`` proxies -> pending + multisig ops on each delegate, keeping ops whose depositor + sudo key + re-derive the delegate as a 2-of-2 multisig (the CI deployment pattern). + """ + sudo_key = str(await client.query(st.Sudo.Key)) + value = await client.query(st.Proxy.Proxies, [sudo_key]) + delegations = (value or ([], 0))[0] + upgrades: list[dict[str, Any]] = [] + for delegation in delegations: + if str(delegation.get("proxy_type")) != SUDO_PROXY_TYPE: + continue + if int(delegation.get("delay") or 0) != 0: + continue + delegate = str(delegation.get("delegate")) + for op in await ms_helpers.list_pending_multisig_ops(client, delegate): + depositor = str(op.get("depositor")) + try: + derived = await client.multisig([depositor, sudo_key], 2) + except Exception: + continue + if derived.address != delegate: + continue # not the CI + sudo deployment pattern + upgrades.append( + { + "kind": "runtime-upgrade-proposal", + "call_hash": op["call_hash"], + "timepoint": op["timepoint"], + "timepoint_display": op["timepoint_display"], + "sudo_key": sudo_key, + "deployment_multisig": delegate, + "ci_address": depositor, + "deployment_approvals": list(op.get("approvals") or []), + } + ) + return upgrades + + +async def find_pending_upgrade(client, call_hash: str) -> Optional[dict[str, Any]]: + """The discovered pending upgrade matching ``call_hash``, if any.""" + wanted = call_hash.lower() + for upgrade in await discover_pending_upgrades(client): + if str(upgrade["call_hash"]).lower() == wanted: + return upgrade + return None + + +# --- the finalizing call and the sudo-multisig layer ------------------------------------- + + +async def compose_finalizing_call( + client, + *, + blob: bytes, + ci_address: str, + deploy_timepoint: dict[str, int], +): + """The deployment multisig's finalizing call — what the sudo multisig signs. + + ``Multisig.as_multi(2, [ci], deploy_timepoint, , + FINALIZE_WEIGHT)``: submitted by the sudo multisig account, it lands the + second (executing) approval of CI's proposal. Its encoding must be + byte-identical across all triumvirate signers, which is why the weight is + a pinned constant and the call bytes are spliced straight from the shared + blob (the call encoder accepts pre-composed calls as raw SCALE bytes). + """ + return await client.compose( + calls.Multisig.as_multi( + threshold=2, + other_signatories=[ci_address], + maybe_timepoint={ + "height": int(deploy_timepoint["height"]), + "index": int(deploy_timepoint["index"]), + }, + call=bytes(blob), + max_weight=FINALIZE_WEIGHT, + ) + ) + + +def sorted_other_signatories(signatories: list[str], self_address: str) -> list[str]: + """Everyone but ``self_address``, sorted by raw account id (chain order).""" + others = [s for s in signatories if s != self_address] + return sorted(set(others), key=lambda s: bytes(ss58_decode(s))) + + +async def sudo_layer_status( + client, + *, + sudo_key: str, + finalizing_call_hash: str, +) -> Optional[dict[str, Any]]: + """The sudo multisig's pending op for the finalizing call, if opened.""" + pending = await client.query(st.Multisig.Multisigs, [sudo_key, finalizing_call_hash]) + if not pending: + return None + when = pending.get("when") or {} + return { + "call_hash": finalizing_call_hash, + "timepoint": {"height": int(when.get("height", 0)), "index": int(when.get("index", 0))}, + "approvals": [str(a) for a in pending.get("approvals") or []], + "depositor": str(pending.get("depositor")), + } + + +def sign_command(network: str, release_url: str, wallet: str = "") -> str: + """The copy-paste command for a co-signer.""" + prefix = "btcli" if network == "finney" else f"btcli -n {network}" + return f"{prefix} upgrade sign --url {release_url} -w {wallet}" + + +def check_command(network: str, release_url: str) -> str: + prefix = "btcli" if network == "finney" else f"btcli -n {network}" + return f"{prefix} upgrade check --url {release_url} --wasm " + + +def srtool_recipe(manifest: Optional[dict]) -> list[str]: + """The reproduce-from-source recipe, tailored by the manifest when present.""" + tag = (manifest or {}).get("tag") or "" + repo = (manifest or {}).get("repo") or DEFAULT_UPGRADE_REPO + rustc = (manifest or {}).get("srtool_rustc") + image = f"paritytech/srtool:{rustc}" if rustc else "paritytech/srtool:" + return [ + f"git clone https://github.com/{repo} && cd {repo.split('/', 1)[1]}", + f"git checkout {tag}", + "ln -s . runtime/node-subtensor", + "docker run --rm --user root --platform=linux/amd64 \\", + " -e PACKAGE=node-subtensor-runtime \\", + ' -e BUILD_OPTS="--features=metadata-hash" \\', + " -e PROFILE=production \\", + ' -v "$(pwd)":/build \\', + f" {image} /srtool/build --app", + ] + + +# --- verification runner ------------------------------------------------------------------ + + +@dataclass +class Check: + name: str + ok: Optional[bool] # None = skipped / not applicable + detail: str + + +@dataclass +class CheckOutcome: + checks: list[Check] + data: dict[str, Any] + + @property + def ok(self) -> bool: + return all(c.ok is not False for c in self.checks) + + def failed(self) -> list[Check]: + return [c for c in self.checks if c.ok is False] + + +async def run_proposal_checks(client, bundle: ProposalBundle) -> CheckOutcome: + """Run every offline + on-chain check for a proposal bundle. + + The checks, in order: + + 1. structure — the blob parses as exactly the CI proposal call shape; + 2. reconstruction — re-encoding ``proxy.proxy(real, None, + sudoUncheckedWeight(setCode(code), W))`` against live chain metadata + reproduces the blob byte-for-byte; + 3. runtime match — the embedded runtime equals the provided wasm + (local srtool build or release asset); + 4. digest — sha256 of the embedded runtime matches subtensor-digest.json; + 5. sudo key — the proxied account is the chain's live sudo.key(); + 6. on-chain proposal — a pending deployment-multisig op exists whose call + hash is blake2_256(blob), depositor rederives the delegate; + 7. manifest cross-checks, when a manifest is present. + """ + checks: list[Check] = [] + data: dict[str, Any] = {"call_hash": bundle.call_hash} + + sudo_key = str(await client.query(st.Sudo.Key)) + data["sudo_key"] = sudo_key + + # 1. structure + parsed: Optional[ParsedBlob] = None + try: + parsed = parse_proposal_blob(bundle.blob) + checks.append( + Check( + "call structure", + True, + "exactly proxy.proxy(real, None, sudoUncheckedWeight(setCode(code), " + f"{{ref_time: {PROPOSAL_WEIGHT['ref_time']}, proof_size: " + f"{PROPOSAL_WEIGHT['proof_size']}}})); runtime is " + f"{len(parsed.code)} bytes", + ) + ) + except ValueError as error: + checks.append(Check("call structure", False, str(error))) + + if parsed is not None: + data["proxied_account"] = parsed.real_ss58 + data["code_sha256"] = "0x" + sha256(parsed.code).hexdigest() + + # 2. reconstruction against live metadata + reconstructed = await reconstruct_proxy_call( + client, wasm=parsed.code, sudo_key=parsed.real_ss58 + ) + if bytes(reconstructed.data) == bundle.blob: + checks.append( + Check( + "re-encoding", + True, + "composing the same call against live chain metadata reproduces " + "the call data byte-for-byte", + ) + ) + else: + checks.append( + Check( + "re-encoding", + False, + "re-encoded call differs from the call data " + f"({len(bytes(reconstructed.data))} vs {len(bundle.blob)} bytes) — " + "runtime metadata may disagree with the offline parse", + ) + ) + + # 3. runtime match (the trust anchor when --wasm is a local srtool build) + if bundle.wasm is not None: + if parsed.code == bundle.wasm: + source = ( + "your local build" + if bundle.wasm_source == "local" + else "the release's subtensor.wasm" + ) + checks.append( + Check("runtime match", True, f"embedded runtime is byte-identical to {source}") + ) + else: + checks.append( + Check( + "runtime match", + False, + "embedded runtime differs from the provided wasm " + f"(sha256 {sha256(bundle.wasm).hexdigest()} vs " + f"{sha256(parsed.code).hexdigest()})", + ) + ) + else: + checks.append( + Check( + "runtime match", + None, + "no wasm provided — build from source with srtool and pass --wasm " + "to pin the call data to code you compiled yourself", + ) + ) + + # 4. digest + if bundle.digest is not None: + expected = str(bundle.digest.get("sha256", "")).removeprefix("0x").lower() + actual = sha256(parsed.code).hexdigest() + checks.append( + Check( + "srtool digest", + actual == expected, + f"sha256 {'matches' if actual == expected else 'differs from'} " + "subtensor-digest.json", + ) + ) + + # 5. sudo key + checks.append( + Check( + "sudo key", + parsed.real_ss58 == sudo_key, + f"proxied account {parsed.real_ss58} " + f"{'is' if parsed.real_ss58 == sudo_key else 'IS NOT'} " + f"the chain's sudo key {sudo_key}", + ) + ) + + # 6. on-chain proposal + pending = await find_pending_upgrade(client, bundle.call_hash) + if pending is not None: + data["pending"] = pending + checks.append( + Check( + "on-chain proposal", + True, + f"pending deployment-multisig op matches blake2_256(call data) " + f"{bundle.call_hash} (proposed at {pending['timepoint_display']} " + f"by {pending['ci_address']})", + ) + ) + else: + checks.append( + Check( + "on-chain proposal", + False, + f"no pending deployment-multisig op has call hash {bundle.call_hash} " + "(already executed, cancelled, or not proposed on this network)", + ) + ) + + # 7. manifest cross-checks + manifest = bundle.manifest + if manifest: + manifest_hash = str(manifest.get("call_hash", "")).lower() + checks.append( + Check( + "manifest call hash", + manifest_hash == bundle.call_hash.lower(), + "upgrade-manifest.json call_hash " + f"{'matches' if manifest_hash == bundle.call_hash.lower() else 'differs from'} " + "the call data", + ) + ) + if pending is not None and manifest.get("ci_address"): + ci_matches = str(manifest["ci_address"]) == pending["ci_address"] + checks.append( + Check( + "manifest CI key", + ci_matches, + "manifest ci_address " + f"{'matches' if ci_matches else 'differs from'} the on-chain depositor", + ) + ) + data["manifest"] = { + key: manifest.get(key) + for key in ("spec_version", "commit", "tag", "repo", "wasm_sha256") + } + + return CheckOutcome(checks=checks, data=data) diff --git a/sdk/python/bittensor/intents/staking.py b/sdk/python/bittensor/intents/staking.py index 06cdb4782e..109433cd87 100644 --- a/sdk/python/bittensor/intents/staking.py +++ b/sdk/python/bittensor/intents/staking.py @@ -6,13 +6,18 @@ from typing import Any, ClassVar, Optional from .._generated import calls -from .._generated.runtime_apis import StakeInfoRuntimeApi +from .._generated.runtime_apis import StakeInfoRuntimeApi, SwapRuntimeApi from ..result import BittensorError +from ..settings import RAO_PER_TAO from ..signing import public_view from ._money import ALL, UNBOUNDED, Money, Spend, call_amount from .base import Intent from .registry import register +# Default slippage-protection tolerance: the pool price may move at most this +# fraction from the price observed at build time before the call stops filling. +DEFAULT_RATE_TOLERANCE = 0.05 + NETUID_HELP = "Subnet the stake lives on (netuid 0 is the root network)." STAKE_HOTKEY_HELP = "Hotkey the stake is held on (the validator backing the position)." @@ -34,6 +39,37 @@ "instead of failing the whole call when the limit would be breached." ) +SLIPPAGE_PROTECTION_HELP = ( + "Bound the price the swap may execute at (on by default): the call fails " + "(`SlippageTooHigh`) instead of filling once the pool price moves more than " + "`rate_tolerance` from the price at submission. Disable to execute at any price." +) + +RATE_TOLERANCE_HELP = ( + "Maximum price move slippage protection accepts, as a fraction (0.05 = 5%). " + "Ignored when slippage protection is disabled." +) + + +def _check_rate_tolerance(tolerance: float) -> None: + if not 0 <= tolerance < 1: + raise BittensorError( + f"rate_tolerance must be a fraction in [0, 1), got {tolerance!r} " + "(0.05 = 5%). To trade with no price bound, disable slippage protection " + "(slippage_protection=False / --no-slippage-protection) instead." + ) + + +async def _alpha_price_rao(substrate, netuid: int) -> int: + """Current spot alpha price (rao per alpha) used to derive a limit price.""" + price = await substrate.runtime_call(*SwapRuntimeApi.current_alpha_price, [netuid]) + if price is None: + raise BittensorError( + f"could not read the alpha price for netuid {netuid} to derive a slippage " + "limit; retry, or disable slippage protection to submit without a bound" + ) + return int(price) + async def _staked_rao(substrate, wallet: Any, hotkey_ss58: str, netuid: int) -> int: """Current stake (rao) the signing coldkey holds on ``hotkey_ss58`` at ``netuid``. @@ -60,18 +96,22 @@ class AddStake(Intent): Swaps TAO from the coldkey's free balance into the subnet's alpha at the current pool price and credits the result to your stake on the hotkey; on netuid 0 (root) the stake stays TAO-denominated. The swap moves the pool, - so large amounts incur slippage — use ``add_stake_limit`` to bound the - price. The position's value then follows the pool price and the validator's - performance, and can be exited later with ``remove_stake``. Fails if the - coldkey's free balance cannot cover the amount plus the transaction fee, - and with ``AmountTooLow`` when the amount is below the chain minimum of - 0.002 TAO plus the swap fee. Dynamic subnets also reject a single swap - larger than 1000x the pool's TAO reserve (``InsufficientLiquidity``). + so large amounts incur slippage. By default the call is slippage-protected: + it fails (``SlippageTooHigh``) instead of filling once the price rises more + than ``rate_tolerance`` (5%) above the price at submission — raise the + tolerance or set ``slippage_protection`` to False to execute at any price, + or use ``add_stake_limit`` to set an explicit limit price. The position's + value then follows the pool price and the validator's performance, and can + be exited later with ``remove_stake``. Fails if the coldkey's free balance + cannot cover the amount plus the transaction fee, and with ``AmountTooLow`` + when the amount is below the chain minimum of 0.002 TAO plus the swap fee. + Dynamic subnets also reject a single swap larger than 1000x the pool's TAO + reserve (``InsufficientLiquidity``). """ op = "add_stake" signer = "coldkey" - wraps = (("SubtensorModule", "add_stake"),) + wraps = (("SubtensorModule", "add_stake"), ("SubtensorModule", "add_stake_limit")) mev_shield_default = True hotkey_ss58: str = field( @@ -79,13 +119,29 @@ class AddStake(Intent): ) netuid: int = field(metadata={"help": NETUID_HELP}) amount_tao: Money = field(metadata={"help": "How much of the coldkey's free balance to stake."}) + slippage_protection: bool = field(default=True, metadata={"help": SLIPPAGE_PROTECTION_HELP}) + rate_tolerance: float = field( + default=DEFAULT_RATE_TOLERANCE, metadata={"help": RATE_TOLERANCE_HELP} + ) def __post_init__(self): self.amount_tao = call_amount( self.amount_tao, self.wraps[0], "amount_staked", netuid=self.netuid ) + _check_rate_tolerance(self.rate_tolerance) async def build(self, substrate, wallet: Any): + if self.slippage_protection: + price = await _alpha_price_rao(substrate, self.netuid) + return await substrate.compose( + calls.SubtensorModule.add_stake_limit( + hotkey=self.hotkey_ss58, + netuid=self.netuid, + amount_staked=self.amount_tao.rao, + limit_price=int(price * (1 + self.rate_tolerance)), + allow_partial=False, + ) + ) return await substrate.compose( calls.SubtensorModule.add_stake( hotkey=self.hotkey_ss58, @@ -95,7 +151,12 @@ async def build(self, substrate, wallet: Any): ) def summary(self) -> str: - return f"stake {self.amount_tao} to {self.hotkey_ss58} on netuid {self.netuid}" + note = ( + f" (fails if price moves >{self.rate_tolerance:.2%})" + if self.slippage_protection + else " (no slippage protection)" + ) + return f"stake {self.amount_tao} to {self.hotkey_ss58} on netuid {self.netuid}{note}" def spend(self) -> Spend: return self.amount_tao @@ -110,7 +171,11 @@ class RemoveStake(Intent): it to the signing coldkey's free balance. Pass ``all`` to exit the entire position on that hotkey and subnet (the build fails if nothing is staked there). Like staking, the swap moves the pool, so large amounts incur - slippage — use ``remove_stake_limit`` to bound the price. The hotkey and + slippage. By default the call is slippage-protected: it fails + (``SlippageTooHigh``) instead of filling once the price falls more than + ``rate_tolerance`` (5%) below the price at submission — raise the tolerance + or set ``slippage_protection`` to False to execute at any price, or use + ``remove_stake_limit`` to set an explicit limit price. The hotkey and netuid must match where the stake is actually held, and the subnet must have subtoken trading enabled. The requested amount is capped to the stake currently available. A partial unstake must leave a remainder @@ -120,7 +185,7 @@ class RemoveStake(Intent): op = "remove_stake" signer = "coldkey" - wraps = (("SubtensorModule", "remove_stake"),) + wraps = (("SubtensorModule", "remove_stake"), ("SubtensorModule", "remove_stake_limit")) mev_shield_default = True all_amount_fields: ClassVar[tuple[str, ...]] = ("amount_alpha",) @@ -129,17 +194,33 @@ class RemoveStake(Intent): amount_alpha: Money = field( metadata={"help": "How much to unstake from this position, or ``all``."} ) + slippage_protection: bool = field(default=True, metadata={"help": SLIPPAGE_PROTECTION_HELP}) + rate_tolerance: float = field( + default=DEFAULT_RATE_TOLERANCE, metadata={"help": RATE_TOLERANCE_HELP} + ) def __post_init__(self): self.amount_alpha = call_amount( self.amount_alpha, self.wraps[0], "amount_unstaked", netuid=self.netuid, allow_all=True ) + _check_rate_tolerance(self.rate_tolerance) async def build(self, substrate, wallet: Any): if self.amount_alpha == ALL: rao = await _staked_rao(substrate, wallet, self.hotkey_ss58, self.netuid) else: rao = self.amount_alpha.rao + if self.slippage_protection: + price = await _alpha_price_rao(substrate, self.netuid) + return await substrate.compose( + calls.SubtensorModule.remove_stake_limit( + hotkey=self.hotkey_ss58, + netuid=self.netuid, + amount_unstaked=rao, + limit_price=int(price * (1 - self.rate_tolerance)), + allow_partial=False, + ) + ) return await substrate.compose( calls.SubtensorModule.remove_stake( hotkey=self.hotkey_ss58, @@ -150,7 +231,12 @@ async def build(self, substrate, wallet: Any): def summary(self) -> str: amount = "ALL alpha" if self.amount_alpha == ALL else str(self.amount_alpha) - return f"unstake {amount} from {self.hotkey_ss58} on netuid {self.netuid}" + note = ( + f" (fails if price moves >{self.rate_tolerance:.2%})" + if self.slippage_protection + else " (no slippage protection)" + ) + return f"unstake {amount} from {self.hotkey_ss58} on netuid {self.netuid}{note}" async def warnings(self, substrate, signer_address: str) -> list[str]: if self.amount_alpha == ALL: @@ -412,14 +498,18 @@ class SwapStake(Intent): Moves part of a position from the origin subnet to the destination subnet while staying on the same hotkey: the alpha is swapped to TAO in the origin pool and then to alpha in the destination pool, so both legs can - incur slippage. The two netuids must differ (``SameNetuid``). Use - ``move_stake`` when the hotkey should change too, and ``remove_stake`` - plus ``add_stake`` only if you want to control each leg separately. + incur slippage. By default the call is slippage-protected: it fails + (``SlippageTooHigh``) instead of filling once the origin/destination price + ratio falls more than ``rate_tolerance`` (5%) below the ratio at submission + — raise the tolerance or set ``slippage_protection`` to False to execute at + any price. The two netuids must differ (``SameNetuid``). Use ``move_stake`` + when the hotkey should change too, and ``remove_stake`` plus ``add_stake`` + only if you want to control each leg separately. """ op = "swap_stake" signer = "coldkey" - wraps = (("SubtensorModule", "swap_stake"),) + wraps = (("SubtensorModule", "swap_stake"), ("SubtensorModule", "swap_stake_limit")) mev_shield_default = True hotkey_ss58: str = field(metadata={"help": STAKE_HOTKEY_HELP}) @@ -431,13 +521,39 @@ class SwapStake(Intent): "explicit amount; ``all`` is not accepted)." } ) + slippage_protection: bool = field(default=True, metadata={"help": SLIPPAGE_PROTECTION_HELP}) + rate_tolerance: float = field( + default=DEFAULT_RATE_TOLERANCE, metadata={"help": RATE_TOLERANCE_HELP} + ) def __post_init__(self): self.amount_alpha = call_amount( self.amount_alpha, self.wraps[0], "alpha_amount", netuid=self.origin_netuid ) + _check_rate_tolerance(self.rate_tolerance) async def build(self, substrate, wallet: Any): + if self.slippage_protection: + origin_price = await _alpha_price_rao(substrate, self.origin_netuid) + dest_price = await _alpha_price_rao(substrate, self.dest_netuid) + if dest_price <= 0: + raise BittensorError( + f"netuid {self.dest_netuid} has no alpha price; cannot derive a " + "slippage limit — disable slippage protection to submit anyway" + ) + # The chain compares the limit against the origin/destination price + # ratio (falling as the swap executes), scaled by 1e9. + ratio_rao = origin_price * RAO_PER_TAO // dest_price + return await substrate.compose( + calls.SubtensorModule.swap_stake_limit( + hotkey=self.hotkey_ss58, + origin_netuid=self.origin_netuid, + destination_netuid=self.dest_netuid, + alpha_amount=self.amount_alpha.rao, + limit_price=int(ratio_rao * (1 - self.rate_tolerance)), + allow_partial=False, + ) + ) return await substrate.compose( calls.SubtensorModule.swap_stake( hotkey=self.hotkey_ss58, @@ -448,9 +564,14 @@ async def build(self, substrate, wallet: Any): ) def summary(self) -> str: + note = ( + f" (fails if price ratio moves >{self.rate_tolerance:.2%})" + if self.slippage_protection + else " (no slippage protection)" + ) return ( f"swap {self.amount_alpha} on {self.hotkey_ss58} from netuid " - f"{self.origin_netuid} to netuid {self.dest_netuid}" + f"{self.origin_netuid} to netuid {self.dest_netuid}{note}" ) def touches_netuids(self) -> list[int]: diff --git a/sdk/python/bittensor/result.py b/sdk/python/bittensor/result.py index 38c30504a2..3b4aa1cec8 100644 --- a/sdk/python/bittensor/result.py +++ b/sdk/python/bittensor/result.py @@ -83,7 +83,12 @@ class ConnectionNotReady(BittensorError): "\n" "unlike insufficient_balance this is not about your account — it is about " "the subnet's liquidity. reduce the amount, split it into smaller steps " - "across blocks, or trade on a subnet with deeper reserves." + "across blocks, or trade on a subnet with deeper reserves.\n" + "\n" + "stake trades are slippage-protected by default with a 5% tolerance " + "(SlippageTooHigh): raise it with `--rate-tolerance`, or disable the " + "protection with `--no-slippage-protection` (slippage_protection=False " + "in the SDK) to trade through the price move." ), ErrorCode.RATE_LIMITED: ( "the chain enforces per-key rate limits on certain calls (registration, weight " @@ -242,6 +247,17 @@ class ConnectionNotReady(BittensorError): ("invalid transaction", ErrorCode.INVALID_ARGUMENT), ) +# Remediation overrides keyed by the exact chain error name; more specific +# than the per-code defaults above. +_NAME_HELP_OVERRIDES: dict[str, str] = { + "SlippageTooHigh": ( + "the price moved past the slippage-protection limit (stake trades are " + "protected by default with a 5% tolerance); retry, raise the tolerance " + "(`--rate-tolerance 0.1` / rate_tolerance=0.1), or disable protection " + "entirely (`--no-slippage-protection` / slippage_protection=False)" + ), +} + _HELP_OVERRIDES: tuple[tuple[str, str], ...] = ( ( "bad signature", @@ -287,6 +303,8 @@ def __init__( @property def remediation(self) -> str: + if self.name in _NAME_HELP_OVERRIDES: + return _NAME_HELP_OVERRIDES[self.name] haystack = self.message.lower() for needle, help_text in _HELP_OVERRIDES: if needle in haystack: diff --git a/sdk/python/bittensor/settings.py b/sdk/python/bittensor/settings.py index cf918d9074..090182a08d 100644 --- a/sdk/python/bittensor/settings.py +++ b/sdk/python/bittensor/settings.py @@ -34,6 +34,7 @@ "finney": "wss://entrypoint-finney.opentensor.ai:443", "test": "wss://test.finney.opentensor.ai:443", "archive": "wss://archive.chain.opentensor.ai:443", + "devnet": "wss://dev.chain.opentensor.ai:443", "local": os.getenv("BT_CHAIN_ENDPOINT") or "ws://127.0.0.1:9944", } diff --git a/sdk/python/codegen/check.py b/sdk/python/codegen/check.py index 1d22cd93f2..56959078c7 100644 --- a/sdk/python/codegen/check.py +++ b/sdk/python/codegen/check.py @@ -112,7 +112,6 @@ def check_drift(endpoint: str) -> int: "burn_alpha", "recycle_alpha", "remove_stake_full_limit", - "swap_stake_limit", "register_limit", # identity / metadata / misc (set_identity / set_subnet_identity / # update_symbol are wrapped) diff --git a/sdk/python/tests/harness/fake_substrate.py b/sdk/python/tests/harness/fake_substrate.py index 1c0124679b..fc7fbf964b 100644 --- a/sdk/python/tests/harness/fake_substrate.py +++ b/sdk/python/tests/harness/fake_substrate.py @@ -61,6 +61,13 @@ ("SubtensorModule", "InitialStartCallDelay"): 100, } +# Runtime-API results answered when the test seeds nothing. Chosen so intent +# builds that consult chain state (e.g. the alpha price backing the default +# slippage-protection limit) work offline. +DEFAULT_RUNTIME: dict[tuple[str, str], Any] = { + ("SwapRuntimeApi", "current_alpha_price"): 10**9, # 1 TAO per alpha +} + GENESIS_HASH = "0x" + "00" * 32 @@ -128,7 +135,7 @@ def __init__(self) -> None: self._maps: dict[tuple[str, str], list[tuple[Any, Any]]] = {} self._constants: dict[tuple[str, str], Any] = dict(DEFAULT_CONSTANTS) # (api, method) -> value, or callable(params) -> value - self._runtime: dict[tuple[str, str], Any] = {} + self._runtime: dict[tuple[str, str], Any] = dict(DEFAULT_RUNTIME) self.fee = Balance.from_rao(124_414) self.weight = {"ref_time": 1_000_000, "proof_size": 3_593} diff --git a/sdk/python/tests/unit/test_slippage_protection.py b/sdk/python/tests/unit/test_slippage_protection.py new file mode 100644 index 0000000000..2f219696de --- /dev/null +++ b/sdk/python/tests/unit/test_slippage_protection.py @@ -0,0 +1,149 @@ +"""Default slippage protection on the staking intents. + +``add_stake``, ``remove_stake``, and ``swap_stake`` are slippage-protected by +default: at build time they read the spot price and compose the ``*_limit`` +call variant with a fill-or-kill limit derived from ``rate_tolerance`` (5%). +These tests pin the call selection, the limit-price math against seeded +prices, the opt-out, the failure modes, and the ``SlippageTooHigh`` +remediation that tells the user how to loosen or disable the protection. +""" + +from __future__ import annotations + +import pytest + +from bittensor.intents import REGISTRY, build +from bittensor.result import BittensorError, ChainError, ErrorCode +from tests.harness.fake_substrate import FakeSubstrate +from tests.harness.samples import BOB_HOT, dev_wallet + +RAO = 10**9 + +ADD = {"hotkey_ss58": BOB_HOT, "netuid": 1, "amount_tao": 1.0} +REMOVE = {"hotkey_ss58": BOB_HOT, "netuid": 1, "amount_alpha": 1.0} +SWAP = {"hotkey_ss58": BOB_HOT, "origin_netuid": 1, "dest_netuid": 2, "amount_alpha": 1.0} + + +@pytest.fixture() +def substrate() -> FakeSubstrate: + sub = FakeSubstrate() + # Spot prices: netuid 1 at 2 TAO/alpha, netuid 2 at 1 TAO/alpha. + sub.seed_runtime("SwapRuntimeApi", "current_alpha_price", lambda p: {1: 2 * RAO, 2: RAO}[p[0]]) + return sub + + +@pytest.fixture(scope="module") +def wallet(): + return dev_wallet() + + +class TestDefaultProtection: + @pytest.mark.asyncio + async def test_add_stake_composes_limit_call(self, substrate, wallet): + call = await build("add_stake", ADD).build(substrate, wallet) + assert call.function == "add_stake_limit" + # Max price to pay: spot * (1 + 5%). + assert call.params["limit_price"] == int(2 * RAO * 1.05) + assert call.params["allow_partial"] is False + assert call.params["amount_staked"] == RAO + + @pytest.mark.asyncio + async def test_remove_stake_composes_limit_call(self, substrate, wallet): + call = await build("remove_stake", REMOVE).build(substrate, wallet) + assert call.function == "remove_stake_limit" + # Min price to accept: spot * (1 - 5%). + assert call.params["limit_price"] == int(2 * RAO * 0.95) + assert call.params["allow_partial"] is False + + @pytest.mark.asyncio + async def test_swap_stake_composes_limit_call(self, substrate, wallet): + call = await build("swap_stake", SWAP).build(substrate, wallet) + assert call.function == "swap_stake_limit" + # Min origin/destination price ratio (scaled by 1e9): 2.0 * (1 - 5%). + assert call.params["limit_price"] == int(2 * RAO * 0.95) + assert call.params["allow_partial"] is False + + @pytest.mark.asyncio + async def test_custom_tolerance_moves_the_limit(self, substrate, wallet): + call = await build("add_stake", {**ADD, "rate_tolerance": 0.1}).build(substrate, wallet) + assert call.params["limit_price"] == int(2 * RAO * 1.1) + call = await build("remove_stake", {**REMOVE, "rate_tolerance": 0.1}).build( + substrate, wallet + ) + assert call.params["limit_price"] == int(2 * RAO * 0.9) + + @pytest.mark.asyncio + async def test_remove_all_resolves_stake_and_keeps_protection(self, substrate, wallet): + substrate.seed_runtime( + "StakeInfoRuntimeApi", + "get_stake_info_for_hotkey_coldkey_netuid", + {"stake": 5 * RAO}, + ) + call = await build("remove_stake", {**REMOVE, "amount_alpha": "all"}).build( + substrate, wallet + ) + assert call.function == "remove_stake_limit" + assert call.params["amount_unstaked"] == 5 * RAO + + def test_summary_names_the_tolerance(self): + assert "5.00%" in build("add_stake", ADD).summary() + off = build("add_stake", {**ADD, "slippage_protection": False}).summary() + assert "no slippage protection" in off + + +class TestOptOut: + @pytest.mark.parametrize( + ("op", "args", "plain"), + [ + ("add_stake", ADD, "add_stake"), + ("remove_stake", REMOVE, "remove_stake"), + ("swap_stake", SWAP, "swap_stake"), + ], + ) + @pytest.mark.asyncio + async def test_disabled_composes_plain_call(self, substrate, wallet, op, args, plain): + call = await build(op, {**args, "slippage_protection": False}).build(substrate, wallet) + assert call.function == plain + assert "limit_price" not in call.params + + +class TestFailureModes: + @pytest.mark.parametrize("bad", [-0.1, 1.0, 5]) + @pytest.mark.parametrize( + ("op", "args"), [("add_stake", ADD), ("remove_stake", REMOVE), ("swap_stake", SWAP)] + ) + def test_out_of_range_tolerance_rejected_at_construction(self, op, args, bad): + with pytest.raises(BittensorError, match="rate_tolerance"): + build(op, {**args, "rate_tolerance": bad}) + + @pytest.mark.asyncio + async def test_missing_price_fails_with_disable_hint(self, wallet): + sub = FakeSubstrate() + sub.seed_runtime("SwapRuntimeApi", "current_alpha_price", None) + with pytest.raises(BittensorError, match="disable slippage protection"): + await build("add_stake", ADD).build(sub, wallet) + + @pytest.mark.asyncio + async def test_swap_with_unpriced_destination_fails_loudly(self, wallet): + sub = FakeSubstrate() + sub.seed_runtime("SwapRuntimeApi", "current_alpha_price", lambda p: {1: RAO, 2: 0}[p[0]]) + with pytest.raises(BittensorError, match="no alpha price"): + await build("swap_stake", SWAP).build(sub, wallet) + + +class TestErrorSurface: + def test_slippage_too_high_remediation_names_the_off_switch(self): + error = ChainError("Slippage is too high for the transaction.", "SlippageTooHigh") + assert error.code is ErrorCode.INSUFFICIENT_LIQUIDITY + assert "--rate-tolerance" in error.remediation + assert "--no-slippage-protection" in error.remediation + assert "slippage_protection=False" in error.remediation + + def test_schema_exposes_the_protection_fields(self): + for op in ("add_stake", "remove_stake", "swap_stake"): + schema = REGISTRY[op].json_schema() + assert schema["properties"]["slippage_protection"]["type"] == "boolean" + assert schema["properties"]["rate_tolerance"]["type"] == "number" + # Optional with defaults: agents that omit them get the protection. + assert "slippage_protection" not in schema["required"] + assert "rate_tolerance" not in schema["required"] diff --git a/ts-tests/configs/zombie_node.json b/ts-tests/configs/zombie_single_node.json similarity index 72% rename from ts-tests/configs/zombie_node.json rename to ts-tests/configs/zombie_single_node.json index f073be80bd..e37ab34595 100644 --- a/ts-tests/configs/zombie_node.json +++ b/ts-tests/configs/zombie_single_node.json @@ -7,6 +7,7 @@ "chain_spec_path": "specs/chain-spec.json", "default_command": "../target/release/node-subtensor", "default_args": [ + "--sealing=100" ], "genesis": { "runtimeGenesis": { @@ -22,17 +23,7 @@ "nodes": [ { "name": "one", - "rpc_port": "9947", - "validator": true - }, - { - "name": "two", - "rpc_port": "9948", - "validator": true - }, - { - "name": "three", - "rpc_port": "9949", + "rpc_port": 9947, "validator": true } ] @@ -44,4 +35,4 @@ "post_state": "Hash" } } -} \ No newline at end of file +} diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index 951661ef9e..c8fcaaca64 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -52,7 +52,7 @@ "foundation": { "type": "zombie", "zombieSpec": { - "configPath": "./configs/zombie_node.json", + "configPath": "./configs/zombie_single_node.json", "skipBlockCheck": [] } }, @@ -99,6 +99,117 @@ } ] }, + { + "name": "zombienet_shield_a", + "timeout": 600000, + "testFileDir": ["suites/zombienet_shield"], + "include": [ + "suites/zombienet_shield/00.01-basic.test.ts" + ], + "runScripts": [ + "generate-types.sh", + "build-spec.sh" + ], + "foundation": { + "type": "zombie", + "zombieSpec": { + "configPath": "./configs/zombie_extended.json", + "skipBlockCheck": [] + } + }, + "vitestArgs": { + "bail": 1 + }, + "connections": [ + { + "name": "Node", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9947"], + "descriptor": "subtensor" + }, + { + "name": "NodeFull", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9950"], + "descriptor": "subtensor" + } + ] + }, + { + "name": "zombienet_shield_b", + "timeout": 600000, + "testFileDir": ["suites/zombienet_shield"], + "include": [ + "suites/zombienet_shield/03-timing.test.ts", + "suites/zombienet_shield/04-mortality.test.ts" + ], + "runScripts": [ + "generate-types.sh", + "build-spec.sh" + ], + "foundation": { + "type": "zombie", + "zombieSpec": { + "configPath": "./configs/zombie_extended.json", + "skipBlockCheck": [] + } + }, + "vitestArgs": { + "bail": 1 + }, + "connections": [ + { + "name": "Node", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9947"], + "descriptor": "subtensor" + }, + { + "name": "NodeFull", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9950"], + "descriptor": "subtensor" + } + ] + }, + { + "name": "zombienet_shield_c", + "timeout": 600000, + "testFileDir": ["suites/zombienet_shield"], + "include": [ + "suites/zombienet_shield/00.00-basic.test.ts", + "suites/zombienet_shield/01-scaling.test.ts", + "suites/zombienet_shield/02-edge-cases.test.ts" + ], + "runScripts": [ + "generate-types.sh", + "build-spec.sh" + ], + "foundation": { + "type": "zombie", + "zombieSpec": { + "configPath": "./configs/zombie_extended.json", + "skipBlockCheck": [] + } + }, + "vitestArgs": { + "bail": 1 + }, + "connections": [ + { + "name": "Node", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9947"], + "descriptor": "subtensor" + }, + { + "name": "NodeFull", + "type": "papi", + "endpoints": ["ws://127.0.0.1:9950"], + "descriptor": "subtensor" + } + ] + }, { "name": "zombienet_coldkey_swap", "timeout": 600000, @@ -110,7 +221,7 @@ "foundation": { "type": "zombie", "zombieSpec": { - "configPath": "./configs/zombie_node.json", + "configPath": "./configs/zombie_single_node.json", "skipBlockCheck": [] } }, @@ -136,7 +247,7 @@ "foundation": { "type": "zombie", "zombieSpec": { - "configPath": "./configs/zombie_node.json", + "configPath": "./configs/zombie_single_node.json", "skipBlockCheck": [] } }, @@ -168,7 +279,7 @@ "foundation": { "type": "zombie", "zombieSpec": { - "configPath": "./configs/zombie_node.json", + "configPath": "./configs/zombie_single_node.json", "skipBlockCheck": [] } }, diff --git a/ts-tests/scripts/validate-e2e-config.mjs b/ts-tests/scripts/validate-e2e-config.mjs new file mode 100644 index 0000000000..d0048c0c93 --- /dev/null +++ b/ts-tests/scripts/validate-e2e-config.mjs @@ -0,0 +1,95 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +const tsTestsDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const config = JSON.parse(readFileSync(join(tsTestsDir, "moonwall.config.json"), "utf8")); +const singleNodeSpec = JSON.parse(readFileSync(join(tsTestsDir, "configs/zombie_single_node.json"), "utf8")); +const shieldSpec = JSON.parse(readFileSync(join(tsTestsDir, "configs/zombie_extended.json"), "utf8")); +const environments = new Map(config.environments.map((environment) => [environment.name, environment])); + +const shieldFiles = readdirSync(join(tsTestsDir, "suites/zombienet_shield")) + .filter((file) => file.endsWith(".test.ts")) + .map((file) => `suites/zombienet_shield/${file}`) + .sort(); +const shieldShardNames = ["zombienet_shield_a", "zombienet_shield_b", "zombienet_shield_c"]; +const shieldShardIncludes = shieldShardNames.map((name) => { + const environment = environments.get(name); + if (!environment) { + throw new Error(`Missing Moonwall environment: ${name}`); + } + const includes = environment.include ?? []; + if (includes.length === 0) { + throw new Error(`${name} must include at least one Shield test`); + } + return includes; +}); +// File counts intentionally differ: the shards are balanced by measured runtime. +const shieldIncludes = shieldShardIncludes.flat(); + +const duplicateShieldFiles = shieldIncludes.filter((file, index) => shieldIncludes.indexOf(file) !== index); +if (duplicateShieldFiles.length > 0) { + throw new Error(`Shield tests assigned to multiple shards: ${[...new Set(duplicateShieldFiles)].join(", ")}`); +} + +const sortedIncludes = [...shieldIncludes].sort(); +if (JSON.stringify(sortedIncludes) !== JSON.stringify(shieldFiles)) { + const missing = shieldFiles.filter((file) => !sortedIncludes.includes(file)); + const unknown = sortedIncludes.filter((file) => !shieldFiles.includes(file)); + throw new Error(`Shield shard coverage mismatch; missing=[${missing.join(", ")}] unknown=[${unknown.join(", ")}]`); +} + +const singleNodeConfig = "./configs/zombie_single_node.json"; +const singleNodes = singleNodeSpec.relaychain?.nodes ?? []; +if ( + singleNodes.length !== 1 || + singleNodes[0]?.validator !== true || + !singleNodeSpec.relaychain?.default_args?.includes("--sealing=100") +) { + throw new Error("Single-node state spec must contain one validator using --sealing=100"); +} + +for (const name of ["zombienet_staking", "zombienet_coldkey_swap", "zombienet_evm", "zombienet_subnets"]) { + const configPath = environments.get(name)?.foundation?.zombieSpec?.configPath; + if (configPath !== singleNodeConfig) { + throw new Error(`${name} must use ${singleNodeConfig}; found ${configPath ?? "no config"}`); + } +} + +const shieldConfig = "./configs/zombie_extended.json"; +const shieldNodes = shieldSpec.relaychain?.nodes ?? []; +const shieldValidators = shieldNodes.filter((node) => node.validator === true); +if (shieldNodes.length !== 6 || shieldValidators.length !== 3) { + throw new Error( + `Shield spec must retain six nodes and three validators; found ${shieldNodes.length} nodes and ${shieldValidators.length} validators` + ); +} + +const canonicalShieldEnvironment = environments.get("zombienet_shield"); +if (!canonicalShieldEnvironment) { + throw new Error("Missing Moonwall environment: zombienet_shield"); +} +const sharedShieldSettings = ({ name: _name, include: _include, ...settings }) => settings; +const canonicalShieldSettings = sharedShieldSettings(canonicalShieldEnvironment); + +for (const name of ["zombienet_shield", ...shieldShardNames]) { + const environment = environments.get(name); + const configPath = environment?.foundation?.zombieSpec?.configPath; + const connectionNames = new Set((environment?.connections ?? []).map((connection) => connection.name)); + if (configPath !== shieldConfig) { + throw new Error(`${name} must use ${shieldConfig}; found ${configPath ?? "no config"}`); + } + if (!connectionNames.has("Node") || !connectionNames.has("NodeFull")) { + throw new Error(`${name} must expose authority and full-node connections`); + } + if (name !== "zombienet_shield") { + if (!isDeepStrictEqual(sharedShieldSettings(environment), canonicalShieldSettings)) { + throw new Error(`${name} settings must match zombienet_shield except for name and include`); + } + } +} + +console.log( + `Validated ${shieldFiles.length} Shield files, ${shieldShardNames.length + 1} multi-node Shield environments, and four single-node state suites.` +); diff --git a/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts index a21b61c777..e10967f1ce 100644 --- a/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts +++ b/ts-tests/suites/zombienet_evm/00-evm-substrate-transfer.test.ts @@ -24,6 +24,7 @@ import { ss58ToEthAddress, ss58ToH160, tao, + waitForEthBalance, waitForFinalizedBlocks, waitForTransactionWithRetry, WITHDRAW_CONTRACT_ABI, @@ -48,7 +49,7 @@ function expectWithinTxFee(actual: bigint, expected: bigint): void { async function transferAndGetFee( wallet: ethers.Wallet, wallet2: ethers.Wallet, - provider: ethers.Provider, + provider: ethers.JsonRpcProvider, maxFeePerGas: bigint, maxPriorityFeePerGas: bigint ): Promise { @@ -137,7 +138,11 @@ describeSuite({ }); await waitForTransactionWithRetry(api, tx, signer, "substrate_to_evm"); - const receiverBalanceAfter = await getEthBalance(provider, ethWallet.address); + const receiverBalanceAfter = await waitForEthBalance( + provider, + ethWallet.address, + receiverBalance + raoToEth(transferBalance) + ); expect(receiverBalanceAfter).toEqual(receiverBalance + raoToEth(transferBalance)); }, }); @@ -226,7 +231,11 @@ describeSuite({ await waitForTransactionWithRetry(api, tx, signer, "evm_call"); - const receiverBalanceAfterCall = await getEthBalance(provider, ethWallet.address); + const receiverBalanceAfterCall = await waitForEthBalance( + provider, + ethWallet.address, + receiverBalance + raoToEth(tao(1)) + ); expect(receiverBalanceAfterCall).toEqual(receiverBalance + raoToEth(tao(1))); }, }); diff --git a/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts b/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts index f23e9b5380..ad2af9bc32 100644 --- a/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts +++ b/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts @@ -297,12 +297,13 @@ describeSuite({ const depositAlphaTx = await contractForCall.depositAlpha(netuid, tao(10).toString(), hotkey.publicKey); const depositReceipt = await depositAlphaTx.wait(); - expect(depositReceipt?.status).toEqual(1); + if (!depositReceipt) throw new Error("Missing depositAlpha receipt"); + expect(depositReceipt.status).toEqual(1); // Wait for the deposit's own block to finalize rather than a // fixed block count: when GRANDPA lags best by more than 2 // blocks, the finalized-state stake reads below see the // pre-deposit stake and the toBeLessThan assertion flakes. - await waitUntilBlockFinalized(api, depositReceipt!.blockNumber); + await waitUntilBlockFinalized(api, depositReceipt.blockNumber); const stakeAfterDeposit = await getStake(api, hotkeySs58, walletSs58, netuid); expect(stakeAfterDeposit).toBeLessThan(stakeBeforeDeposit); diff --git a/ts-tests/suites/zombienet_shield/01-scaling.test.ts b/ts-tests/suites/zombienet_shield/01-scaling.test.ts index a83d5a3e2b..aa12f50d46 100644 --- a/ts-tests/suites/zombienet_shield/01-scaling.test.ts +++ b/ts-tests/suites/zombienet_shield/01-scaling.test.ts @@ -5,6 +5,7 @@ import { Keyring } from "@polkadot/keyring"; import { hexToU8a } from "@polkadot/util"; import type { PolkadotClient, TypedApi } from "polkadot-api"; import { beforeAll, expect } from "vitest"; +import { sleep } from "@zombienet/utils"; import { checkRuntime, getAccountNonce, @@ -15,13 +16,38 @@ import { waitForFinalizedBlocks, } from "../../utils"; +type IndexedEvmBlock = { + hash: string; + number: string; +}; + +async function waitForIndexedEvmBlock(client: PolkadotClient, blockNumber: number): Promise { + const deadline = Date.now() + 60_000; + const blockTag = `0x${blockNumber.toString(16)}`; + + while (Date.now() < deadline) { + try { + const block = (await client._request("eth_getBlockByNumber", [blockTag, false])) as IndexedEvmBlock | null; + if (block) return block; + } catch { + // The node may know the finalized Substrate block just before its + // asynchronous Frontier mapping is queryable. + } + await sleep(1_000); + } + + throw new Error(`Frontier did not index finalized block #${blockNumber}`); +} + describeSuite({ id: "01_scaling", title: "MEV Shield — 6 node scaling", foundationMethods: "zombie", testCases: ({ it, context }) => { let api: TypedApi; + let apiFull: TypedApi; let client: PolkadotClient; + let clientFull: PolkadotClient; let alice: KeyringPair; let bob: KeyringPair; @@ -35,6 +61,8 @@ describeSuite({ client = context.papi("Node"); api = client.getTypedApi(subtensor); + clientFull = context.papi("NodeFull"); + apiFull = clientFull.getTypedApi(subtensor); await checkRuntime(api); }, 120000); @@ -85,6 +113,37 @@ describeSuite({ const balanceAfter = await getBalance(api, bob.address); expect(balanceAfter).toBeGreaterThan(balanceBefore); + + // The state-oriented suites run one immediately-finalized node, + // so retain explicit GRANDPA propagation and Frontier indexing + // assertions on the production-like Shield topology. Pin every + // read to the same finalized block to avoid comparing independently + // advancing latest-state views. + const finalizedHash = (await client._request("chain_getFinalizedHead", [])) as string; + const finalizedNumber = await api.query.System.Number.getValue({ at: finalizedHash }); + const authorityAccount = await api.query.System.Account.getValue(bob.address, { at: finalizedHash }); + expect(authorityAccount.data.free).toBe(balanceAfter); + const deadline = Date.now() + 60_000; + let fullNodeBalance: bigint | undefined; + while (Date.now() < deadline) { + try { + const fullAccount = await apiFull.query.System.Account.getValue(bob.address, { + at: finalizedHash, + }); + fullNodeBalance = fullAccount.data.free; + break; + } catch { + await sleep(1_000); + } + } + expect(fullNodeBalance).toBe(authorityAccount.data.free); + + const [authorityEvmBlock, fullNodeEvmBlock] = await Promise.all([ + waitForIndexedEvmBlock(client, finalizedNumber), + waitForIndexedEvmBlock(clientFull, finalizedNumber), + ]); + expect(fullNodeEvmBlock.number).toBe(authorityEvmBlock.number); + expect(fullNodeEvmBlock.hash).toBe(authorityEvmBlock.hash); }, }); diff --git a/ts-tests/utils/evm.ts b/ts-tests/utils/evm.ts index c4f35c1fba..48ba6c96bd 100644 --- a/ts-tests/utils/evm.ts +++ b/ts-tests/utils/evm.ts @@ -1,4 +1,4 @@ -import { subtensor } from "@polkadot-api/descriptors"; +import type { subtensor } from "@polkadot-api/descriptors"; import { Keyring } from "@polkadot/keyring"; import { ethers } from "ethers"; import type { TypedApi } from "polkadot-api"; @@ -16,13 +16,63 @@ export async function disableWhiteListCheck(api: TypedApi, dis await waitForTransactionWithRetry(api, tx, alice, "disable_whitelist", 5); } +class UncachedNonceWallet extends ethers.Wallet { + constructor( + privateKey: string, + private readonly rpcProvider: ethers.JsonRpcProvider + ) { + super(privateKey, rpcProvider); + } + + /** + * Bypass ethers' 250ms request cache for nonce reads. Development blocks + * seal every 100ms, so a cached pending nonce can already be stale when the + * next transaction is populated and cause a spurious "nonce too low". + */ + override async getNonce(blockTag: ethers.BlockTag = "latest"): Promise { + if (blockTag !== "pending") { + return super.getNonce(blockTag); + } + + const nonce = await this.rpcProvider.send("eth_getTransactionCount", [this.address, "pending"]); + return ethers.getNumber(nonce, "nonce"); + } +} + export function createEthersWallet(provider: ethers.JsonRpcProvider): ethers.Wallet { const account = ethers.Wallet.createRandom(); - return new ethers.Wallet(account.privateKey, provider); + return new UncachedNonceWallet(account.privateKey, provider); } -export async function getEthBalance(provider: ethers.Provider, address: string): Promise { - return provider.getBalance(address); +/** Read an uncached latest balance directly from the node. */ +export async function getEthBalance(provider: ethers.JsonRpcProvider, address: string): Promise { + return BigInt(await provider.send("eth_getBalance", [address, "latest"])); +} + +/** + * Wait for Frontier's latest-state view to expose an exact balance. + * + * Raw RPC is intentional: ethers caches some high-level reads briefly, which + * can return the pre-transaction value when development blocks are very fast. + */ +export async function waitForEthBalance( + provider: ethers.JsonRpcProvider, + address: string, + expected: bigint, + timeoutMs = 30_000 +): Promise { + const deadline = Date.now() + timeoutMs; + let actual = 0n; + + while (Date.now() < deadline) { + actual = await getEthBalance(provider, address); + if (actual === expected) { + return actual; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Timed out waiting for ${address} balance ${expected}; last observed ${actual}`); } /** Read chain ID via RPC without ethers' cached-network checks. */ diff --git a/ts-tests/utils/staking.ts b/ts-tests/utils/staking.ts index 79c432b17b..a43ed5000a 100644 --- a/ts-tests/utils/staking.ts +++ b/ts-tests/utils/staking.ts @@ -315,7 +315,7 @@ export async function waitForBlocks(api: TypedApi, numBlocks: if (currentBlock >= targetBlock) { break; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 100)); } } diff --git a/ts-tests/utils/transactions.ts b/ts-tests/utils/transactions.ts index d57be282fb..d8c66dafae 100644 --- a/ts-tests/utils/transactions.ts +++ b/ts-tests/utils/transactions.ts @@ -112,6 +112,7 @@ export async function sendTransaction( } const SECOND = 1000; +const LOCAL_BLOCK_POLL_INTERVAL = 100; /** * Polls until the finalized head reaches `blockNumber` (inclusive). Use this @@ -124,7 +125,7 @@ const SECOND = 1000; export async function waitUntilBlockFinalized( api: TypedApi, blockNumber: number, - pollInterval = 1 * SECOND, + pollInterval = LOCAL_BLOCK_POLL_INTERVAL, timeout = 120 * SECOND ): Promise { const deadline = Date.now() + timeout; @@ -142,7 +143,7 @@ export async function waitUntilBlockFinalized( export async function waitForFinalizedBlocks( api: TypedApi, count: number, - pollInterval = 1 * SECOND, + pollInterval = LOCAL_BLOCK_POLL_INTERVAL, timeout = 120 * SECOND ): Promise { const startBlock = await api.query.System.Number.getValue({ at: "finalized" }); diff --git a/website/apps/bittensor-website/public/catalog/intents.json b/website/apps/bittensor-website/public/catalog/intents.json index 9eb8d7edf0..990cc26d4d 100644 --- a/website/apps/bittensor-website/public/catalog/intents.json +++ b/website/apps/bittensor-website/public/catalog/intents.json @@ -32,7 +32,7 @@ { "name": "add_stake", "summary": "Stake TAO from the coldkey onto a hotkey.", - "description": "Stake TAO from the coldkey onto a hotkey.\n\nSwaps TAO from the coldkey's free balance into the subnet's alpha at the\ncurrent pool price and credits the result to your stake on the hotkey; on\nnetuid 0 (root) the stake stays TAO-denominated. The swap moves the pool,\nso large amounts incur slippage \u2014 use `add_stake_limit` to bound the\nprice. The position's value then follows the pool price and the validator's\nperformance, and can be exited later with `remove_stake`. Fails if the\ncoldkey's free balance cannot cover the amount plus the transaction fee,\nand with `AmountTooLow` when the amount is below the chain minimum of\n0.002 TAO plus the swap fee. Dynamic subnets also reject a single swap\nlarger than 1000x the pool's TAO reserve (`InsufficientLiquidity`).", + "description": "Stake TAO from the coldkey onto a hotkey.\n\nSwaps TAO from the coldkey's free balance into the subnet's alpha at the\ncurrent pool price and credits the result to your stake on the hotkey; on\nnetuid 0 (root) the stake stays TAO-denominated. The swap moves the pool,\nso large amounts incur slippage. By default the call is slippage-protected:\nit fails (`SlippageTooHigh`) instead of filling once the price rises more\nthan `rate_tolerance` (5%) above the price at submission \u2014 raise the\ntolerance or set `slippage_protection` to False to execute at any price,\nor use `add_stake_limit` to set an explicit limit price. The position's\nvalue then follows the pool price and the validator's performance, and can\nbe exited later with `remove_stake`. Fails if the coldkey's free balance\ncannot cover the amount plus the transaction fee, and with `AmountTooLow`\nwhen the amount is below the chain minimum of 0.002 TAO plus the swap fee.\nDynamic subnets also reject a single swap larger than 1000x the pool's TAO\nreserve (`InsufficientLiquidity`).", "signer": "coldkey", "input_schema": { "type": "object", @@ -57,6 +57,14 @@ } ], "description": "How much of the coldkey's free balance to stake." + }, + "slippage_protection": { + "type": "boolean", + "description": "Bound the price the swap may execute at (on by default): the call fails (`SlippageTooHigh`) instead of filling once the pool price moves more than `rate_tolerance` from the price at submission. Disable to execute at any price." + }, + "rate_tolerance": { + "type": "number", + "description": "Maximum price move slippage protection accepts, as a fraction (0.05 = 5%). Ignored when slippage protection is disabled." } }, "required": [ @@ -1112,7 +1120,7 @@ { "name": "remove_stake", "summary": "Unstake alpha from a hotkey back to the coldkey.", - "description": "Unstake alpha from a hotkey back to the coldkey.\n\nSwaps the alpha position back to TAO at the current pool price and credits\nit to the signing coldkey's free balance. Pass `all` to exit the entire\nposition on that hotkey and subnet (the build fails if nothing is staked\nthere). Like staking, the swap moves the pool, so large amounts incur\nslippage \u2014 use `remove_stake_limit` to bound the price. The hotkey and\nnetuid must match where the stake is actually held, and the subnet must\nhave subtoken trading enabled. The requested amount is capped to the\nstake currently available. A partial unstake must leave a remainder\nworth at least 0.002 TAO at the simulated pool price \u2014 exit the full\nposition instead of leaving dust (`AmountTooLow`).", + "description": "Unstake alpha from a hotkey back to the coldkey.\n\nSwaps the alpha position back to TAO at the current pool price and credits\nit to the signing coldkey's free balance. Pass `all` to exit the entire\nposition on that hotkey and subnet (the build fails if nothing is staked\nthere). Like staking, the swap moves the pool, so large amounts incur\nslippage. By default the call is slippage-protected: it fails\n(`SlippageTooHigh`) instead of filling once the price falls more than\n`rate_tolerance` (5%) below the price at submission \u2014 raise the tolerance\nor set `slippage_protection` to False to execute at any price, or use\n`remove_stake_limit` to set an explicit limit price. The hotkey and\nnetuid must match where the stake is actually held, and the subnet must\nhave subtoken trading enabled. The requested amount is capped to the\nstake currently available. A partial unstake must leave a remainder\nworth at least 0.002 TAO at the simulated pool price \u2014 exit the full\nposition instead of leaving dust (`AmountTooLow`).", "signer": "coldkey", "input_schema": { "type": "object", @@ -1143,6 +1151,14 @@ } ], "description": "How much to unstake from this position, or ``all``." + }, + "slippage_protection": { + "type": "boolean", + "description": "Bound the price the swap may execute at (on by default): the call fails (`SlippageTooHigh`) instead of filling once the pool price moves more than `rate_tolerance` from the price at submission. Disable to execute at any price." + }, + "rate_tolerance": { + "type": "number", + "description": "Maximum price move slippage protection accepts, as a fraction (0.05 = 5%). Ignored when slippage protection is disabled." } }, "required": [ @@ -1990,7 +2006,7 @@ { "name": "swap_stake", "summary": "Swap stake on one hotkey between two subnets.", - "description": "Swap stake on one hotkey between two subnets.\n\nMoves part of a position from the origin subnet to the destination subnet\nwhile staying on the same hotkey: the alpha is swapped to TAO in the\norigin pool and then to alpha in the destination pool, so both legs can\nincur slippage. The two netuids must differ (`SameNetuid`). Use\n`move_stake` when the hotkey should change too, and `remove_stake`\nplus `add_stake` only if you want to control each leg separately.", + "description": "Swap stake on one hotkey between two subnets.\n\nMoves part of a position from the origin subnet to the destination subnet\nwhile staying on the same hotkey: the alpha is swapped to TAO in the\norigin pool and then to alpha in the destination pool, so both legs can\nincur slippage. By default the call is slippage-protected: it fails\n(`SlippageTooHigh`) instead of filling once the origin/destination price\nratio falls more than `rate_tolerance` (5%) below the ratio at submission\n\u2014 raise the tolerance or set `slippage_protection` to False to execute at\nany price. The two netuids must differ (`SameNetuid`). Use `move_stake`\nwhen the hotkey should change too, and `remove_stake` plus `add_stake`\nonly if you want to control each leg separately.", "signer": "coldkey", "input_schema": { "type": "object", @@ -2019,6 +2035,14 @@ } ], "description": "How much of the origin position to swap across (an explicit amount; ``all`` is not accepted)." + }, + "slippage_protection": { + "type": "boolean", + "description": "Bound the price the swap may execute at (on by default): the call fails (`SlippageTooHigh`) instead of filling once the pool price moves more than `rate_tolerance` from the price at submission. Disable to execute at any price." + }, + "rate_tolerance": { + "type": "number", + "description": "Maximum price move slippage protection accepts, as a fraction (0.05 = 5%). Ignored when slippage protection is disabled." } }, "required": [ diff --git a/website/apps/bittensor-website/public/images/og_thumbs/v431-upgrade.png b/website/apps/bittensor-website/public/images/og_thumbs/v431-upgrade.png new file mode 100644 index 0000000000..5770ca85b7 Binary files /dev/null and b/website/apps/bittensor-website/public/images/og_thumbs/v431-upgrade.png differ diff --git a/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/page.module.css b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/page.module.css new file mode 100644 index 0000000000..4064864cad --- /dev/null +++ b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/page.module.css @@ -0,0 +1,78 @@ +@import url(../shared.module.css); + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.paper_title { + font-size: 20px; +} + +.subtitle { + font-family: 'FiraCode'; + font-size: 12px; + font-weight: 200; + margin-bottom: -8px; + text-transform: uppercase; + text-align: center; +} + +.release_list { + display: flex; + flex-direction: column; + width: 100%; + gap: 0; +} + +.release_item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 28px 0; + border-bottom: 1px solid rgba(41, 41, 41, 0.15); + text-decoration: none; + color: inherit; +} + +.release_item:first-child { + border-top: 1px solid rgba(41, 41, 41, 0.15); +} + +.release_meta { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; +} + +.release_tag { + font-family: 'FiraCode'; + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + color: #d15168; +} + +.release_date { + font-family: 'FiraCode'; + font-size: 11px; + font-weight: 200; + text-transform: uppercase; + color: rgb(41, 41, 41); +} + +.release_title { + font-size: 16px; + font-weight: 600; + text-align: left; +} + +.release_summary { + text-align: left; + margin: 0; +} diff --git a/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/page.tsx b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/page.tsx new file mode 100644 index 0000000000..5f4de79780 --- /dev/null +++ b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/page.tsx @@ -0,0 +1,66 @@ +import FadeInWrapper from '@/app/components/FadeInWrapper'; +import {Link} from '@raofoundation/ui'; +import type {Metadata} from 'next'; +import {Suspense} from 'react'; +import styles from './page.module.css'; + +export const metadata: Metadata = { + title: 'Releases', + description: + 'Bittensor network releases: every runtime upgrade with what changed, why it matters, ' + + 'and what to do about it.', + alternates: {canonical: '/releases'}, +}; + +type Release = { + tag: string; + date: string; + title: string; + summary: string; + href: string; +}; + +// Newest first. Add new releases to the top. +const releases: Release[] = [ + { + tag: 'v431', + date: 'July 2026', + title: 'The V431 Upgrade', + summary: + 'Conviction-based subnet ownership, price-driven emissions, the bittensor v11 SDK ' + + 'with a Rust core, Ledger and browser-extension signing, and a verifiable upgrade ' + + 'pipeline — the monorepo era.', + href: '/releases/v431-upgrade', + }, +]; + +const page = () => { + return ( + }> + +
+

Releases

+

+ Network upgrades, in order +

+
+
+
+ {releases.map((release) => ( + + + {release.tag} + {release.date} + + {release.title} +

{release.summary}

+ + ))} +
+
+
+
+ ); +}; + +export default page; diff --git a/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/v431-upgrade/page.module.css b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/v431-upgrade/page.module.css new file mode 100644 index 0000000000..f5ea37976f --- /dev/null +++ b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/v431-upgrade/page.module.css @@ -0,0 +1,132 @@ +@import url(../../shared.module.css); + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.paper_title { + font-size: 20px; +} + +.subtitle { + font-family: 'FiraCode'; + font-size: 12px; + font-weight: 200; + margin-bottom: -8px; + text-transform: uppercase; + text-align: center; +} + +.paper_link { + font-family: 'FiraCode'; + font-size: 12px; + font-weight: 200; + text-transform: uppercase; + color: #d15168; +} + +.inline_link { + color: #d15168; + text-decoration: underline; + text-underline-offset: 3px; +} + +.list { + list-style-type: decimal; + margin: 10px 10px; + padding: inherit 10px; + display: flex; + gap: 10px; + flex-direction: column; + width: calc(100% - 40px); +} + +.code_block { + font-family: 'FiraCode'; + font-size: 12px; + font-weight: 200; + /* Viewport-based width: with white-space pre, width 100% would let the + longest code line dictate the section's min-content width and force the + whole page to overflow horizontally on narrow screens. The page + container is max-width 960px with 32px side padding. */ + width: calc(100vw - 64px); + max-width: 896px; + margin: 0; + padding: 16px 20px; + border: 1px solid rgba(41, 41, 41, 0.2); + box-sizing: border-box; + text-align: left; + overflow-x: auto; + white-space: pre; + line-height: 1.6; +} + +.graph { + width: 100%; + height: auto; + margin-top: 8px; +} + +/* The SVG scales with the viewport, so at phone widths the 10px labels + become ~5px. CSS font-size beats the SVG presentation attribute. */ +@media (max-width: 640px) { + .graph text { + font-size: 16px; + } +} + +.graph_caption { + font-size: 12px; + opacity: 0.7; + text-align: center !important; +} + +.metrics_table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.metrics_table th { + font-family: 'FiraCode'; + font-size: 11px; + font-weight: 400; + text-transform: uppercase; + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid rgb(41, 41, 41); +} + +.metrics_table td { + padding: 8px 12px; + border-bottom: 1px solid rgba(41, 41, 41, 0.15); + text-align: left; +} + +.connect_links { + display: flex; + justify-content: center; + align-items: center; + gap: 24px; + margin-top: 24px; + flex-wrap: wrap; +} + +.connect_link { + display: inline-flex; + text-decoration: none; +} + +.connect_label { + font-family: 'FiraCode'; + font-weight: 400; + font-size: 12px; + line-height: 18px; + color: rgb(41, 41, 41); + text-transform: uppercase; +} diff --git a/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/v431-upgrade/page.tsx b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/v431-upgrade/page.tsx new file mode 100644 index 0000000000..da928da631 --- /dev/null +++ b/website/apps/bittensor-website/src/app/(pages-without-footer)/releases/v431-upgrade/page.tsx @@ -0,0 +1,595 @@ +import FadeInWrapper from '@/app/components/FadeInWrapper'; +import {MenuSchema} from '@/app/components/Header/MenuSchema'; +import {Link} from '@raofoundation/ui'; +import type {Metadata} from 'next'; +import {Suspense} from 'react'; +import styles from './page.module.css'; + +export const metadata: Metadata = { + title: 'The V431 Upgrade', + description: + 'One repository, one package, new documentation, and new network economics: ' + + 'conviction-based subnet ownership, price-driven emissions, and the bittensor v11 SDK.', + alternates: {canonical: '/releases/v431-upgrade'}, + openGraph: {images: '/images/og_thumbs/v431-upgrade.png'}, +}; + +const CONNECT_LINK_ORDER = ['DISCORD', 'X', 'GITHUB'] as const; +const connectLinks = CONNECT_LINK_ORDER.map((label) => + MenuSchema.connect.find((item) => item.label.toUpperCase() === label), +).filter((link): link is (typeof MenuSchema)['connect'][number] => Boolean(link)); + +const DocLink = ({href, children}: {href: string; children: React.ReactNode}) => ( + + {children} + +); + +const GRAPH_TEXT = { + fontFamily: 'FiraCode', + fontSize: 10, + fill: 'rgb(41, 41, 41)', +} as const; + +const ConvictionGraph = () => ( + + {/* Region where both conditions hold */} + + + OWNERSHIP + + + CONTESTABLE + + + {/* Axes */} + + + + TIME + + + 0% + + + 10% + + + 20% + + + {/* 10% threshold */} + + + CONVICTION THRESHOLD: 10% OF OUTSTANDING ALPHA + + + {/* Subnet age = 1 year */} + + + SUBNET AGE = 1 YEAR + + + {/* Total conviction accrued by lockers */} + + + TOTAL CONVICTION + + + {/* Point where the threshold is crossed past one year of age */} + + +); + +const EMISSION_BARS = { + before: [150, 108, 70], + after: [109, 109, 109], + ages: ['3 MO', '1 YR', '2 YR'], +} as const; + +const EmissionGraph = () => ( + + {(['before', 'after'] as const).map((panel, p) => { + const x0 = p === 0 ? 80 : 425; + return ( + + + {panel === 'before' ? 'BEFORE V431' : 'AFTER V431'} + + + {panel === 'before' ? 'SAME PRICE, LESS WITH AGE' : 'EMISSION FOLLOWS PRICE'} + + {EMISSION_BARS[panel].map((h, i) => { + const x = x0 + i * 90; + return ( + + + + {EMISSION_BARS.ages[i]} + + + ); + })} + + + ); + })} + + {'\u2192'} + + + THREE SUBNETS, IDENTICAL MOVING-AVERAGE PRICE + + +); + +const page = () => { + return ( + }> + +
+

The V431 Upgrade

+

+ Written by Arbos +

+

+ July 2026 +

+
+ +
+

Introduction

+

+ The chain now runs + spec version 431, and the release changes both the network's + economics and the software used to interact with it. Subnet ownership is now + contestable through a time-weighted commitment mechanism called{' '} + conviction. Emission between + subnets is now allocated purely in proportion to each subnet's moving-average + price. The Python SDK and the btcli command line now ship together as{' '} + bittensor v11, built on a new Rust core. The documentation has + been rebuilt at bittensor.com/docs. And the chain, + SDK, CLI, documentation, and website are now developed and released together from a + single repository. +

+

+ This page explains each change, the reasoning behind it, and the actions required + of network participants. Each section links to the relevant documentation. +

+
+ +
+

Ownership by conviction

+

+ Locking alpha on a subnet accrues conviction: a time-weighted + commitment score credited to a hotkey chosen by the locker. Prior to this upgrade, + conviction was recorded on-chain but had no effect. As of spec 431, it governs + subnet ownership: +

+

+ + If a subnet is more than one year old, and the total conviction across its + lockers exceeds ten percent of its outstanding alpha, ownership of the subnet — + including the owner's share of emissions — transfers to the hotkey with the + highest conviction. + +

+ +

+ Ownership becomes contestable once both conditions hold: the subnet is older than + one year, and total conviction exceeds ten percent of its outstanding alpha. At + that point the hotkey with the highest conviction takes ownership. +

+

+ Subnet ownership is therefore no longer fixed at registration; it is contestable + through open, on-chain rules. Two lock modes are available, and both are + exponential processes rather than fixed terms. A perpetual lock's conviction + approaches its locked mass asymptotically — it never quite completes. The + chain's maturity rate sets the exponential time constant, roughly 43 + days at current values: after one time constant conviction stands at about 63% of + the locked mass, after two about 86%, and so on. A decaying lock — the default — + frees its locked mass on the chain's unlock rate, a time constant of + roughly 130 days, after which about 37% of the mass remains locked; its conviction + peaks and then unwinds. Both rates are governance-set storage values — read them + from chain state before planning a lock rather than relying on the figures here — + and there is one exception: locks credited to the subnet owner's own hotkey + mature instantly, so their conviction always equals their locked mass. The mechanism is designed to reward long-horizon commitment to a + subnet's success. The lock modes, the conviction formula, and a worked example + are documented in the{' '} + conviction guide. +

+
+ +
+

Emissions, simplified

+

+ Each block, the chain divides TAO emission between subnets. As of this upgrade, + that division is determined solely by each subnet's + moving-average price, weighted by a miner-burn penalty. The + root-proportion term has been removed from the cross-subnet calculation. + Previously, this term reduced a subnet's emission share as its alpha issuance + grew, which structurally disadvantaged older subnets. Root proportion continues to + operate within each subnet — capping liquidity injection and reserving the + root stakers' share of dividends — but it no longer affects how emission is + divided between subnets. +

+ +

+ Three subnets with an identical moving-average price. Before v431, the + root-proportion term reduced each subnet's share as its alpha issuance grew; + after, the same price earns the same emission regardless of age. +

+

+ The result is that a subnet's emission share is a direct function of its + market price. The full formula and its parameters are documented in{' '} + emissions. +

+
+ +
+

One package: bittensor v11

+

+ bittensor v11 consolidates the SDK and the btcli command line into + a single package, installed with pip install bittensor. It + replaces the separate bittensor-cli and bittensor-wallet packages. Existing wallet + keyfiles are unchanged and fully compatible. +

+

+ The package is built on a new Rust core covering keys, keyfiles, encoding, and + timelock encryption. The following measurements were taken against the live + network, before and after the change: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Operationv10v11
Startup codec build (every btcli invocation)337 ms4 ms
Metagraph decode throughput3–6 MB/s60–77 MB/s
Storage map decode92k entries/s599k entries/s
1,000-operation batch construction~1.1 s~50 ms
+

+ Transaction submission and inclusion remain bound by chain block time; the + improvements are concentrated in startup, decoding, and construction. v11 is also a + major revision of the API: the Subtensor class is replaced by a client-and-intent + model with planning, policy gates, and typed results. The{' '} + migration guide maps every v9/v10 call to + its v11 equivalent, and the{' '} + quickstart covers new installations. +

+

+ To upgrade an existing environment, uninstall the old packages first — both own the + btcli command, so order matters: +

+
+            {`pip uninstall -y bittensor-cli bittensor-wallet
+pip install -U bittensor`}
+          
+

+ In the new SDK, chain state is read through a typed client, and every transaction + is an intent that can be planned before it is executed: +

+
+            {`import asyncio
+import bittensor as sub
+from bittensor.wallet import Wallet
+
+async def main():
+    wallet = Wallet(name="my_coldkey", hotkey="my_hotkey")
+    async with sub.Client("finney") as client:
+        balance = await client.balances.get("5F...coldkey")
+
+        intent = sub.Transfer(dest_ss58="5F...dest", amount_tao=1.5)
+        plan = await client.plan(intent, wallet)      # fee and effects; nothing submitted
+        result = await client.execute(intent, wallet)
+        if not result.success:
+            print(result.error.code, result.error.remediation)
+
+asyncio.run(main())`}
+          
+

+ The CLI is generated from the same catalog: every transaction is a btcli tx + command and every query a btcli query command, with hand-written groups wrapping + the familiar workflows and the v9 shorthands preserved as aliases. Every mutation + supports --dry-run, which shows the fee, the predicted effects, and any policy + verdict without submitting: +

+
+            {`btcli config set network finney
+btcli wallet balance my_coldkey
+btcli query metagraph --netuid 1
+btcli tx transfer --dest 5F...dest --amount-tao 1.5 --dry-run
+btcli tx transfer --dest 5F...dest --amount-tao 1.5 -w my_coldkey`}
+          
+

+ Beyond the consolidation, v11 adds capabilities that did not exist in the old + stack: +

+
    +
  1. + Unit-safe money — every Balance is tagged with its currency, so + TAO and subnet alpha cannot be silently mixed; arithmetic across units raises + instead of producing a wrong number. See{' '} + money. +
  2. +
  3. + Policy guardrails — a client can be bound to hard limits + (maximum fee, maximum spend, allowed subnets), and any transaction that would + exceed them is refused before it is signed. See{' '} + the transaction model. +
  4. +
  5. + Typed errors — every failure returns a semantic error code and + a remediation hint rather than prose, with the full mapping published as a + machine-readable catalog. See errors. +
  6. +
  7. + Signed requests — hotkey-signed HTTP between validators and + miners, so a request provably came from a specific hotkey, covers exactly the + bytes received, and cannot be replayed. See{' '} + signed requests. +
  8. +
  9. + Timelock encryption — seal data that anyone can open at a known + future time and nobody, including the author, can open early; the same mechanism + that secures commit-reveal weights, exposed directly. See{' '} + timelock. +
  10. +
  11. + Proxies as a first-class signer — every transaction accepts + --proxy-for, so a delegate key can act for a coldkey that never comes online; + scoped proxy types, announced (delayed) proxies, and pure proxy accounts are all + supported. See advanced operations. +
  12. +
  13. + Multisig accounts — create and operate k-of-n multisig + accounts, with the full approve, execute, and cancel flow wrapped by the btcli + multisig command group. +
  14. +
  15. + Atomic batches and MEV-shielded submission — compose several + intents into one all-or-nothing transaction, or encrypt a coldkey transaction to + the next block's ephemeral key so it cannot be observed or front-run in the + mempool. +
  16. +
  17. + Safer key rotation — hotkey swaps move registrations and stake + to a new key, and a leaked coldkey can be evacuated through an announced, + five-day-delayed swap that the real owner can dispute. See{' '} + wallets and keys. +
  18. +
  19. + Address safety — CLI address arguments resolve from a saved + address book or local key names as well as raw ss58, a defense against address + poisoning documented in{' '} + address hygiene. +
  20. +
+
+ +
+

Hardware and extension signing

+

+ Every transaction can now be signed on a Ledger hardware wallet{' '} + using clear signing. Through merkleized metadata, the device decodes the + transaction on its own screen, and the chain verifies the same metadata digest that + was signed; the device rejects any transaction it cannot decode and verify. Any + command that signs accepts the --ledger flag: +

+
+            {`btcli tx transfer --dest 5F...dest --amount-tao 1 --ledger
+btcli tx transfer --dest 5F...dest --amount-tao 1 --ledger --ledger-account 1`}
+          
+

+ Setup and usage are documented in{' '} + signing with a Ledger. +

+

+ Transactions can also be signed with a browser extension — + Talisman, Polkadot.js, or SubWallet. The CLI relays the transaction to the + extension through a local bridge, and only the signature is returned; no keyfile, + password, or mnemonic is present on the machine running btcli: +

+
+            {`btcli extension accounts    # list accounts the extensions expose
+btcli tx transfer --dest 5F...dest --amount-tao 1 --signer extension`}
+          
+

+ The full flow is documented in{' '} + + signing with a browser extension + + . +

+
+ +
+

Documentation, rebuilt

+

+ The documentation has been rebuilt and now lives at{' '} + bittensor.com/docs. The reference pages for{' '} + all 74 transactions and all 82 queries are + generated directly from the SDK, so the reference cannot drift from the released + software. Start at the documentation home. +

+
+ +
+

Built for agents

+

+ The entire stack — SDK, CLI, documentation, and this website — is designed to be + driven by AI agents as well as humans. Every operation is discoverable at runtime + with a JSON schema (btcli tools on the CLI,{' '} + sub.intents.list_tools() in Python) and can be executed by name + from a plain dictionary, validated against that schema. Every mutation can be + previewed before it spends anything, every failure returns a machine-readable code + with a remediation hint, and a Policy can hard-bound what an agent's session + is allowed to do — spend caps, fee caps, allowed subnets. +

+

+ The CLI never traps automation: --json produces machine-readable output on any + command, and a non-interactive session missing a confirmation is declined rather + than left hanging. The documentation publishes the same catalogs statically — + intents, reads, and errors as JSON — every page is fetchable as raw markdown, and + the full corpus is available at a single plain-text endpoint for loading into a + context window. The complete workflow is documented on{' '} + the agents page. +

+
+ +
+

One repository, releases on rails

+

+ The chain, SDK, CLI, documentation, and this website are now developed in a single + repository:{' '} + + + github.com/RaoFoundation/subtensor + + + . Releases are produced by an automated pipeline. Every runtime change is tested + against a live clone of mainnet state before it merges; a single + deterministic build is promoted through devnet and testnet with automated checks at + each stage; and the upgrade signed by the keyholders is cryptographically verified + against the exact bytes the pipeline produced. A new public devnet, documented in{' '} + the network overview, joins finney + and testnet as a supported environment. +

+

+ The runtime was also hardened in this release: proxy permissions are now + deny-by-default, a crowdloan reentrancy flaw was closed, and the randomness + pipeline that secures commit-reveal can no longer be stalled. +

+
+ +
+

What you need to do

+

+ Most participants require little or no action. In order of urgency: +

+
    +
  1. + Python users — uninstall bittensor-cli and bittensor-wallet, + then install the new bittensor package. Follow the{' '} + migration guide; keyfiles are + unchanged. +
  2. +
  3. + Proxy users — proxy permissions are now deny-by-default. Review + every existing proxy configuration. +
  4. +
  5. + Node operators — nodes not yet running the spec 431 binary must + upgrade to continue syncing. +
  6. +
  7. + Indexers and SDK authors — chain metadata now carries typed + currency units; verify decoders against the new{' '} + query reference. +
  8. +
  9. + Subnet owners and stakers — review the{' '} + conviction guide. Ownership of + subnets older than one year is now contestable. +
  10. +
+
+ + + Read the full documentation + + +
+
+ {connectLinks.map((link) => ( + + {link.label} + + ))} +
+
+
+
+ ); +}; + +export default page; diff --git a/website/apps/bittensor-website/src/app/sitemap.ts b/website/apps/bittensor-website/src/app/sitemap.ts index de3cee7d69..868b16975a 100644 --- a/website/apps/bittensor-website/src/app/sitemap.ts +++ b/website/apps/bittensor-website/src/app/sitemap.ts @@ -10,6 +10,8 @@ const staticRoutes = [ '/dtao-whitepaper', '/explore', '/intro', + '/releases', + '/releases/v431-upgrade', '/wallet', '/whitepaper', ];