# Subtensor QA Bug Report — May 2026

https://github.com/RaoFoundation/subtensor/pull/2643

10 confirmed correctness defects in the Subtensor / Bittensor Substrate runtime, found during a defensive-security audit. 6 high, 4 medium. Each bug has a runnable POC at L1 + L2 (Rust pallet tests) and most also at L3 (live-runtime TypeScript driver).

All source line references are valid at commit **`e6a5f56cefeb96b9c63ea3ce6553a8e1066aaeb9`** (`Merge remote-tracking branch 'origin/devnet-ready' into devnet`).

Each folder under `report/high/<bug>/` and `report/medium/<bug>/` contains everything needed to verify one bug: the raw issue file (`issue.md`) plus its POC test(s).

---

## Environment configuration

### Toolchain prerequisites
- Rust `1.89` (channel is pinned in `rust-toolchain.toml`; `rustup` will install it automatically on first `cargo` invocation).
- `wasm32v1-none` target: `rustup target add wasm32v1-none`.
- Node.js **22** for L3 TypeScript drivers.
- `pnpm` for `ts-tests/` (Moonwall + Vitest).
- `yarn` for `contract-tests/` (Mocha + ethers / papi).

### Build the node

```sh
cargo build -p node-subtensor --release --features "pow-faucet fast-runtime"
```

**The `fast-runtime` feature is required for L3 tests.** Without it, time-gated extrinsics like `HotkeySwapOnSubnetInterval=7200` cannot be exercised in a single dev session.

### Launch the dev chain

```sh
mkdir -p .local/subtensor-dev .local/logs
nohup target/release/node-subtensor \
  --dev \
  --one \
  --base-path .local/subtensor-dev \
  --rpc-port 9944 \
  --port 30333 \
  --rpc-cors=all \
  --unsafe-force-node-key-generation \
  >.local/logs/subtensor.log 2>&1 &
echo $! > .local/subtensor.pid
```

**Note:** Subtensor's `--dev` chain spec uses `//One` (not `//Alice`) as the single Aura+Grandpa authority, so `--one` is required for block authoring. `//Alice` is still the dev sudo key used by all L3 scripts.

### Health check

```sh
# Should return a result and best block should advance after ~6 seconds:
curl -s -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"system_health","params":[]}' \
  http://127.0.0.1:9944
curl -s -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"chain_getHeader","params":[]}' \
  http://127.0.0.1:9944
```

### Stop the dev chain

```sh
kill "$(cat .local/subtensor.pid)" 2>/dev/null || true
pkill -f 'node-subtensor.*--dev' 2>/dev/null || true
rm -f .local/subtensor.pid
```

### Run the POCs

**L1 + L2 (Rust pallet tests — no live node required):**

```sh
SKIP_WASM_BUILD=1 cargo test -p pallet-subtensor          --lib -- bug_1778790492849
SKIP_WASM_BUILD=1 cargo test -p pallet-subtensor-swap     --lib -- bug_1778794419957
SKIP_WASM_BUILD=1 cargo test -p subtensor-transaction-fee --lib -- bug_1778793237882
SKIP_WASM_BUILD=1 cargo test -p subtensor-chain-extension --lib -- poc_bug_1778790994000
# For tests inside pallets/subtensor/src/tests/coinbase.rs (e.g. bug-1778790768154):
SKIP_WASM_BUILD=1 cargo test -p pallet-subtensor --lib -- \
  coinbase::test_coinbase_inject_and_maybe_swap_swap_failure_strands_tao \
  coinbase::test_coinbase_run_coinbase_swap_failure_strands_tao_l2
```

`SKIP_WASM_BUILD=1` matches CI and avoids rebuilding the runtime wasm blob during each test run.

**L3 (TypeScript drivers against a live dev node):**

```sh
# Build with fast-runtime (one-time):
cargo build -p node-subtensor --release --features "pow-faucet fast-runtime"
# Start the node (see "Launch the dev chain" above) and verify health.

# Then either:

# (a) Standalone tsx scripts under ts-tests/scripts/:
cd ts-tests && pnpm install
pnpm exec tsx scripts/poc-bug-XXXX-...l3.ts

# (b) Mocha + papi suites under contract-tests/test/:
cd contract-tests && yarn install
yarn run test test/bug_XXXX.test.ts
```

