# Subtensor (Bittensor) Audit Report

Findings sorted by severity, then by likelihood. Each entry links to its full report and to its proof-of-concept tests.

---

## High

### H-01. ActivityCutoff deprecation operator-deception compound: migration silently shifts consensus windows, legacy correction surfaces are dead no-ops, and no truthful+reachable path exists to detect or fix the misconfiguration

- **Likelihood:** possible
- **Combines:** 7 findings — same root cause (incomplete ActivityCutoff→ActivityCutoffFactorMilli deprecation), same fix surface (migration event + setter rewire + RPC type versioning)
- **Filed as:** 1 High (compound) + 3 Medium (components) + 2 Low (components)
- **Full reports:**
  - [issues/high/bug-1781103171000-activity-cutoff-clamp-no-operator-correction-path.md](issues/high/bug-1781103171000-activity-cutoff-clamp-no-operator-correction-path.md) — compound: convergent operator-deception finding
  - [issues/medium/bug-1781099836984-dynamic-tempo-cutoff-clamp-silent-consensus-shift.md](issues/medium/bug-1781099836984-dynamic-tempo-cutoff-clamp-silent-consensus-shift.md) — `migrate_dynamic_tempo` silent clamp shifts active-neuron set
  - [issues/medium/bug-1781100986001-subnet-hyperparams-activity-cutoff-u16-to-u64-wire-break.md](issues/medium/bug-1781100986001-subnet-hyperparams-activity-cutoff-u16-to-u64-wire-break.md) — `SubnetHyperparams`/`V2` RPC `Compact<u16>→u64` wire break
  - [issues/medium/bug-1781102156508-metagraph-rpc-activity-cutoff-u16-to-u64-wire-break.md](issues/medium/bug-1781102156508-metagraph-rpc-activity-cutoff-u16-to-u64-wire-break.md) — `Metagraph` RPC `Compact<u16>→u64` wire break
  - [issues/medium/bug-1781102156509-selective-metagraph-activity-cutoff-u16-to-u64-wire-break.md](issues/medium/bug-1781102156509-selective-metagraph-activity-cutoff-u16-to-u64-wire-break.md) — `SelectiveMetagraph` RPC `Option<Compact<u16>>→u64` wire break
  - [issues/low/bug-1781099745209-set-activity-cutoff-silent-noop-after-factor-migration.md](issues/low/bug-1781099745209-set-activity-cutoff-silent-noop-after-factor-migration.md) — `sudo_set_activity_cutoff` / EVM `setActivityCutoff` write dead storage (silent no-op)
  - [issues/low/bug-1781102156510-evm-get-activity-cutoff-returns-dead-stale-storage.md](issues/low/bug-1781102156510-evm-get-activity-cutoff-returns-dead-stale-storage.md) — EVM `getActivityCutoff` reads permanently stale dead storage
- **PoC:** [poc/bug-1781103171000-activity-cutoff-clamp-no-operator-correction-path/](poc/bug-1781103171000-activity-cutoff-clamp-no-operator-correction-path/), [poc/bug-1781099836984-dynamic-tempo-cutoff-clamp-silent-consensus-shift/](poc/bug-1781099836984-dynamic-tempo-cutoff-clamp-silent-consensus-shift/), [poc/bug-1781100986001-subnet-hyperparams-activity-cutoff-u16-to-u64-wire-break/](poc/bug-1781100986001-subnet-hyperparams-activity-cutoff-u16-to-u64-wire-break/), [poc/bug-1781102156508-metagraph-rpc-activity-cutoff-u16-to-u64-wire-break/](poc/bug-1781102156508-metagraph-rpc-activity-cutoff-u16-to-u64-wire-break/), [poc/bug-1781102156509-selective-metagraph-activity-cutoff-u16-to-u64-wire-break/](poc/bug-1781102156509-selective-metagraph-activity-cutoff-u16-to-u64-wire-break/)

