# Subtensor PR #2769 — Consolidated Security Report

**Target:** opentensor/subtensor PR #2769 ("mainnet deploy June 24th, 2026"), base `fa83646` → head `0664b7e67`.

Each finding links to its raw ticket under `issues/<severity>/` and its PoC under `poc/`, both copied into this directory. The source report and original ID are noted per finding. Bug ID ranges: `bug-17818…` = RAI-1, `bug-17819…` = RAI-2.

---

## 1. Summary

| Severity | RAI-2 | RAI-1 | Total |
|----------|:-----:|:-----:|:-----:|
| 🟠 High   | 6  | 0 | **6**  |
| 🟡 Medium | 11 | 6 | **17** |
| 🟢 Low    | 8  | 0 | **8**  |
| **Total** | **25** | **6** | **31** |

All 6 Highs and all 17 Mediums carry PoCs. Of the 8 Lows, 5 are reproduced with PoCs; 3 are `status: suspected` (code-review only).

---

## 2. High

### H-01 — `execute_batched_orders`: zero-share order is collected from but paid nothing and permanently marked `Fulfilled` (signer fund loss) `[RAI-2 H-01]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N`
- **Ticket:** [issues/high/bug-1781969913468-batched-zero-share-order-fund-loss.md](issues/high/bug-1781969913468-batched-zero-share-order-fund-loss.md) · **PoC:** [poc/bug-1781969913468-batched-zero-share-order-fund-loss/](poc/bug-1781969913468-batched-zero-share-order-fund-loss/)

Every valid order has its input collected up front by `collect_assets`. Output is distributed pro-rata with integer floor division; when an order's computed share floors to `0` (`lib.rs:1084-1114` buy, `:1145-1180` sell), the loop skips the transfer but still marks the order `Fulfilled`/advances `PartiallyFilled`. The signer's full input is taken and never returned, and the slot is now terminal — the order can never be retried. Example: price 1,000 TAO/alpha, one 1,000,000-TAO buyer + one 1-TAO buyer → small buyer's share `floor(1000×1 / 1,000,001) = 0`, pays 1 TAO, receives zero alpha, permanently.

### H-02 — `migrate_cleanup_swap_v3` is never wired; dropping the `*Provided` addend from reserves shrinks every affected subnet's reserves/price discontinuously and strands liquidity `[RAI-2 H-02]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:H/SA:N`
- **Ticket:** [issues/high/bug-1781971681323-cleanup-swap-v3-migration-unwired-reserve-drop.md](issues/high/bug-1781971681323-cleanup-swap-v3-migration-unwired-reserve-drop.md) · **PoC:** [poc/bug-1781971681323-cleanup-swap-v3-migration-unwired-reserve-drop/](poc/bug-1781971681323-cleanup-swap-v3-migration-unwired-reserve-drop/)

The PR changes `reserve()` from `SubnetTAO + SubnetTaoProvided` to `SubnetTAO` alone (alpha side analogously), relying on `migrate_cleanup_swap_v3` to fold the residual `*Provided` balances in first. That migration is absent from both the pallet `on_runtime_upgrade` chain (`hooks.rs:41-181`) and the runtime `Migrations` tuple (`runtime/src/lib.rs:1683-1690`) — declared and unit-tested but never run. On every subnet with non-zero `*Provided`, the upgrade causes an instant discontinuous drop in AMM reserves/spot price and strands the residual.

### H-03 — Any account can jump pallet-drand `LastStoredRound` to skip rounds, permanently denying timelock reveals (CR-v3 weights dropped) `[RAI-2 H-03]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N`
- **Ticket:** [issues/high/bug-1781974681902-drand-laststoredround-skip-gap-denies-timelock-reveal.md](issues/high/bug-1781974681902-drand-laststoredround-skip-gap-denies-timelock-reveal.md) · **PoC:** [poc/bug-1781974681902-drand-laststoredround-skip-gap-denies-timelock-reveal/](poc/bug-1781974681902-drand-laststoredround-skip-gap-denies-timelock-reveal/)
- **Related:** H-04, H-05, M-04.

`pallet_drand::write_pulse` is unsigned, feeless, permissionless; the BLS verifier is the only gate and it runs inside dispatch (`lib.rs:359`), not in `validate_unsigned`. An attacker fetches the genuine pulse for the current real round `R` and submits one bare `write_pulse`; `LastStoredRound` jumps `L → R`. The honest OCW then fetches only from `R+1`; rounds `L+1..R-1` are never stored and can never be backfilled (strict `>` monotonicity at `lib.rs:365-367`; later inserts dropped `Stale`). Any CR-v3 commit whose `reveal_round` lands in the gap finds `Pulses::get == None`, is re-queued one epoch, then deleted — weights silently and irrecoverably dropped.

### H-04 — Systemic: CR-v3 reveal denial via a single-epoch retention sink (missing-round commit retained one `reveal_epoch`, then permanently deleted) `[RAI-2 H-04]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N`
- **Ticket:** [issues/high/bug-1781975963000-crv3-single-epoch-retention-sink-reveal-denial.md](issues/high/bug-1781975963000-crv3-single-epoch-retention-sink-reveal-denial.md) · **PoC:** [poc/bug-1781975963000-crv3-single-epoch-retention-sink-reveal-denial/](poc/bug-1781975963000-crv3-single-epoch-retention-sink-reveal-denial/)
- **Related:** H-03, H-05, M-04, L-02.