The L3 scripts assume `node-subtensor --dev` is reachable at `ws://127.0.0.1:9944`. They sign with `//Alice` for sudo seeding.

### Test-layer mapping used in this report
- **L1 (unit)** — Rust pallet tests in `pallets/*/src/tests/*.rs`, using `frame_support::construct_runtime!` mock runtime + `TestExternalities`. Most isolated layer.
- **L2 (integration)** — Same framework, but drives the bug through the real signed-extrinsic dispatch chain (`ensure_signed` → fee withdrawal → `with_storage_layer` → dispatchable → state writes), with no mocks of verification code.
- **L3 (end-to-end)** — TypeScript driver talking to a live `node-subtensor --dev` over JSON-RPC/WebSocket, using either `polkadot-api`/`polkadot/api` (`ts-tests/scripts/`) or Mocha + ethers/papi (`contract-tests/test/`).

### Known L3 environment caveats
- **Parallel L3 runs share the //Alice signer.** Running multiple L3 POCs concurrently may produce `InvalidTxError: Stale` or `1012: Transaction is temporarily banned` from txpool replay-ban. Most scripts here were hardened with batched unique remarks and Stale-retry loops, but **run L3 scripts sequentially** for the cleanest experience.
- **`fast-runtime` is required for L3.** Without it, two of the L3 scripts (`bug-1778791243292`, and any other gated on `HotkeySwapOnSubnetInterval`) cannot pass in a dev session.

---

## Summary

| Severity | # | Bugs |
|---|---|---|
| High | 6 | inject-swap stranding · root-claim silent loss · terminate-lease stranded stake · withdraw-in-alpha silent failure · force-reduce-lock invariant break · dissolve burns LP positions |
| Medium | 4 | do-unlock-stake conviction undercount · chain-extension non-atomic recycle/burn · swap-hotkey-on-subnet strands locks · NetTaoFlow toggle EMA shock |

POC levels: 8 issues reach L3 (live runtime); 2 are validated at L2 only (`bug-1778790492849`, `bug-1778791243292`). L3 limitation reasons documented per-bug.

---

# HIGH

## `inject_and_maybe_swap` silently strands TAO on swap failure

- **Severity:** HIGH · **POC level:** L3
- **Full issue:** [`high/bug-1778790768154-inject-swap-failure-strands-tao/issue.md`](high/bug-1778790768154-inject-swap-failure-strands-tao/issue.md)
- **POCs:**
  - L1+L2: [`high/bug-1778790768154-inject-swap-failure-strands-tao/poc-L1L2.rs`](high/bug-1778790768154-inject-swap-failure-strands-tao/poc-L1L2.rs)
  - L3: [`high/bug-1778790768154-inject-swap-failure-strands-tao/poc-L3.ts`](high/bug-1778790768154-inject-swap-failure-strands-tao/poc-L3.ts)

`inject_and_maybe_swap` in `pallets/subtensor/src/coinbase/run_coinbase.rs:70-114` (function body; bug at line 102) deposits TAO into the subnet account via `spend_tao`, then attempts `swap_tao_for_alpha`. The swap result is consumed by `if let Ok(buy_swap_result_ok) = buy_swap_result` — the `Err` arm is silently dropped. `on_initialize` has no outer `with_storage_layer`, so the deposit persists.

**Impact:** when the swap returns `Err(ReservesTooLow)` (organically reachable any block `SubnetAlphaIn < MinimumReserve` after concentrated alpha selling), the subnet account's real chain balance grows by `tao_to_swap_with` per block while `SubnetTAO`, `TotalStake`, `SubnetExcessTao`, and `SubnetProtocolFlow` are not updated. The strand is permanent (no public extrinsic recovers TAO locked in the PalletId-derived subnet account), grows every block the precondition holds, and is invisible to `check_total_issuance` (both `balances::TotalIssuance` and `subtensor::TotalIssuance` stay in sync — only internal SubnetTAO-vs-chain-balance diverges). Downstream `SubnetProtocolFlow` EMA is permanently biased. Zero-privilege, fires automatically via `on_initialize`.

---

## `root_claim_on_subnet` swap branch silently marks claim paid on transfer failure