The dynamic-tempo migration incompletely replaced the absolute `ActivityCutoff` storage with the derived `ActivityCutoffFactorMilli` model across four distinct surfaces, and their failures converge to remove every truthful, reachable path an operator has to detect or correct a silently-shifted consensus window. `migrate_dynamic_tempo.rs:112-118` clamps the converted factor to `[1000, 50000]` with no per-subnet on-chain event; for a subnet with `tempo=60_000` and `cutoff=5_000` this widens the effective window ~12x on the upgrade block. The operator cannot learn this happened because the EVM view `getActivityCutoff` (`subnet.rs:454`) reports the forever-frozen pre-migration `ActivityCutoff` storage, while `sudo_set_activity_cutoff` (`admin-utils/src/lib.rs:619`) and the EVM `setActivityCutoff` precompile (`subnet.rs:463`) write only that same dead storage — both dispatch `Ok()` plus an `ActivityCutoffSet` event while `get_activity_cutoff_blocks` and thus Yuma Consensus are entirely unaffected. All three RPC struct families (`SubnetHyperparams`/`V2` at `subnet_info.rs:70,338`, `Metagraph` at `metagraph.rs:57,713`, `SelectiveMetagraph` at `metagraph.rs:157,1054`) now emit the derived value as `Compact<u64>` where the old layout used `Compact<u16>`, desyncing the SCALE cursor for every subsequent field in already-deployed SDK consumers that pin the old type registry. The sole live correction path is `set_activity_cutoff_factor`, which is undiscoverable to an operator who received no per-subnet signal, reads a stale EVM value, and reaches for the identically-named legacy setter — at each step the visible surface confirms a change that consensus ignores. The active/inactive neuron mask driving Yuma Consensus weight, bond, and emission computation is wrong relative to operator intent with no truthful feedback.

---

## Medium

### M-01. Epoch safety-net (`BlocksSinceLastStep > MAX_TEMPO`) cannot force an epoch — per-block cap defers it unconditionally, permanently starving a subnet's emission distribution

- **Likelihood:** possible
- **Full report:** [issues/medium/bug-1781099761408-safety-net-starved-by-per-block-epoch-cap.md](issues/medium/bug-1781099761408-safety-net-starved-by-per-block-epoch-cap.md)
- **PoC:** [poc/bug-1781099761408-safety-net-starved-by-per-block-epoch-cap/](poc/bug-1781099761408-safety-net-starved-by-per-block-epoch-cap/)

`drain_pending` (`run_coinbase.rs:356-406`) evaluates `should_run_epoch` first (line 360), which correctly returns `true` via the safety-net path (`BlocksSinceLastStep > MAX_TEMPO`, line 1075) for a long-overdue subnet, but the per-block cap check at line 365 runs unconditionally afterward and `continue`s without consulting the trigger reason. There is no safety-net bypass of `epochs_run_this_block >= T::MaxEpochsPerBlock::get()`. In production `MaxEpochsPerBlock = 2`; only three simultaneously-due subnets are needed for the victim to lose its cap slot in every block of the ascending-netuid iteration over `get_all_subnet_netuids`. When this collision persists, `LastEpochBlock` is never advanced (line 423 skipped), `PendingEpochAt` cascades forward by one block each round (`b → b+1 → b+2`), and `PendingServerEmission`/`PendingValidatorEmission`/`PendingRootAlphaDivs`/`PendingOwnerCut` accumulate indefinitely without draining. The scheduler invariant "BlocksSinceLastStep > MAX_TEMPO forces an epoch regardless" is architecturally broken. The new exact-period scheduler (period = `tempo`, not the legacy `tempo+1`) removes the netuid-offset spreading that previously reduced simultaneous-due collisions, and owner `set_tempo`/`trigger_epoch` re-anchoring further concentrates due-blocks, making persistent starvation more reachable at mainnet scale than under the old formula.

---