The architectural root behind the reveal-denial class: in `reveal_crv3_commits_for_subnet` (`reveal_commits.rs:43-210`), a `Pulses::get == None` pushes the commit to `unrevealed` and re-inserts it only at the current `reveal_epoch` (`:204`); next epoch the cleanup loop (`:57-62`) unconditionally deletes any `epoch < reveal_epoch`. The sink can't distinguish a transiently-future round (would succeed on retry) from a permanently-missing one, so four independent denial vectors (H-03 skip-gap, H-05 schedule-stretch, L-02 deferred-block mistag, M-04 block-fill) all converge on the same silent, traceless deletion. Fix needs multi-epoch retention or a permanent-miss gate.

### H-05 — Owner can `set_tempo(MAX_TEMPO)`/`reveal_period` to push a CR-v3 reveal past Drand pulse pruning, permanently dropping committed weights `[RAI-2 H-05]`
- **Likelihood:** possible · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N`
- **Ticket:** [issues/high/bug-1781972856243-set-tempo-pushes-crv3-reveal-past-drand-pruning.md](issues/high/bug-1781972856243-set-tempo-pushes-crv3-reveal-past-drand-pruning.md) · **PoC:** [poc/bug-1781972856243-set-tempo-pushes-crv3-reveal-past-drand-pruning/](poc/bug-1781972856243-set-tempo-pushes-crv3-reveal-past-drand-pruning/)
- **Related:** H-03, H-04.

A subnet owner enlarges `tempo` (up to `MAX_TEMPO = 50,400`) and `reveal_period` (up to 100) after a CR-v3 commit but before its `reveal_epoch`, stretching wall-clock-to-reveal past Drand's `MAX_KEPT_PULSES = 216,000` window (~7.5 days). By reveal time the `reveal_round` is pruned from `Pulses`; the commit is re-queued one epoch then deleted. The `DynamicTempoBlockedByCommitReveal` guard (`tempo_control.rs:84-88`) that blocks this on the `do_trigger_epoch` path is absent from `do_set_tempo` and `sudo_set_commit_reveal_weights_interval`. Silent weight suppression of any CR-v3 validator on the owner's subnet.

### H-06 — Partial-fill order over-filled and double-debited when re-submitted with `partial_fill = None` `[RAI-2 H-06]`
- **Likelihood:** possible · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N`
- **Ticket:** [issues/high/bug-1781969825220-partial-fill-none-overfills-order.md](issues/high/bug-1781969825220-partial-fill-none-overfills-order.md) · **PoC:** [poc/bug-1781969825220-partial-fill-none-overfills-order/](poc/bug-1781969825220-partial-fill-none-overfills-order/)

A `PartiallyFilled(n)` order is re-executed for its full `order.amount` when a relayer resubmits the same signed order with `SignedOrder.partial_fill = None`. The cumulative cap `max_fill = amount - already_filled` (`lib.rs:657-666`) runs only inside `if let Some(partial_fill)`; with `None` it is skipped, and `PartiallyFilled` passes the `Fulfilled`/`Cancelled` terminal checks, so `tao_in = partial_fill.unwrap_or(order.amount)` = full original amount. Buy order amount 1000, already-filled 600 → signer debited another 1000 (60 % over-fill); over-fill fraction `already_filled/amount` can approach 100 %. `partial_fill` is unsigned (outside the user signature), so any allow-listed relayer triggers it.

---

## 3. Medium

### M-01 — `PalletRegistryCleanupMigration` releases Registry holds via raw `AccountStore::mutate`, leaking the consumer reference (permanent account-ref corruption) `[RAI-2 M-01]`
- **Likelihood:** almost-certain · **Status:** confirmed · **CVSS:** `4.0/AV:P/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781971583459-registry-cleanup-leaks-consumer-ref.md](issues/medium/bug-1781971583459-registry-cleanup-leaks-consumer-ref.md) · **PoC:** [poc/bug-1781971583459-registry-cleanup-leaks-consumer-ref/](poc/bug-1781971583459-registry-cleanup-leaks-consumer-ref/)

Releases the dropped `Registry` hold by mutating the account ledger directly via `AccountStoreOf::<Runtime>::mutate` (`pallet_registry_cleanup_migration.rs:119-122`), bypassing `pallet_balances`' release path. The original hold called `inc_consumers`; the symmetric `dec_consumers` is never called. Every account whose sole reserved balance was the identity deposit has `reserved` driven to zero while `consumers` stays elevated — un-reapable accounts, broken "consumers>0 ⇒ live resource" invariant, across the whole on-chain-identity population (subnet owners, root hotkeys).

### M-02 — `execute_batched_orders`: a single order's fee transfer (recipient below ED) can revert the entire batch, contradicting `collect_fees`' "best-effort, does not revert" contract `[RAI-2 M-02]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781969913469-batched-fee-transfer-reverts-whole-batch.md](issues/medium/bug-1781969913469-batched-fee-transfer-reverts-whole-batch.md) · **PoC:** [poc/bug-1781969913469-batched-fee-transfer-reverts-whole-batch/](poc/bug-1781969913469-batched-fee-transfer-reverts-whole-batch/)