- **Severity:** HIGH · **POC level:** L3
- **Full issue:** [`high/bug-1778790771922-root-claim-swap-silent-loss/issue.md`](high/bug-1778790771922-root-claim-swap-silent-loss/issue.md)
- **POCs:**
  - L1+L2: [`high/bug-1778790771922-root-claim-swap-silent-loss/poc-L1L2.rs`](high/bug-1778790771922-root-claim-swap-silent-loss/poc-L1L2.rs)
  - L3: [`high/bug-1778790771922-root-claim-swap-silent-loss/poc-L3.test.ts`](high/bug-1778790771922-root-claim-swap-silent-loss/poc-L3.test.ts)

In `pallets/subtensor/src/staking/claim_root.rs:127-245` (`root_claim_on_subnet`), the swap branch (lines 191-221) uses `if let Some(..) && transfer_tao_from_subnet(..).is_ok()` to gate the root-side state updates. The `Err` case of `transfer_tao_from_subnet` is silently swallowed, but `add_stake_adjust_root_claimed_for_hotkey_and_coldkey` (lines 222-226) and the unconditional `RootClaimed +=` write inside it still fire. Earlier writes — `SubnetRootSellTao::mutate` (line 184) and `record_protocol_outflow` (line 187) — also run before the transfer attempt and are not rolled back. The skew was introduced across two commits: `440cfc2998` (2026-04-14, added the `is_ok()` conditional) and `1cc545b599` (2025-11-17, added the unconditional `add_stake_adjust_...` call) — different authors, months apart.

**Impact:** one failed transfer marks the entire round's dividends as "paid" across every subnet the hotkey earns on. `RootClaimed` is monotonic, so the user cannot re-claim. The extrinsic returns `Ok`, so there is no on-chain trail. Unprivileged, regular-user-reachable, irreversible. Observed on the live runtime: `RootClaimed[X] 0 → 10_246_293_286_000`, `RootClaimed[Y] 0 → 246_293_286_000` (cross-subnet contamination of a subnet that was never even claimed against), root stake unchanged at 10 TAO.

---

## `do_terminate_lease` strands stake under unspendable lease coldkey

- **Severity:** HIGH · **POC level:** L3
- **Full issue:** [`high/bug-1778791587349-terminate-lease-strands-coldkey-stake/issue.md`](high/bug-1778791587349-terminate-lease-strands-coldkey-stake/issue.md)
- **POCs:**
  - L1+L2: [`high/bug-1778791587349-terminate-lease-strands-coldkey-stake/poc-L1L2.rs`](high/bug-1778791587349-terminate-lease-strands-coldkey-stake/poc-L1L2.rs)
  - L3: [`high/bug-1778791587349-terminate-lease-strands-coldkey-stake/poc-L3.ts`](high/bug-1778791587349-terminate-lease-strands-coldkey-stake/poc-L3.ts)

`do_terminate_lease` in `pallets/subtensor/src/subnets/leasing.rs:197-251` transfers subnet `SubnetOwner`/`SubnetOwnerHotkey` to the beneficiary, decrements providers on `lease.coldkey`/`lease.hotkey`, clears `SubnetLeaseShares`/`AccumulatedLeaseDividends`/`SubnetLeases`, and removes the beneficiary proxy. It never touches the stake-side storages: `AlphaV2((lease.hotkey, lease.coldkey, netuid))`, `Lock((lease.coldkey, netuid, lease.hotkey))`, `HotkeyLock(netuid, lease.hotkey)`, `Owner(lease.hotkey)=lease.coldkey`, or `OwnedHotkeys`/`StakingHotkeys` on `lease.coldkey`. The lease coldkey is `blake2_256("leasing/coldkey", lease_id)` — there is no preimage, so nobody can ever sign as it. The `SubnetLeaseBeneficiary` proxy allowlist at `runtime/src/lib.rs:758-815` only includes `start_call` and `sudo_set_*` parameter-tuning calls — never `remove_stake`/`unlock_stake`/`transfer_stake`/`move_stake`, so the beneficiary never had a stake-extraction path even before the proxy is removed at termination.