### M-02. `trigger_epoch` / `set_tempo` lack the commit-reveal lockout guard and desync CRv3 reveal windows, silently dropping committed weights

- **Likelihood:** possible
- **Full report:** [issues/medium/bug-1781099808854-trigger-epoch-no-commit-reveal-guard-desyncs-reveal-window.md](issues/medium/bug-1781099808854-trigger-epoch-no-commit-reveal-guard-desyncs-reveal-window.md)
- **PoC:** [poc/bug-1781099808854-trigger-epoch-no-commit-reveal-guard-desyncs-reveal-window/](poc/bug-1781099808854-trigger-epoch-no-commit-reveal-guard-desyncs-reveal-window/)

`do_set_tempo` (`tempo_control.rs:12`) and `do_trigger_epoch` (`tempo_control.rs:76`) perform no `get_commit_reveal_weights_enabled` check — the `DynamicTempoBlockedByCommitReveal` guard was deliberately removed (commit `1fc3e8824`) on the assumption that the stateful `SubnetEpochIndex` counter makes tempo changes safe for CRv3. That assumption is wrong because CRv3 reveal is dual-coupled: a commit at epoch `E` (`TimelockedWeightCommits[netuid_index][E]`, `weights.rs:346`) is revealed only at the single block where the epoch counter reaches `E + reveal_period` (`reveal_commits.rs:43-46`), but decryption also requires the Drand pulse for the committer's chosen `reveal_round` (`reveal_commits.rs:74`), which was fixed at commit time to coincide with the expected wall-clock arrival of that epoch. A `trigger_epoch` fires an out-of-band epoch and increments `SubnetEpochIndex` by an extra step (`run_coinbase.rs:425`); a `set_tempo` shortening resets `LastEpochBlock` and compresses the per-epoch block period. Either operation causes the epoch counter to reach `E + reveal_period` before the Drand pulse is on-chain. The reveal attempt re-queues at epoch `E`, but as soon as the next epoch fires the cleanup loop (`reveal_commits.rs:54-58`) finds `epoch E < reveal_epoch E+1` and silently deletes the commit unrevealed — the validator's weights for that round are permanently lost, never reaching `do_set_mechanism_weights`, with no event or error surfaced to the validator.

---

### M-03. `sudo_set_tempo` has no `[MIN_TEMPO, MAX_TEMPO]` bound; `tempo=0` permanently freezes a subnet's epochs, bypassing the safety net

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1781099745208-root-set-tempo-zero-bricks-subnet-epochs.md](issues/medium/bug-1781099745208-root-set-tempo-zero-bricks-subnet-epochs.md)
- **PoC:** [poc/bug-1781099745208-root-set-tempo-zero-bricks-subnet-epochs/](poc/bug-1781099745208-root-set-tempo-zero-bricks-subnet-epochs/)

`sudo_set_tempo` (`pallets/admin-utils/src/lib.rs:982`) calls `apply_tempo_with_cycle_reset(netuid, tempo)` with no `[MIN_TEMPO, MAX_TEMPO]` bounds check, unlike the owner-side `do_set_tempo` (`tempo_control.rs:15-18`) which enforces the same range and rejects `tempo=0` with `TempoOutOfBounds`. Under the new stateful dynamic-tempo scheduler, `should_run_epoch` (`run_coinbase.rs:1067-1070`) early-returns `false` when `tempo == 0` before reaching the `BlocksSinceLastStep > MAX_TEMPO` safety-net at line 1075, permanently bypassing it. No epoch ever runs for that subnet: `PendingServerEmission`/`PendingValidatorEmission`/`PendingRootAlphaDivs`/`PendingOwnerCut` accumulate without drainage, `SubnetEpochIndex` is frozen (stalling commit-reveal v2/v3 which key on it), `is_in_admin_freeze_window` silently returns `false`, and `get_next_epoch_start_block` returns `None`. Recovery requires a second root `sudo_set_tempo` with a valid tempo; without that, the subnet is indefinitely bricked. Under the legacy modulo scheduler `tempo=0` was benign (period 1, epoch every block), making this a regression introduced by the dynamic-tempo feature. The asymmetric validation between the owner path and the root override path on a live ~$4B market cap network means a single fat-fingered privileged call produces an irreversible-until-corrected consensus stall for the affected subnet.