`collect_fees` (`lib.rs:1191`) documents itself best-effort but propagates errors with `?` (`:1213`). A TAO fee to a fresh recipient whose result would fall below `ExistentialDeposit = 500` fails, and `do_execute_batched_orders` returns that error → FRAME rolls back all orders in the batch. An order with `fee_rate`/amount producing a fee in `[1,499]` to a fresh `fee_recipient` is valid yet poisons any batch it joins; nothing in `is_order_valid` rejects the pair.

### M-03 — Staking-precompile aggregate-stake views under-charge DB-read gas for unbounded storage iteration (cheap-read DoS) `[RAI-2 M-03]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781971588872-staking-precompile-unbounded-stake-views-undercharge.md](issues/medium/bug-1781971588872-staking-precompile-unbounded-stake-views-undercharge.md) · **PoC:** [poc/bug-1781971588872-staking-precompile-unbounded-stake-views-undercharge/](poc/bug-1781971588872-staking-precompile-unbounded-stake-views-undercharge/)
- **Related:** L-04.

`getTotalHotkeyStake` (`staking.rs:310,807`), `getTotalColdkeyStake` (`:296,785`), `getTotalColdkeyStakeOnSubnet` (`:474`) record a fixed `record_db_reads(2)` but call runtime helpers that iterate all subnets (~`N×5` reads) or all hotkeys × stake-entries (unbounded). On mainnet with `N` in the hundreds, an EVM caller pays gas for 2 DB reads while the node performs thousands; a contract looping these views drives unbounded proof-size weight and crowds out extrinsics at ~zero TAO cost.

### M-04 — Bare unsigned `write_pulse` with a bogus BLS signature passes `validate_unsigned` and burns full benchmarked weight per call at zero fee → feeless block-fill / OCW crowd-out DoS `[RAI-2 M-04]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781972875000-drand-write-pulse-feeless-block-weight-grief.md](issues/medium/bug-1781972875000-drand-write-pulse-feeless-block-weight-grief.md) · **PoC:** [poc/bug-1781972875000-drand-write-pulse-feeless-block-weight-grief/](poc/bug-1781972875000-drand-write-pulse-feeless-block-weight-grief/)
- **Related:** H-03, H-04.

`validate_unsigned` authenticates only the sr25519 wrapper against the attacker-controlled `payload.public`; the BLS pairing runs inside dispatch (`lib.rs:359`). A bare extrinsic passes admission and is charged its declared weight (`4.294e9` ref_time) with zero fee; ~698 bogus calls saturate the `3e12` normal-dispatch block budget. Varying round numbers defeats per-round dedup; `NextUnsignedAt` intentionally allows multiple/block; `LastStoredRound` never advances (bogus pulses fail BLS before storage). The same (round, bogus-sig) set is indefinitely reusable.

### M-05 — `migrate_dynamic_tempo` activity-cutoff clamp shifts the consensus activity window up to ~10–12× on non-standard-tempo mainnet subnets `[RAI-2 M-05]`
- **Likelihood:** likely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VA:N/SC:N/SI:L/SA:N`
- **Ticket:** [issues/medium/bug-1781970314000-activity-cutoff-clamp-shifts-consensus-window.md](issues/medium/bug-1781970314000-activity-cutoff-clamp-shifts-consensus-window.md) · **PoC:** [poc/bug-1781970314000-activity-cutoff-clamp-shifts-consensus-window/](poc/bug-1781970314000-activity-cutoff-clamp-shifts-consensus-window/)
- **Related:** M-06.

Converts legacy absolute `ActivityCutoff` to `ActivityCutoffFactorMilli`, clamped to `[1000, 50000]`. Round-trip is exact only inside that range; PR #2769's own test documents two mainnet subnets (`tempo = 10` and `60,000`, "outside MIN/MAX bounds"). With `ActivityCutoff = 5,000`: the `tempo=10` subnet collapses 5,000→500 blocks (÷10), flipping neurons active 500–5,000 blocks ago to inactive on the migration block; the `tempo=60,000` subnet balloons to 60,000 (×12), keeping stale neurons in consensus. Both distort Yuma weights immediately on upgrade.

### M-06 — `sudo_set_activity_cutoff` / EVM `setActivityCutoff` write a storage value the live epoch ignores (silent no-op + EVM view divergence) `[RAI-2 M-06]`
- **Likelihood:** unlikely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N`
- **Ticket:** [issues/medium/bug-1781970226749-activity-cutoff-legacy-path-noop.md](issues/medium/bug-1781970226749-activity-cutoff-legacy-path-noop.md) · **PoC:** [poc/bug-1781970226749-activity-cutoff-legacy-path-noop/](poc/bug-1781970226749-activity-cutoff-legacy-path-noop/)
- **Related:** M-05.