**Impact:** every fixed-end lease termination permanently forfeits the cumulative owner-cut residual that has been accruing to `lease.coldkey` via `auto_lock_owner_cut`, plus all `Lock`/`HotkeyLock` state. No malicious actor — this is the intended happy path. The beneficiary loses TAO-backed alpha every time, with no recovery mechanism. The system also dec_providers a coldkey that still owns non-zero alpha, leaving the `AlphaV2` row referencing a coldkey that may now fail `system::Account` existence assumptions in unrelated paths.

---

## `withdraw_in_alpha` silently swallows `unstake_from_subnet` failure

- **Severity:** HIGH · **POC level:** L3
- **Full issue:** [`high/bug-1778793237882-withdraw-in-alpha-swallows-unstake-failure/issue.md`](high/bug-1778793237882-withdraw-in-alpha-swallows-unstake-failure/issue.md)
- **POCs:**
  - L1+L2: [`high/bug-1778793237882-withdraw-in-alpha-swallows-unstake-failure/poc-L1L2.rs`](high/bug-1778793237882-withdraw-in-alpha-swallows-unstake-failure/poc-L1L2.rs)
  - L3: [`high/bug-1778793237882-withdraw-in-alpha-swallows-unstake-failure/poc-L3.test.ts`](high/bug-1778793237882-withdraw-in-alpha-swallows-unstake-failure/poc-L3.test.ts)

In `pallets/transaction-fee/src/lib.rs:154-200` (`withdraw_in_alpha`), the function is called from the transaction-fee tx-extension's `prepare` phase. Substrate does NOT wrap `prepare` in `with_storage_layer`. The bug site is the `unstake_from_subnet` call at line 179: it is non-atomic (debits the user's alpha share, mutates the AMM bookkeeping, then attempts `transfer_tao_from_subnet` which can fail with chain-balance-below-SubnetTAO drift). When the transfer fails, the function catches `Err` and returns `(0, 0, NetUid::ROOT)`, emitting `TransactionFeePaidWithAlpha{alpha_fee:0, tao_amount:0, netuid:ROOT}`. Partial state from before the failing transfer (user alpha debited, AMM moved) is NOT rolled back.

**Impact:** the user loses alpha but the system reports zero fee charged. Off-chain indexers see a "no fee paid" event while on-chain the user's stake silently decreased. AMM-vs-chain drift (`SubnetTAO > subnet account chain balance`) widens on every alpha-fee dispatch and compounds across the 11 alpha-fee-paying extrinsics in `fees_in_alpha`. No privileges required; regular signed extrinsics like `remove_stake` trigger it once the drift precondition is reached. Observed on the live runtime: user lost 1,421,151 RAO of alpha, `TotalHotkeyAlpha` decreased, `SubnetTAO` diverged from the subnet account's chain balance by the same amount.

---

## `force_reduce_lock` over-reduces `HotkeyLock.locked_mass`

- **Severity:** HIGH · **POC level:** L3
- **Full issue:** [`high/bug-1778794321756-force-reduce-lock-overreduces-hotkeylock/issue.md`](high/bug-1778794321756-force-reduce-lock-overreduces-hotkeylock/issue.md)
- **POCs:**
  - L1+L2: [`high/bug-1778794321756-force-reduce-lock-overreduces-hotkeylock/poc-L1L2.rs`](high/bug-1778794321756-force-reduce-lock-overreduces-hotkeylock/poc-L1L2.rs)
  - L3: [`high/bug-1778794321756-force-reduce-lock-overreduces-hotkeylock/poc-L3.ts`](high/bug-1778794321756-force-reduce-lock-overreduces-hotkeylock/poc-L3.ts)

`force_reduce_lock` in `pallets/subtensor/src/staking/lock.rs:273-302` passes the raw `amount` argument to `reduce_hotkey_lock` (line 301) while the per-coldkey `Lock` only ever contributed `min(amount, rolled.locked_mass)` to the aggregate. The conviction delta (`rolled.conviction.saturating_sub(new_conviction)` at line 297) is computed correctly; the `locked_mass` delta is not. Reachable via the unprivileged path `do_remove_stake` → `clear_small_nomination_if_required` → `force_reduce_lock`. The vulnerability is amplified by a separate missing lock-hotkey-ownership check in `do_lock_stake` at `pallets/subtensor/src/staking/lock.rs:171-200` (already annotated `@qa-review: risk` in-tree), which lets any signed coldkey target an arbitrary victim hotkey by depositing 1 alpha against it.