---

## Low

### L-01. `sudo_set_activity_cutoff` / EVM `setActivityCutoff` still live but ignored — write deprecated `ActivityCutoff` storage that `get_activity_cutoff_blocks` no longer reads

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781099745209-set-activity-cutoff-silent-noop-after-factor-migration.md](issues/low/bug-1781099745209-set-activity-cutoff-silent-noop-after-factor-migration.md)
- **PoC:** none
- **Related:** bug-1781102156510, bug-1781103171000

`sudo_set_activity_cutoff` (call index 18, `admin-utils/src/lib.rs:619`) is still owner-or-root callable, validates against `MinActivityCutoff`, writes `ActivityCutoff[netuid]`, and emits `ActivityCutoffSet` — a complete success response with no indication of failure. The EVM precompile `setActivityCutoff(uint16,uint16)` (`subnet.rs:460`) dispatches the same call as `Signed(caller)`. Both write to dead storage: `get_activity_cutoff_blocks` (`utils/misc.rs:608`) derives the consensus-used window exclusively from `ActivityCutoffFactorMilli x Tempo / 1000` and never reads `ActivityCutoff`. An owner calling this still-documented, still-exposed surface gets a successful dispatch and a changed `getActivityCutoff` view but achieves zero effect on the activity window used by `epoch()` (`run_epoch.rs:172`). The two EVM precompile views now report contradictory values — `getActivityCutoff` (dead, settable) and `getActivityCutoffFactor` (live) — with no signal indicating which is authoritative.

---

### L-02. `get_next_epoch_start_block` returns a block in the past during per-block-cap deferral, mis-timing off-chain weight commit/reveal tooling

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781099761409-next-epoch-rpc-returns-past-block-during-deferral.md](issues/low/bug-1781099761409-next-epoch-rpc-returns-past-block-during-deferral.md)
- **PoC:** none
- **Related:** bug-1781099761408

`get_next_epoch_start_block` (`run_coinbase.rs:1105`) returns `min(LastEpochBlock + tempo, PendingEpochAt)`. When `drain_pending` defers a subnet due to the per-block cap (lines 365-373), it sets `PendingEpochAt := current_block + 1` without advancing `LastEpochBlock`. At that point `LastEpochBlock + tempo <= current_block` already holds, so `auto_next` is a block at or before the current block, and `min(auto_next, PendingEpochAt) = auto_next` — a value in the past, not an approximation of a future boundary. Off-chain tooling polling this RPC to schedule commit/reveal submissions will treat the current epoch window as already closed and may skip a commit or reveal that is still actionable. For commit-reveal subnets this can cause a validator to miss the reveal window for an epoch, dropping its weights from consensus. The API's own doc-comment hedges that it ignores the per-block cap but understates the failure mode: it returns a demonstrably wrong (past) value, not merely an imprecise future estimate.

---

### L-03. Stale `PendingEpochAt` survives `tempo→0` / cycle-reset and fires a spurious epoch on re-enable

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781099820615-stale-pending-epoch-survives-tempo-zero-cycle-reset.md](issues/low/bug-1781099820615-stale-pending-epoch-survives-tempo-zero-cycle-reset.md)
- **PoC:** none
- **Related:** bug-1781099745208