After PR #2769 the live gate derives from `ActivityCutoffFactorMilli` (`misc.rs:605`); absolute `ActivityCutoff` is dead. The legacy `sudo_set_activity_cutoff` (call 18) and EVM `setActivityCutoff` (`subnet.rs:475-491`) remain live, emit `ActivityCutoffSet`, but write only `ActivityCutoff` — successful extrinsic/event, zero consensus change. EVM `getActivityCutoff` returns the dead value while RPC/metagraph return the factor-derived one (divergent read surfaces).

### M-07 — Limit-orders intermediary hotkey can be pre-squatted, permanently blocking pallet enablement on upgrade `[RAI-2 M-07]`
- **Likelihood:** possible · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781969874394-limit-orders-hotkey-squat-enable-dos.md](issues/medium/bug-1781969874394-limit-orders-hotkey-squat-enable-dos.md) · **PoC:** [poc/bug-1781969874394-limit-orders-hotkey-squat-enable-dos/](poc/bug-1781969874394-limit-orders-hotkey-squat-enable-dos/)

The pallet derives its intermediary hotkey from the hard-coded public `PalletId(*b"bt/lmhky")` (`runtime/src/lib.rs:1480-1485`). `set_pallet_status(true)` requires that AccountId be owned by the pallet coldkey, but `migrate_register_pallet_hotkey` claims it only `create_account_if_non_existent`. Any user pre-registers `bt/lmhky` as their own hotkey before upgrade; afterward the migration no-ops, its key is marked done (no re-run), and `set_pallet_status(true)` reverts forever with `PalletHotkeyNotRegistered`. Recovery needs a fresh migration in a later runtime upgrade.

### M-08 — Removing the same-block stake limiter enables intra-block AMM price manipulation that drains co-batch limit-order victims via fee-free netting `[RAI-2 M-08]`
- **Likelihood:** possible · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781974092516-same-block-price-manip-batched-netting.md](issues/medium/bug-1781974092516-same-block-price-manip-batched-netting.md) · **PoC:** [poc/bug-1781974092516-same-block-price-manip-batched-netting/](poc/bug-1781974092516-same-block-price-manip-batched-netting/)

`do_execute_batched_orders` reads AMM spot once into `current_price` and settles all netted buy/sell orders at that snapshot with no AMM fee/slippage. PR #2712 removed `StakingOperationRateLimiter` (which blocked same-block stake→unstake round-trips). The attacker: (1) `add_stake(M)` to inflate `current_price` `p0→p1`; (2) execute a batch as relayer + co-batch seller, receiving `total_sell_tao_equiv` at `p1` out of victim buyers' collected TAO with no fee; (3) `remove_stake(M)`. Cost ≈ the ~0.1 % round-trip fee; profitable for any sufficiently large victim volume.