**Impact:** any signed user can wipe any `HotkeyLock` entry at near-zero net cost (1 alpha dust + residual cleared and returned). Breaks the conservation invariant `HotkeyLock.locked_mass == Σ Lock.locked_mass` — surviving lockers' per-coldkey records remain intact while the aggregate goes to zero. `get_hotkey_conviction(victim_hk) → 0`, `get_most_convicted_hotkey_on_subnet` flips to `null`, the victim is dethroned from `subnet_king` without consent. Repeatable, permanent without manual re-lock by every surviving locker.

---

## Swap-pallet no-op LP path burns user liquidity on dissolution

- **Severity:** HIGH · **POC level:** L3
- **Full issue:** [`high/bug-1778794419957-dissolve-burns-user-lp-positions/issue.md`](high/bug-1778794419957-dissolve-burns-user-lp-positions/issue.md)
- **POCs:**
  - L1: [`high/bug-1778794419957-dissolve-burns-user-lp-positions/poc-L1.rs`](high/bug-1778794419957-dissolve-burns-user-lp-positions/poc-L1.rs)
  - L2: [`high/bug-1778794419957-dissolve-burns-user-lp-positions/poc-L2.rs`](high/bug-1778794419957-dissolve-burns-user-lp-positions/poc-L2.rs)
  - L3: [`high/bug-1778794419957-dissolve-burns-user-lp-positions/poc-L3.test.ts`](high/bug-1778794419957-dissolve-burns-user-lp-positions/poc-L3.test.ts)

Three Swap-pallet entry points are no-op stubs: `remove_liquidity` at `pallets/swap/src/pallet/mod.rs:464-472`, `modify_position` at `pallets/swap/src/pallet/mod.rs:485-494`, and `do_dissolve_all_liquidity_providers` at `pallets/swap/src/pallet/impls.rs:832-836`. `do_dissolve_network` (`pallets/subtensor/src/coinbase/root.rs:206-230`) invokes the no-op `dissolve_all_liquidity_providers`, then `destroy_alpha_in_out_stakes` (distributes only to AlphaV2 stakers), then `do_clear_protocol_liquidity` (`clear_prefix` wipes user `Positions` without refund). Commit `9cd57f222` "Address code review" (2026-04-24) gutted the prior LP refund logic (`-259/+5` lines across the two files; the previous code refunded TAO via `T::BalanceOps::increase_balance` and alpha via `T::BalanceOps::increase_stake`). The orphan migration `migrate_swapv3_to_balancer.rs` is NOT in the runtime `Migrations` tuple and references the non-existent `maybe_initialize_palswap` (would fail to compile if wired up).

**Impact:** on a normal sudo / governance `dissolve_network`, every pre-existing user LP position on that subnet is silently destroyed. No event, no error, no on-chain trail. The trigger is a normal governance op — no attacker required. Irreversible. The realised loss scales with however many pre-existing user `Positions` exist from prior runtimes; the code path is unambiguously broken regardless.

---

# MEDIUM

## `do_unlock_stake` undercounts `HotkeyLock` conviction

- **Severity:** MEDIUM · **POC level:** L2 *(L3 attempted but blocked by parallel-runtime tx-pool ban; impact fully proven at L2)*
- **Full issue:** [`medium/bug-1778790492849-do-unlock-stake-undercuts-hotkeylock-conviction/issue.md`](medium/bug-1778790492849-do-unlock-stake-undercuts-hotkeylock-conviction/issue.md)
- **POCs:**
  - L1+L2: [`medium/bug-1778790492849-do-unlock-stake-undercuts-hotkeylock-conviction/poc-L1L2.rs`](medium/bug-1778790492849-do-unlock-stake-undercuts-hotkeylock-conviction/poc-L1L2.rs)
  - L3 (not green): [`medium/bug-1778790492849-do-unlock-stake-undercuts-hotkeylock-conviction/poc-L3-notgreen.ts`](medium/bug-1778790492849-do-unlock-stake-undercuts-hotkeylock-conviction/poc-L3-notgreen.ts)