`PendingEpochAt[netuid]` is only cleared inside `drain_pending`'s slot-consumed tail (`run_coinbase.rs:424`), which is only reached when `should_run_epoch` returns `true`. When `tempo` is set to `0` before a deferred epoch fires, `should_run_epoch` early-returns `false` (`run_coinbase.rs:1068-1070`) on every subsequent block and the clear is never reached — the deferred marker is stranded. Separately, `apply_tempo_with_cycle_reset` (`utils/misc.rs:121-125`) resets `LastEpochBlock` but does not touch `PendingEpochAt`, leaving an incomplete cycle-reset. When `tempo` is later restored to a non-zero value, `should_run_epoch` finds `pending <= current_block` and immediately fires an out-of-schedule epoch ahead of the freshly reset `LastEpochBlock + tempo` cadence. `is_in_admin_freeze_window` and `get_next_epoch_start_block` also read the stale cell during the bricked interval, producing transiently wrong freeze-window evaluations and past-block RPC responses. Impact is bounded: a single premature epoch executes and `SubnetEpochIndex` advances exactly once; no emissions are double-counted. Reachability collapses to a residual edge case if the `MIN_TEMPO` bound fix recommended by bug-1781099745208 is applied.

---

### L-04. Reveal pre-dispatch validation scans the entire `WeightCommits` map at zero mempool weight — unmetered per-node validation DoS, newly inherited by `reveal_mechanism_weights`

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781100986000-reveal-predispatch-full-weightcommits-scan-dos.md](issues/low/bug-1781100986000-reveal-predispatch-full-weightcommits-scan-dos.md)
- **PoC:** none

`SubtensorTransactionExtension::validate` invokes `find_commit_epoch_via_hash` for the `reveal_weights`, `reveal_mechanism_weights`, and `batch_reveal_weights` arms (`extensions/subtensor.rs:161, 191, 233`). That function (`weights.rs:1351`) performs a full `WeightCommits::<T>::iter()` scan — O(N_subnets x N_users x 10 queue entries) storage reads and hash comparisons — for every transaction-pool validation event on every node, charging no fee on rejection since the failure occurs in the signed extension `validate` path before block inclusion. The corresponding dispatch path (`weights.rs:478, 641`) does an O(1) `try_mutate_exists` lookup. The `reveal_mechanism_weights` arm is brand-new in this PR and not present on `main`, extending the unmetered scan to an additional call. An account meeting the per-subnet weights stake threshold can flood the mempool with random-hash reveal transactions at zero on-chain cost, each triggering a full map scan then being rejected, degrading validation throughput on every node as the `WeightCommits` population grows. The `@qa-review: risk` annotation at `extensions/subtensor.rs:103` predates this rewrite; the O(N) scan body is byte-for-byte unchanged by the migration, now with one more entry point.

---

### L-05. EVM `getActivityCutoff` precompile reports a permanently stale value — reads dead `ActivityCutoff` storage that no live path updates

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781102156510-evm-get-activity-cutoff-returns-dead-stale-storage.md](issues/low/bug-1781102156510-evm-get-activity-cutoff-returns-dead-stale-storage.md)
- **PoC:** none
- **Related:** bug-1781099745209, bug-1781103171000

`getActivityCutoff(uint16)` (`precompiles/src/subnet.rs:452`) returns `ActivityCutoff::<R>::get(netuid)` — raw legacy storage that no live path updates post-migration. For a migrated subnet the returned value is the pre-migration absolute cutoff, frozen forever; for a new subnet it is the static `InitialActivityCutoff` default. `run_epoch.rs:173` and `:605` call `get_activity_cutoff_blocks` (`utils/misc.rs:614`), which derives the consensus window exclusively from `ActivityCutoffFactorMilli x Tempo / 1000` and never reads `ActivityCutoff`. After any `setActivityCutoffFactor` call (the live tuning path), `getActivityCutoff` and the actual consensus window diverge permanently and silently. The only EVM read that reflects reality is `getActivityCutoffFactor` (`subnet.rs:478`), but a consumer must additionally fetch `tempo` and compute `factor x tempo / 1000` to obtain the effective block window. An EVM contract or dapp that reads `getActivityCutoff` to determine the active-neuron window acts on a stale number invisible to consensus.