### M-09 — `migrate_fix_subnet_hotkey_lock_swaps` moves `Lock` entries with raw take/insert and never updates the `LockingColdkeys` reverse index (orphan + missing index) `[RAI-2 M-09]`
- **Likelihood:** unlikely · **Status:** confirmed · **CVSS:** `4.0/AV:L/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781970252554-migration-fix-hotkey-lock-swaps-skips-locking-coldkeys-index.md](issues/medium/bug-1781970252554-migration-fix-hotkey-lock-swaps-skips-locking-coldkeys-index.md) · **PoC:** [poc/bug-1781970252554-migration-fix-hotkey-lock-swaps-skips-locking-coldkeys-index/](poc/bug-1781970252554-migration-fix-hotkey-lock-swaps-skips-locking-coldkeys-index/)

Production `Lock` writes go through `insert_lock_state` (pairs `Lock` with `LockingColdkeys` maintenance). This migration (wired at `hooks.rs:180`) mutates `Lock` directly via `take`/`insert` (`:283,293,327`) without touching `LockingColdkeys`. Since `migrate_populate_locking_coldkeys` back-filled the index first, the fix creates an orphan index entry and a missing index entry per hard-coded `HOTKEY_SWAP_LOCK_FIXES` tuple (netuids 28/97/120/128). The missing direction is harmful: `swap_hotkey_locks_for_netuids` builds its transfer list only from `LockingColdkeys::iter_prefix`, so a later swap skips the migrated lock — individual `Lock` desynced from aggregate, unstaking blocked.

### M-10 — GHSA-2026-010 cleanliness gate checks `RootClaimed` only on the ROOT prefix, so a stale non-root residual on `new_hotkey` is summed in `[RAI-2 M-10]`
- **Likelihood:** unlikely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781971674266-rootclaimed-gate-misses-nonroot-subnets.md](issues/medium/bug-1781971674266-rootclaimed-gate-misses-nonroot-subnets.md) · **PoC:** [poc/bug-1781971674266-rootclaimed-gate-misses-nonroot-subnets/](poc/bug-1781971674266-rootclaimed-gate-misses-nonroot-subnets/)

The gate (`swap_hotkey.rs:99-108`) checks only `RootClaimed::iter_prefix((NetUid::ROOT, new_hotkey)).next().is_none()`. But `RootClaimed` is keyed `(subnet, hotkey, coldkey)` with non-root earning subnets; a `new_hotkey` can carry a stale `RootClaimed[(S, new_hotkey, ck)]` while having zero root-prefix entries, passing the gate. The watermark merge `saturating_add` (`claim_root.rs:476`) then inflates `root_claimed`, under-paying future dividend claims. `sum()` is only safe if the gate guarantees zero on all non-root prefixes.

### M-11 — Coldkey swap collides with a pre-seeded `DecayingLock` on the new coldkey, flipping a decaying lock to perpetual (permanent alpha freeze) and corrupting hotkey aggregates `[RAI-2 M-11]`
- **Likelihood:** unlikely · **Status:** confirmed · **CVSS:** `4.0/AV:N/AC:H/AT:P/PR:N/UI:R/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N`
- **Ticket:** [issues/medium/bug-1781972951455-coldkey-swap-decayinglock-collision-freezes-lock.md](issues/medium/bug-1781972951455-coldkey-swap-decayinglock-collision-freezes-lock.md) · **PoC:** [poc/bug-1781972951455-coldkey-swap-decayinglock-collision-freezes-lock/](poc/bug-1781972951455-coldkey-swap-decayinglock-collision-freezes-lock/)
- **Related:** L-01, L-07.

`swap_coldkey_locks`' precondition `ensure_no_active_locks(new_coldkey)` checks only `Lock` for `locked_mass > 0` — never `DecayingLock`. A fresh `C_new` can hold `DecayingLock(C_new, N) = Some(false)` (perpetual flag) planted by `set_perpetual_lock` (call 138, `ensure_signed` only) while passing both gates. The moved decaying lock from `C_old` leaves `C_new`'s perpetual flag in place; `add_aggregate_lock` then routes its conviction into the perpetual aggregate — the lock never decays, alpha is permanently frozen, and aggregates desync from individual entries.

### M-12 — Total-stake precompile views record a fixed 2 DB reads while looping over unbounded on-chain state `[RAI-1 M-01]`
- **Likelihood:** almost-certain
- **Ticket:** [issues/medium/bug-1781883193000-precompile-total-stake-views-undercharge-db-reads.md](issues/medium/bug-1781883193000-precompile-total-stake-views-undercharge-db-reads.md) · **PoC:** [poc/bug-1781883193000-precompile-total-stake-views-undercharge-db-reads/](poc/bug-1781883193000-precompile-total-stake-views-undercharge-db-reads/)

PR #2757 added `record_db_reads` to every EVM precompile view, but the total-stake aggregation views hard-code `n = 2` while the runtime helpers loop over unbounded state. `StakingPrecompile`/`StakingPrecompileV2` `get_total_coldkey_stake` / `get_total_hotkey_stake` / `get_total_coldkey_stake_on_subnet` (`precompiles/src/staking.rs`) and `MetagraphPrecompile::get_stake` (`precompiles/src/metagraph.rs:39-49`) each record 2 while performing O(subnets) or O(hotkeys × stake-entries) reads plus a `sim_swap` and `current_alpha_price` per element. The node pays for the reads; the EVM caller is billed 2, so a contract can fan these views out in a loop and force the validator into far more storage work than the gas reflects.

### M-13 — `batch_set_weights` `Pays::No` swallows per-item failures and skips the rate-limit mirror → N free failing set_weights per block `[RAI-1 M-02]`
- **Likelihood:** likely
- **Ticket:** [issues/medium/bug-1781885649000-batch-set-weights-zero-fee-griefing-no-rate-limit.md](issues/medium/bug-1781885649000-batch-set-weights-zero-fee-griefing-no-rate-limit.md) · **PoC:** [poc/bug-1781885649000-batch-set-weights-zero-fee-griefing-no-rate-limit/](poc/bug-1781885649000-batch-set-weights-zero-fee-griefing-no-rate-limit/)

The `batch_set_weights` arm of `SubtensorTransactionExtension::validate` (`subtensor.rs:314-329`) never calls `check_weights_rate_limit` (the single-call arm does), so txpool admission is unguarded. `do_batch_set_weights` (`weights.rs:1034-1091`) collects per-item results, emits `BatchWeightItemFailed`, and unconditionally returns `Ok(())`; failing items error before `set_last_update_for_uid`, so `last_update` never advances and `check_rate_limit` stays open every block. Whole dispatch is `Pays::No` (call 80): one threshold-staked hotkey crafts every item to fail, pays zero, hits no rate limit, repeats every block — block/txpool spam at no recurring cost.

### M-14 — Alpha tx-fee path sizes the fee at a manipulable no-slippage spot price and never reconciles it → underpayment + attacker-forced full liquidation `[RAI-1 M-03]`
- **Likelihood:** likely · **Combines 3 sub-findings, same root cause/fix:**
- **Tickets:** [issues/medium/bug-1781887618000-alpha-fee-price-pump-forces-full-liquidation.md](issues/medium/bug-1781887618000-alpha-fee-price-pump-forces-full-liquidation.md) (compound) · [issues/medium/bug-1781883737045-alpha-fee-zero-equivalent-liquidates-entire-stake.md](issues/medium/bug-1781883737045-alpha-fee-zero-equivalent-liquidates-entire-stake.md) (sell-all) · [issues/medium/bug-1781883737044-alpha-fee-underpaid-via-spot-price-and-slippage.md](issues/medium/bug-1781883737044-alpha-fee-underpaid-via-spot-price-and-slippage.md) (underpayment)
- **PoCs:** [poc/bug-1781887618000-alpha-fee-price-pump-forces-full-liquidation/](poc/bug-1781887618000-alpha-fee-price-pump-forces-full-liquidation/) · [poc/bug-1781883737045-alpha-fee-zero-equivalent-liquidates-entire-stake/](poc/bug-1781883737045-alpha-fee-zero-equivalent-liquidates-entire-stake/) · [poc/bug-1781883737044-alpha-fee-underpaid-via-spot-price-and-slippage/](poc/bug-1781883737044-alpha-fee-underpaid-via-spot-price-and-slippage/)

When a fee can't be paid in TAO, it is charged in Alpha (`remove_stake`, `unstake_all`, `move_stake`, `swap_stake`, `recycle_alpha`, `burn_alpha`). The fee is sized at a manipulable no-slippage spot price and never reconciled against the realized sale (`pallets/transaction-fee/src/lib.rs`). (1) `get_alpha_amount_for_tao` = `fee/current_price` with no slippage; the real AMM swap sells at `price_limit=0` with no `tao_out >= fee` check → author always underpaid, and pumping `current_price` first underpays more. (2) When the estimate rounds to zero, the code substitutes the caller's entire subnet Alpha balance → a routine op on a high-priced subnet liquidates the whole position for a negligible fee. (3) Compound: an attacker pumps `current_price` above the per-tx fee-in-rao ahead of a victim's call, forcing the zero branch and dumping the victim's entire stake with no floor — on-demand, total, unrecoverable third-party loss. One guard fixes all three.

### M-15 — `StopLoss` `max_slippage` builds the wrong-direction floor and blocks the exit during a real crash `[RAI-1 M-04]`
- **Likelihood:** possible · **Status:** project-acknowledged limitation
- **Ticket:** [issues/medium/bug-1781882843001-stoploss-slippage-inverted-blocks-crash-exit.md](issues/medium/bug-1781882843001-stoploss-slippage-inverted-blocks-crash-exit.md) · **PoC:** [poc/bug-1781882843001-stoploss-slippage-inverted-blocks-crash-exit/](poc/bug-1781882843001-stoploss-slippage-inverted-blocks-crash-exit/)

`compute_effective_swap_limit` (`limit-orders/src/lib.rs:546-568`) treats every non-`LimitBuy` order (incl. `StopLoss`) as a sell and builds a floor `limit_price - max_slippage*limit_price` — correct for `TakeProfit`, backwards for `StopLoss`. With the trigger `current_price <= limit_price` (`:636-639`), the order can only fill in the thin band `limit_price - delta < current_price <= limit_price`. In a sharp crash, price gaps below the band, the sell gate `current_price > floor` (`order.rs:84-86`) is false, the swap reverts `PriceLimitExceeded`, and protection never fires. Medium tier carried by the batched path: `do_execute_batched_orders` takes the max of all sell floors as one netted limit, so one poisoned StopLoss reverts the whole `#[transactional]` batch.