`do_unlock_stake` at `pallets/subtensor/src/staking/lock.rs:230-269` forwards the raw `amount_fixed` (the full amount cast to fixed-point) as the conviction delta to `reduce_hotkey_lock` at line 258, instead of the actual per-coldkey delta `lock.conviction.saturating_sub(new_conviction)` that was just computed at line 243. Compare to `force_reduce_lock` at `pallets/subtensor/src/staking/lock.rs:273-302` (passes `conviction_diff` at line 301) and `do_move_lock` at `pallets/subtensor/src/staking/lock.rs:529` onward (passes `existing_rolled.conviction`) — both forward the correct delta. Single-spot oversight introduced in commit `e563d9800b` "Remove locked mass decay, add unlocking" (2026-05-01). The only caller of `do_unlock_stake` is the `unlock_stake` extrinsic — permissionless, `ensure_signed` only.

**Impact:** breaks the invariant `HotkeyLock.conviction == Σ Lock(coldkey).conviction`. Two public runtime APIs are corrupted: `get_hotkey_conviction(hk, netuid)` returns under-counted convictions, and `get_most_convicted_hotkey_on_subnet` mis-attributes the subnet king. No direct theft of funds (`Lock.locked_mass`/`unlocked_mass` accounting is correct — only the conviction aggregate is wrong), but governance/king-selection visible to consensus is skewed. Infinitely repeatable per unlock-before-maturity event.

---

## Chain-extension `AddStakeRecycleV1` / `AddStakeBurnV1` are non-atomic

- **Severity:** MEDIUM · **POC level:** L3
- **Full issue:** [`medium/bug-1778790994000-chain-extension-add-stake-recycle-burn-not-atomic/issue.md`](medium/bug-1778790994000-chain-extension-add-stake-recycle-burn-not-atomic/issue.md)
- **POCs:**
  - L1+L2: [`medium/bug-1778790994000-chain-extension-add-stake-recycle-burn-not-atomic/poc-L1L2.rs`](medium/bug-1778790994000-chain-extension-add-stake-recycle-burn-not-atomic/poc-L1L2.rs)
  - L3: [`medium/bug-1778790994000-chain-extension-add-stake-recycle-burn-not-atomic/poc-L3.test.ts`](medium/bug-1778790994000-chain-extension-add-stake-recycle-burn-not-atomic/poc-L3.test.ts)

`chain-extensions/src/lib.rs:792-852` calls `do_add_stake_recycle` and `do_add_stake_burn_permissionless` (`pallets/subtensor/src/staking/recycle_alpha.rs:157-178`) from the chain-extension dispatch arms. These are `pub fn` helpers — not `#[pallet::call]` dispatchables — so FRAME's automatic `with_storage_layer` wrap does not apply. Inside each helper, `do_add_stake` runs first; if the second leg (`do_recycle_alpha` / `do_burn_alpha`) returns `Err` (e.g. `netuid=ROOT` triggers `CannotBurnOrRecycleOnRootSubnet`), the first leg's storage writes persist. Commit `d0702db5a` ("add migration part", 2026-05-04) removed the `transactional::with_transaction` block that commit `46989aaba` ("Extract add_stake+recycle/burn composition into pallet", 2026-04-17) had explicitly added for atomicity. The removal is not mentioned in the `d0702db5a` commit message.

**Impact:** a smart contract calling `AddStakeRecycleV1` or `AddStakeBurnV1` with `netuid=ROOT` loses TAO and gains root alpha despite the chain-extension returning an error (`CannotBurnOrRecycleOnRootSubnet=21`). Observed on the live runtime: 100 TAO drained from the contract on the first call, another 100 TAO on the burn-variant call; root alpha increased by 100e9 on each. The damage is contract self-harm (TAO is replaced by on-chain root-alpha owned by the same contract account, not extracted by an external attacker) and recoverable via a follow-up `remove_stake`, but it breaks the documented atomicity contract the helpers' doc-comments still claim ("without leaving residual stake if the second leg fails").

---

## `swap_hotkey_on_subnet` strands `Lock` / `HotkeyLock` entries