### M-16 — Signer cannot cancel a partially-filled order; relayer retains the unilateral right to fill the remainder `[RAI-1 M-05]`
- **Likelihood:** possible
- **Ticket:** [issues/medium/bug-1781882843002-cannot-cancel-partially-filled-order.md](issues/medium/bug-1781882843002-cannot-cancel-partially-filled-order.md) · **PoC:** [poc/bug-1781882843002-cannot-cancel-partially-filled-order/](poc/bug-1781882843002-cannot-cancel-partially-filled-order/)

`cancel_order` (`lib.rs:485-506`) only allows cancellation when `Orders[order_id].is_none()`, assuming absent or terminal `Fulfilled`/`Cancelled`. Partial fills add a third present non-terminal state `PartiallyFilled(n)`; after the first partial fill `cancel_order` returns `OrderAlreadyProcessed`, so the signer cannot cancel the open remainder. Meanwhile `is_order_valid` permits further fills from `PartiallyFilled`, so the authorized relayer keeps a unilateral right to fill `amount - n` at any later moment (price/expiry permitting). The escape hatch is closed exactly when the order is half-open.

### M-17 — Leased-network dividend `ceil` rounding over-distributes the contributor cut and zeroes the beneficiary `[RAI-1 M-06]`
- **Likelihood:** possible
- **Ticket:** [issues/medium/bug-1781885705566-leased-dividends-ceil-over-distribution.md](issues/medium/bug-1781885705566-leased-dividends-ceil-over-distribution.md) · **PoC:** [poc/bug-1781885705566-leased-dividends-ceil-over-distribution/](poc/bug-1781885705566-leased-dividends-ceil-over-distribution/)

`distribute_leased_network_dividends` (`leasing.rs:261`) rounds each contributor cut up (`ceil()` at `:308`), then pays the beneficiary the saturating remainder (`:329-330`). `Σ ceil(share_i·total) ≤ total·Σ share_i + N`; the beneficiary is excluded from the shares map so they sum to <1 and the `+N` term dominates when contributors are many. On overflow `beneficiary_cut_alpha` saturates to 0 and contributors collectively over-receive, the surplus drawn from the lease coldkey's other backing stake via `transfer_stake_within_subnet` — breaking `distributed == beneficiary + Σ contributors`. Worst case (cut=100, 500 contributors) ≈ 5× over-distribution; even common-case leaks up to N units every `LeaseDividendsDistributionInterval` (100 blocks).

---

## 4. Low `[all RAI-2]`

### L-01 — Hotkey-swap lock migration overwrites (does not merge) a pre-existing `HotkeyLock`/`OwnerLock` aggregate on the new hotkey
- **Likelihood:** likely · **Status:** reproduced
- **Ticket:** [issues/low/bug-1781969934415-hotkey-swap-aggregate-overwrite.md](issues/low/bug-1781969934415-hotkey-swap-aggregate-overwrite.md) · **PoC:** [poc/bug-1781969934415-hotkey-swap-aggregate-overwrite/](poc/bug-1781969934415-hotkey-swap-aggregate-overwrite/)
- **Related:** M-11, L-07.

`swap_hotkey_locks_for_netuids` moves aggregates via `HotkeyLock::take` + `insert_hotkey_lock_state` (`lock.rs:1545,1598`), a plain insert that does not merge a pre-existing aggregate on `H_new`. If `H_new` already carries a non-zero `HotkeyLock[(netuid,H_new)]` from a delegate's lock, it is silently overwritten — pre-existing lockers' conviction disappears, moved lockers' is double-represented, breaking `aggregate == Σ individual` and mis-pricing `hotkey_conviction`/`subnet_king`.

### L-02 — Commit submitted on a per-block-cap-deferred epoch block is tagged one epoch ahead, shifting its reveal window
- **Likelihood:** likely · **Status:** reproduced
- **Ticket:** [issues/low/bug-1781970014249-deferred-block-commit-epoch-mistag.md](issues/low/bug-1781970014249-deferred-block-commit-epoch-mistag.md) · **PoC:** [poc/bug-1781970014249-deferred-block-commit-epoch-mistag/](poc/bug-1781970014249-deferred-block-commit-epoch-mistag/)
- **Related:** H-04.

`current_epoch_with_lookahead` (`weights.rs:1275`) returns `SubnetEpochIndex + (1 if should_run_epoch)`. On a block where the epoch was deferred by `MaxEpochsPerBlock = 2`, `SubnetEpochIndex` is not bumped but `should_run_epoch` is still true, so a commit is tagged `E+1` — one epoch too high — shifting its valid reveal window one full epoch later. Tooling revealing at the canonical offset hits `RevealTooEarly`/`CommitBlockNotInRevealRange` and, if it doesn't retry a full epoch late, forfeits incentives. Affects `do_commit_weights`, `internal_commit_timelocked_weights`, and reveal-side validators.

### L-03 — `migrate_swapv3_to_balancer` fallback to `Balancer::default()` re-prices a subnet from its v3 price to `y/x` (orders-of-magnitude jump), enabling first-swap arbitrage
- **Likelihood:** possible · **Status:** reproduced
- **Ticket:** [issues/low/bug-1781971694188-swapv3-balancer-fallback-reprices-subnet.md](issues/low/bug-1781971694188-swapv3-balancer-fallback-reprices-subnet.md) · **PoC:** [poc/bug-1781971694188-swapv3-balancer-fallback-reprices-subnet/](poc/bug-1781971694188-swapv3-balancer-fallback-reprices-subnet/)

Seeds each subnet's `SwapBalancer` from its v3 price; when the implied quote weight falls outside `[0.01, 0.99]` it inserts `Balancer::default()` (0.5/0.5), re-pricing to the raw reserve ratio. The fallback fires precisely when the two diverge, so the gap can be many orders of magnitude (project test: `SubnetTAO=1, SubnetAlphaIn=1e12`, v3 price 1.0 → spot 1e-12). Post-upgrade the mismatch is free permissionless arbitrage. The L2 PoC confirmed the precondition intersection is not present on the current production upgrade path, limiting this to reproduced (not confirmed).

### L-04 — Subnet precompile `getActivityCutoffFactor` reads storage but records zero DB reads (gas under-charge)
- **Likelihood:** n/a · **Status:** suspected (no PoC)
- **Ticket:** [issues/low/bug-1781971588873-get-activity-cutoff-factor-records-no-db-read.md](issues/low/bug-1781971588873-get-activity-cutoff-factor-records-no-db-read.md)
- **Related:** M-03.

`getActivityCutoffFactor(uint16)` (`subnet.rs:494-500`) reads `ActivityCutoffFactorMilli::get` but takes its `handle` as `_` and records zero DB reads, violating the PR #2757 invariant (every sibling single-read view calls `record_db_reads(1)`). A fixed 1-read under-charge, not a DoS.