- **Severity:** MEDIUM · **POC level:** L2 *(L3 blocked by missing `fast-runtime` in current build — `HotkeySwapOnSubnetInterval=7200`, chain at block ~1.2k)*
- **Full issue:** [`medium/bug-1778791243292-swap-hotkey-on-subnet-strands-locks/issue.md`](medium/bug-1778791243292-swap-hotkey-on-subnet-strands-locks/issue.md)
- **POCs:**
  - L1+L2: [`medium/bug-1778791243292-swap-hotkey-on-subnet-strands-locks/poc-L1L2.rs`](medium/bug-1778791243292-swap-hotkey-on-subnet-strands-locks/poc-L1L2.rs)
  - L3 (blocked): [`medium/bug-1778791243292-swap-hotkey-on-subnet-strands-locks/poc-L3-blocked.ts`](medium/bug-1778791243292-swap-hotkey-on-subnet-strands-locks/poc-L3-blocked.ts)

`perform_hotkey_swap_on_one_subnet` (`pallets/subtensor/src/swap/swap_hotkey.rs:379` onward) migrates per-subnet alpha + voting-power + membership (UIDs, Keys, Prometheus, Axons, WeightCommits, LoadedEmission, NeuronCertificates, ChildKeys, ParentKeys) under `!keep_stake`, but never calls `swap_hotkey_locks` or otherwise touches `Lock`/`HotkeyLock` storage. The all-subnet path `perform_hotkey_swap_on_all_subnets` (`swap_hotkey.rs:195`) explicitly calls `swap_hotkey_locks` at line 208 — the asymmetry is confirmed by grep across the file.

**Impact:** after a per-subnet swap, the existing `Lock`/`HotkeyLock` entries remain pinned to the OLD hotkey. Three user-visible failures: (1) any future `do_lock_stake(NEW)` returns `LockHotkeyMismatch` — the user is wedged; (2) the `subnet_king(S)` runtime API still mis-attributes ownership to OLD, even though OLD no longer has any per-subnet membership; (3) `hotkey_conviction(NEW, S) = 0`. Recoverable only via the undocumented `move_lock` extrinsic — a user who doesn't know about it stays wedged forever.

---

## `NetTaoFlowEnabled` toggle injects accumulated flow into EMA

- **Severity:** MEDIUM · **POC level:** L3
- **Full issue:** [`medium/bug-1778792733694-net-tao-flow-toggle-protocol-ema-shock/issue.md`](medium/bug-1778792733694-net-tao-flow-toggle-protocol-ema-shock/issue.md)
- **POCs:**
  - L1+L2: [`medium/bug-1778792733694-net-tao-flow-toggle-protocol-ema-shock/poc-L1L2.rs`](medium/bug-1778792733694-net-tao-flow-toggle-protocol-ema-shock/poc-L1L2.rs)
  - L3: [`medium/bug-1778792733694-net-tao-flow-toggle-protocol-ema-shock/poc-L3.ts`](medium/bug-1778792733694-net-tao-flow-toggle-protocol-ema-shock/poc-L3.ts)

Asymmetric gating in `pallets/subtensor/src/coinbase/subnet_emissions.rs`: `record_protocol_inflow` (lines 54-58) and `record_protocol_outflow` (lines 60-64) mutate `SubnetProtocolFlow` unconditionally on every coinbase block / root-claim, while `update_ema_protocol_flow` at line 70 (the only path that drains the accumulator) is gated behind `if net_flow_enabled` inside `get_shares_flow` at lines 218-226. The in-line comment at line 221 — "Always update the protocol EMA (keeps it warm for when toggled on)" — explicitly states the intended behaviour the code below contradicts. Introduced in commit `ea465d011` (igoraxz, 2026-05-03).

**Impact:** while the flag is OFF, `SubnetProtocolFlow` accumulates indefinitely without ever being drained. When sudo flips the flag back ON, the next coinbase block writes `SubnetEmaProtocolFlow ≈ α × D × F` in one step — `>1000×` the magnitude of a normal single-block contribution. Every subnet's emission share is silently reallocated for the ~30-day EMA decay window. Root-gated trigger (admin-induced, not attacker-induced), and the damage is bounded to share distortion (no direct economic theft), but every user, validator, and miner suffers the silent reallocation during decay. Observed on the live runtime: after `D = 7200` blocks of flag-off with `F = 2 × 10^14`, the toggle wrote `SubnetEmaProtocolFlow ≈ 6.418 × 10^8`, exact match to the predicted `α × D × F`.