### L-05 — `inject_and_maybe_swap` mints/injects alpha and updates Balancer weights unconditionally, but the matching TAO injection is conditional — on `spend_tao` failure the pool gains un-backed alpha and corrupted weights
- **Likelihood:** possible · **Status:** reproduced
- **Ticket:** [issues/low/bug-1781971807934-inject-alpha-minted-without-tao-backing-on-spend-failure.md](issues/low/bug-1781971807934-inject-alpha-minted-without-tao-backing-on-spend-failure.md) · **PoC:** [poc/bug-1781971807934-inject-alpha-minted-without-tao-backing-on-spend-failure/](poc/bug-1781971807934-inject-alpha-minted-without-tao-backing-on-spend-failure/)

`adjust_protocol_liquidity` updates `SwapBalancer` weights for `(tao_in_i, alpha_in_i)` and then alpha is minted into `SubnetAlphaIn` unconditionally (`run_coinbase.rs:133-137`). The TAO half is gated by `spend_tao` (`:140-165`), which fails when `remaining_credit < actual_injected_tao`. On failure, `SubnetAlphaIn` grows with no `SubnetTAO` increase while weights were already set for both — un-backed minted alpha + mis-calibrated weights, mis-pricing every subsequent swap. The `log::error!` at `:157-163` shows authors anticipated credit exhaustion.

### L-06 — Batched limit-orders: a permissionless co-staker on `pallet_hotkey` makes the intermediary's stake-decrease silently no-op (share-pool read-back floor), so `net_pool_swap` pays out TAO with no alpha debit — unbacked TAO mint
- **Likelihood:** possible · **Status:** reproduced
- **Ticket:** [issues/low/bug-1781974210381-pallet-hotkey-readback-floor-unstake-noop-mints-tao.md](issues/low/bug-1781974210381-pallet-hotkey-readback-floor-unstake-noop-mints-tao.md) · **PoC:** [poc/bug-1781974210381-pallet-hotkey-readback-floor-unstake-noop-mints-tao/](poc/bug-1781974210381-pallet-hotkey-readback-floor-unstake-noop-mints-tao/)

A discharged hypothesis assumed `(pallet_acct, pallet_hotkey)` is the sole coldkey in the share pool. But `bt/lmhky` isn't reserved from staking — anyone becomes a co-staker via `add_stake(pallet_hotkey, …)` or a buy order with `order.hotkey == pallet_hotkey`. Then `try_get_value(pallet_acct)` can floor below the deposit (±1 rao). In a sell-dominant batch where the read-back floors below `net_alpha`, `decrease_stake_for_hotkey_and_coldkey_on_subnet` no-ops while `swap_alpha_for_tao` swaps the full `net_alpha` — sellers receive real TAO with no alpha burned (dilution of `SubnetAlphaIn`, drain of `SubnetTAO`).

### L-07 — Coldkey swap silently overwrites (does not merge) the destination's pre-existing conviction-only `Lock` when both coldkeys lock the same hotkey/subnet
- **Likelihood:** n/a · **Status:** suspected (no PoC)
- **Ticket:** [issues/low/bug-1781974198117-coldkey-swap-lock-overwrite-same-hotkey.md](issues/low/bug-1781974198117-coldkey-swap-lock-overwrite-same-hotkey.md)
- **Related:** M-11, L-01.

`swap_coldkey_locks` moves each `Lock` via `insert_lock_state` (`lock.rs:1417`), a plain insert. `ensure_no_active_locks(new_coldkey)` rejects only `locked_mass > 0`; a conviction-only lock `{0, C}` passes. When both coldkeys hold a lock on the same `(N, H)`, the transfer overwrites `new_coldkey`'s `{0, C_new}`, destroying the user's own conviction; the aggregate is asymmetric (`C_new` never removed), over-stating conviction with no backing individual lock.

### L-08 — `apply_roll_delta_to_aggregate` bumps the shared aggregate `last_update` to now without decaying co-lockers' shares, breaking `aggregate == Σ individual locks` for multi-locker hotkeys
- **Likelihood:** n/a · **Status:** suspected (no PoC)
- **Ticket:** [issues/low/bug-1781970252639-apply-roll-delta-freezes-colockers-aggregate-decay.md](issues/low/bug-1781970252639-apply-roll-delta-freezes-colockers-aggregate-decay.md)
- **Related:** L-01, L-07, M-11.

`ConvictionModel::roll_forward`'s `apply_roll_delta_to_aggregate` (`lock.rs:245-254`) subtracts the triggering coldkey's roll-forward decay from the shared aggregate and sets `aggregate.last_update = now` without first decaying other co-lockers' contributions from the old `last_update`. Every later decay starts from `now`, permanently freezing the `[old_last_update, now]` window for other coldkeys. Reachable by any co-locker via `add_stake`/`remove_stake`. The only consumer (`change_subnet_owner_if_needed`) is currently commented out (`run_coinbase.rs:416`), limiting immediate impact.

---

## 5. Directory layout

```
report-final/
├── report.md                 ← this file
├── issues/
│   ├── high/      6 tickets   (RAI-2)
│   ├── medium/    19 tickets  (RAI-2 ×11 + RAI-1 ×8; M-14 bundles 3 tickets)
│   └── low/       8 tickets   (RAI-2)
└── poc/           30 PoC dirs (RAI-2 ×22 + RAI-1 ×8)
```

Source/ID per finding is tagged inline. Bug ID ranges: `bug-17818…` = RAI-1, `bug-17819…` = RAI-2.
