# Subtensor Audit Report

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

---

## Critical

### C-01. COMPOUND: one unprivileged swap_hotkey(Some(S1), new=H_victim) hijacks a victim's validator hotkey — seizes Owner, destroys conviction/subnet-king locks, and wipes the delegation graph + childkey config in a single signed extrinsic

- **Likelihood:** likely
- **Full report:** [issues/critical/bug-1781215670000-compound-swap-hotkey-owner-hijack-takeover.md](issues/critical/bug-1781215670000-compound-swap-hotkey-owner-hijack-takeover.md)
- **PoC:** [poc/bug-1781215670000-compound-swap-hotkey-owner-hijack-takeover/](poc/bug-1781215670000-compound-swap-hotkey-owner-hijack-takeover/)
- **Related:** bug-1781214086002, bug-1781214096254, bug-1781214096255, bug-1781214151380, bug-1781214210714, bug-1781214103000

The `Some(netuid)` registration guard in `do_swap_hotkey` (`swap_hotkey.rs:83`) is `!is_hotkey_registered_on_specific_network(new_hotkey, S1)`, which passes when the victim hotkey is a member of any other subnet `S2 != S1`. Unlike the `None` path's `!is_hotkey_registered_on_any_network`, there is no ownership check on `new_hotkey`, so any unprivileged attacker `A` who owns a throwaway hotkey `H_old` on a non-root subnet `S1` can call `swap_hotkey(H_old, H_victim, Some(S1))` where `H_victim` is a live validator hotkey owned by a different coldkey `B` on subnet `S2`. A single atomic extrinsic then executes four overwrites against the victim: (1) `Owner::insert(H_victim, A)` at `swap_hotkey.rs:372` transfers unconditional ownership; (2) the aggregate lock buckets at `lock.rs:1598` are overwritten, destroying the victim's conviction used to defend the subnet crown; (3) `parent_child_swap_hotkey` at `set_children.rs:393` wipes `H_victim`'s existing `ChildKeys`/`ParentKeys`/`PendingChildKeys`; (4) `ChildkeyTake(H_victim, S1)` is never migrated, collapsing to the minimum. After takeover, the attacker calls `do_set_childkey_take(attacker, H_victim, S2, max)` successfully while the victim receives `NonAssociatedColdKey` on its own key, proven end-to-end at L2 and L3 (7/7 live-node tests). Attack cost: one registration plus `KeySwapOnSubnetCost` plus one-day interval wait. The fix (PR #4, commit 1112fe9eb) is NOT on the audited branch `devnet-ready-06-12`.

---

## High

### H-01. Single-subnet hotkey swap hijacks Owner of a hotkey already owned by another coldkey (narrower registration check than all-subnets path)

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1781214086002-subnet-swap-hotkey-owner-hijack.md](issues/high/bug-1781214086002-subnet-swap-hotkey-owner-hijack.md)
- **PoC:** [poc/bug-1781214086002-subnet-swap-hotkey-owner-hijack/](poc/bug-1781214086002-subnet-swap-hotkey-owner-hijack/)
- **Related:** bug-1781215670000

The `Some(netuid)` branch of `do_swap_hotkey` uses `is_hotkey_registered_on_specific_network(new_hotkey, S1)` as its only destination guard, while the `None` branch uses the stronger `is_hotkey_registered_on_any_network`. An attacker who owns a hotkey on subnet `S1` can specify any victim hotkey registered on a different subnet `S2` as `new_hotkey`; the guard passes and `Owner::insert(new_hotkey, attacker)` at `swap_hotkey.rs:374` overwrites the victim's ownership record unconditionally. After the hijack, `coldkey_owns_hotkey(victim, H_victim)` returns false while `coldkey_owns_hotkey(attacker, H_victim)` returns true, locking the victim out of all ownership-gated operations (`do_increase_take`, `do_set_childkey_take`, future swaps). Proven at L1, L2 (3/3 each), and L3 (6/6 live-node tests); invariant 8.2 (`OwnedHotkeys` stale entry) confirmed. This is the constituent entry-guard omission that the compound finding (C-01) weaponizes for cross-account takeover.

---

### H-02. swap_hotkey_v2(keep_stake=true, netuid=None) removes Owner(old_hotkey) while leaving stake on it, permanently freezing the retained stake

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1781214331488-keepstake-allsubnets-strands-old-hotkey-stake.md](issues/high/bug-1781214331488-keepstake-allsubnets-strands-old-hotkey-stake.md)
- **PoC:** [poc/bug-1781214331488-keepstake-allsubnets-strands-old-hotkey-stake/](poc/bug-1781214331488-keepstake-allsubnets-strands-old-hotkey-stake/)

`perform_hotkey_swap_on_all_subnets` unconditionally executes `Owner::<T>::remove(old_hotkey)` at `swap_hotkey.rs:236` regardless of the `keep_stake` flag, while the `if !keep_stake` block at line 518 that migrates `Alpha`/`AlphaV2`/`StakingHotkeys` is entirely skipped when `keep_stake=true`. After the swap, `old_hotkey` holds staked Alpha with no `Owner` entry, making `hotkey_account_exists(old_hotkey)` false. Every stake-management extrinsic including `do_remove_stake`, `do_unstake_all`, `do_add_stake`, `move_stake`, and a re-swap guards on `hotkey_account_exists` and reverts with `HotKeyAccountNotExists`, permanently freezing the retained stake with no on-chain recovery path. This affects not only the hotkey owner's stake but every third-party nominator delegated to `old_hotkey`; a delegate owner freezes all nominators in one call. The single-subnet path deliberately avoids this by leaving `Owner::remove(old_hotkey)` commented out. Proven at L1 (4/4 tests) and confirmed at L3 (Owner removed plus `HotKeyAccountNotExists` on `remove_stake` and `add_stake` confirmed on live node).

---

### H-03. parent_child_swap_hotkey overwrites new_hotkey's existing ChildKeys/ParentKeys/PendingChildKeys instead of merging them, silently destroying the destination hotkey's delegations

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1781214151380-pc-swap-overwrites-destination-children-parents.md](issues/high/bug-1781214151380-pc-swap-overwrites-destination-children-parents.md)
- **PoC:** [poc/bug-1781214151380-pc-swap-overwrites-destination-children-parents/](poc/bug-1781214151380-pc-swap-overwrites-destination-children-parents/)
- **Related:** bug-1781215670000

`parent_child_swap_hotkey` (`set_children.rs:393`) builds the `relations` object exclusively from `old_hotkey`'s edges via `load_child_parent_relations(old_hotkey, …)`, repoints the pivot to `new_hotkey`, then calls `persist_child_parent_relations`. Because `new_hotkey`'s existing children and parents are never loaded into `relations`, `persist` treats them all as "removed" and actively deletes their reverse edges from every `ParentKeys(child)` and `ChildKeys(parent)`. `PendingChildKeys` for `new_hotkey` is overwritten unconditionally at `set_children.rs:441`. When `old_hotkey` and `new_hotkey` share a parent `P`, `upsert_edge` overwrites `new_hotkey`'s legitimate proportion in `ChildKeys(P)` with `old_hotkey`'s proportion. The childkey/parentkey maps are not gated on `IsNetworkMember`, so `new_hotkey` can carry a full delegation graph while passing the membership guard, then have it all destroyed in one swap. Proven at L1/L2 (8/8) and L3 (6/6 live tests); child `C_new` and parent `Q` both destroyed and reverse edges wiped, confirmed over RPC.

---

### H-04. do_swap_coldkey does not migrate RootClaimType, silently reverting Keep preference to Swap

- **Likelihood:** likely
- **Full report:** [issues/high/bug-1781214167519-rootclaimtype-not-migrated-on-coldkey-swap.md](issues/high/bug-1781214167519-rootclaimtype-not-migrated-on-coldkey-swap.md)
- **PoC:** [poc/bug-1781214167519-rootclaimtype-not-migrated-on-coldkey-swap/](poc/bug-1781214167519-rootclaimtype-not-migrated-on-coldkey-swap/)

`do_swap_coldkey` (`swap_coldkey.rs:6-46`) migrates every other coldkey-keyed map but never reads or writes `RootClaimType`. After a coldkey swap, `new_coldkey` reads the `ValueQuery` default (`RootClaimTypeEnum::Swap`) even when `old_coldkey` had explicitly set `Keep` or `KeepSubnets`. The scheduler's `run_auto_claim_root_divs` then calls `root_claim_all` -> `root_claim_on_subnet`, reads the default `Swap`, and executes `swap_alpha_for_tao`, selling the user's alpha emissions against their explicit on-chain preference on every auto-claim cycle until the user manually re-sets the preference on `new_coldkey`. The omission was introduced in commit `c213dd014` which added partial root-claim swap support but only migrated `RootClaimed` (the watermark), not `RootClaimType` (the preference). Proven end-to-end at L1/L2 (6/6 tests); `poc_l2c` confirms root TAO stake grows (Swap branch taken) after the swap despite `Keep` being set. Grep confirms zero `RootClaimType` writes in the entire `swap/` directory.

---

### H-05. Hotkey lock swap overwrites (not merges) a pre-existing aggregate lock on new_hotkey, corrupting conviction/subnet-king accounting

- **Likelihood:** possible
- **Full report:** [issues/high/bug-1781214096254-hotkey-swap-overwrites-destination-aggregate-lock.md](issues/high/bug-1781214096254-hotkey-swap-overwrites-destination-aggregate-lock.md)
- **PoC:** [poc/bug-1781214096254-hotkey-swap-overwrites-destination-aggregate-lock/](poc/bug-1781214096254-hotkey-swap-overwrites-destination-aggregate-lock/)
- **Related:** bug-1781215670000, bug-1781214096255

`swap_hotkey_locks_for_netuids` (`lock.rs:1442`) calls `insert_hotkey_lock_state` / `insert_decaying_hotkey_lock_state` (plain `StorageMap::insert`, unconditional overwrite) rather than merging into the destination aggregate. If `new_hotkey` already holds a residual non-empty `HotkeyLock(N, new_hk)` from a prior register->lock->deregister lifecycle (deregistration never clears lock state), that aggregate is silently replaced by `old_hotkey`'s aggregate, permanently breaking the invariant `HotkeyLock(N, new_hk) == sum of individual Lock rows`. The function's own doc-comment justifies a "copy as-is" by claiming a 1:1 coldkey-hotkey relation valid only for per-coldkey rows, not for the aggregate which sums all coldkeys. The corrupt aggregate feeds `hotkey_conviction` -> `subnet_king` -> `change_subnet_owner_if_needed` with no corrective gate, permanently under-counting a third-party coldkey's conviction and suppressing its subnet-king candidacy with no re-derive recovery path. Proven at L1/L2 (both GREEN); `hotkey_conviction(new_hk, N)` confirmed below the residual conviction alone.

---

## Medium

### M-01. do_swap_coldkey leaves old_coldkey in StakingColdkeys auto-claim index forever (dead-key dilution)

- **Likelihood:** almost-certain
- **Full report:** [issues/medium/bug-1781214167520-staking-coldkey-index-left-stale-on-coldkey-swap.md](issues/medium/bug-1781214167520-staking-coldkey-index-left-stale-on-coldkey-swap.md)
- **PoC:** [poc/bug-1781214167520-staking-coldkey-index-left-stale-on-coldkey-swap/](poc/bug-1781214167520-staking-coldkey-index-left-stale-on-coldkey-swap/)

`do_swap_coldkey` drains `old_coldkey`'s ROOT stake into `new_coldkey` and calls `maybe_add_coldkey_index(new_coldkey)`, but never calls `StakingColdkeys::remove` or `StakingColdkeysByIndex::remove` for `old_coldkey` — grep confirms no such remove call exists anywhere in the codebase. `run_auto_claim_root_divs` (`claim_root.rs:391`) samples `k` indices out of `n = NumStakingColdkeys` per block; when a sampled index resolves to a dead `old_coldkey`, `do_root_claim` reads its empty `StakingHotkeys` and claims nothing, wasting the sampling slot. Because `n` never decreases while dead entries accumulate with every coldkey swap, live stakers' root dividends are auto-claimed with progressively lower frequency and dead-key slots consume block weight for no effect — a permanent, unbounded liveness degradation of root auto-claim distribution. Proven at L1/L2 (5/5 tests). Triggers automatically for every ordinary coldkey swap from a ROOT-staked key with no adversarial preconditions.

---

### M-02. Full-network hotkey swap bypasses the per-subnet HotkeySwapOnSubnetInterval guard entirely and never records LastHotkeySwapOnNetuid

- **Likelihood:** likely
- **Full report:** [issues/medium/bug-1781214086003-full-network-swap-bypasses-subnet-interval.md](issues/medium/bug-1781214086003-full-network-swap-bypasses-subnet-interval.md)
- **PoC:** [poc/bug-1781214086003-full-network-swap-bypasses-subnet-interval/](poc/bug-1781214086003-full-network-swap-bypasses-subnet-interval/)

`HotkeySwapOnSubnetInterval` (default 7200 blocks ~= 1 day) is enforced only inside `swap_hotkey_on_subnet` (`swap_hotkey.rs:300-305`), keyed by `LastHotkeySwapOnNetuid(netuid, coldkey)`. The full-network path (`netuid = None`, `perform_hotkey_swap_on_all_subnets`) never reads or writes `LastHotkeySwapOnNetuid` and is gated only by the weaker generic `TxRateLimit` (~1000 blocks ~= 3.3 h). A coldkey that wants to rotate a hotkey on subnet `S` more often than once per day can issue a full-network swap instead; the full-network call rewrites `S`'s membership/UID/keys while subject only to the ~7x weaker generic limit. Because the full-network path never advances `LastHotkeySwapOnNetuid(S, coldkey)`, a `Some(S)` swap can follow the full-network swap immediately (the subnet-path cooldown history is blind to full-network swaps). Proven at L1/L2 (all GREEN).

---

### M-03. Hotkey swap copies the dead LastTxBlockChildKeyTake map but never migrates the live per-subnet TransactionKeyLastBlock, resetting the childkey-take (and set-children) rate limit to zero for the new hotkey

- **Likelihood:** likely
- **Full report:** [issues/medium/bug-1781214086004-childkey-take-ratelimit-reset-on-swap.md](issues/medium/bug-1781214086004-childkey-take-ratelimit-reset-on-swap.md)
- **PoC:** [poc/bug-1781214086004-childkey-take-ratelimit-reset-on-swap/](poc/bug-1781214086004-childkey-take-ratelimit-reset-on-swap/)

`do_swap_hotkey` step 10 (`swap_hotkey.rs:109-112`) copies `RateLimitKey::LastTxBlockChildKeyTake(old_hotkey)` to `new_hotkey`, but the live childkey-take rate limit in `do_set_childkey_take` (`set_children.rs:744-762`) reads and writes the NMap `TransactionKeyLastBlock(hotkey, netuid, tx_type)` (`rate_limiting.rs:101`), not `LastTxBlockChildKeyTake`. Grep confirms no swap path writes `TransactionKeyLastBlock`. Because `check_passes_rate_limit` treats `last_block == 0` as "first transaction, always allowed" (`rate_limiting.rs:64`), a freshly-swapped `new_hotkey` can immediately change childkey take and set-children on every subnet regardless of how recently `old_hotkey` did so, defeating the ~30-day production `TxChildkeyTakeRateLimit` throttle. The `SetChildren` (tx_type 0) rate limit is reset by the same root cause. Additionally, `LastTxBlockDelegateTake` is removed from `old_hotkey` on the all-subnets path but retained on the single-subnet path, leaving a delegate-take rate-limit state divergence for a hotkey still operating on other subnets. Proven at L1/L2 (all GREEN).

---

### M-04. migrate_cleanup_swap_v3 is never wired into on_runtime_upgrade — orphaned SubnetTaoProvided/SubnetAlphaInProvided are excluded from balancer reserves (understated AMM pools, lost TAO/alpha backing)

- **Likelihood:** possible
- **Full report:** [issues/medium/bug-1781214242542-cleanup-swap-v3-unwired-orphans-reserves.md](issues/medium/bug-1781214242542-cleanup-swap-v3-unwired-orphans-reserves.md)
- **PoC:** [poc/bug-1781214242542-cleanup-swap-v3-unwired-orphans-reserves/](poc/bug-1781214242542-cleanup-swap-v3-unwired-orphans-reserves/)

`migrate_cleanup_swap_v3` is fully implemented and has a passing unit test but is never added to the `on_runtime_upgrade()` chain in `macros/hooks.rs`. A `git log -S "migrate_cleanup_swap_v3"` over `hooks.rs` history confirms it was never wired. The companion migration `migrate_swapv3_to_balancer` (Swap pallet) folds only the Swap pallet's `ScrapReservoir*` maps and does NOT touch `SubnetorModule::SubnetTaoProvided` / `SubnetAlphaInProvided`. Those `#[pallet::storage]` definitions were removed from `lib.rs` in commit `49accc1a8`, leaving no live decoder and no recovery path other than this unwired migration. When the runtime upgrade executes, any non-zero provided balances are permanently excluded from `SubnetTAO` / `SubnetAlphaIn`, permanently understating the AMM reserve for every affected subnet and corrupting price/reserve calculations and backing ratios. Proven at L1 (direct call) and L2 (real `on_runtime_upgrade()` dispatch via production Runtime, both GREEN).

---

### M-05. Hotkey swap overwrites new_hotkey's existing delegate take (and ownership) when destination is an unregistered delegate, bypassing take bounds and nominator consent

- **Likelihood:** possible
- **Full report:** [issues/medium/bug-1781214019953-hotkey-swap-clobbers-destination-delegate-take.md](issues/medium/bug-1781214019953-hotkey-swap-clobbers-destination-delegate-take.md)
- **PoC:** [poc/bug-1781214019953-hotkey-swap-clobbers-destination-delegate-take/](poc/bug-1781214019953-hotkey-swap-clobbers-destination-delegate-take/)

Step 10 of `perform_hotkey_swap_on_all_subnets` (`swap_hotkey.rs:285-290`) performs an unconditional `Delegates::insert(new_hotkey, old_take)` with no check for whether `new_hotkey` already holds a `Delegates` entry. The `HotKeyAlreadyRegisteredInSubNet` guard checks only `IsNetworkMember`; `Delegates` membership is written by `maybe_become_delegate` for any hotkey receiving stake regardless of subnet membership. A `new_hotkey` that is an active delegate with its own nominators and configured take can pass the guard and have its take silently overwritten without going through `do_increase_take` or `do_decrease_take` (rate-limited and bounds-checked). If the old hotkey's stored take was set when `MaxDelegateTake` was higher, the swap can write a now-out-of-bounds take onto `new_hotkey`. `Delegates` is a global per-hotkey map read every epoch in `run_coinbase.rs:665` to size the validator's dividend cut; the overwrite distorts dividend routing for all existing nominators. Proven at L1/L2 (all GREEN); L2 confirmed ~102M extra alpha extracted per epoch difference in dividend gain.

---

### M-06. ChildkeyTake is never migrated on hotkey swap, silently resetting a childkey's configured take to the minimum

- **Likelihood:** possible
- **Full report:** [issues/medium/bug-1781214103000-childkey-take-not-migrated-on-hotkey-swap.md](issues/medium/bug-1781214103000-childkey-take-not-migrated-on-hotkey-swap.md)
- **PoC:** [poc/bug-1781214103000-childkey-take-not-migrated-on-hotkey-swap/](poc/bug-1781214103000-childkey-take-not-migrated-on-hotkey-swap/)
- **Related:** bug-1781215670000

`perform_hotkey_swap_on_one_subnet` migrates `ChildKeys`/`ParentKeys`/`PendingChildKeys` via `parent_child_swap_hotkey` but never touches `ChildkeyTake(hotkey, netuid)`. After a swap, `ChildkeyTake::get(new_hotkey, netuid)` returns `0` (the `ValueQuery` default) and `get_childkey_take` (`set_children.rs:792`) returns the effective minimum childkey take instead of the configured value. This silently redirects a larger share of every subsequent epoch's dividends to the hotkey's parents with no operator consent. `ChildkeyTake(old_hotkey, netuid)` is left as stale garbage that self-applies if `old_hotkey` is re-registered on the same subnet. The closely related `LastTxBlockChildKeyTake` rate-limit map IS migrated (`swap_hotkey.rs:120-123`), making the value omission an oversight. Recovery via `set_childkey_take` is throttled by the ~30-day `TxChildkeyTakeRateLimit`. Proven at L1/L2; L2 confirmed dividend drop from 179,995,422 to 0 on a 1e9 emission (17.9% loss per epoch).

---

### M-07. Hotkey-swap PendingChildKeys 'old as child' rewrite creates duplicate/unvalidated child entries and overwrites destination pending schedules; mutates storage while iterating

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1781214210714-pending-childkeys-swap-creates-duplicate-unvalidated.md](issues/medium/bug-1781214210714-pending-childkeys-swap-creates-duplicate-unvalidated.md)
- **PoC:** [poc/bug-1781214210714-pending-childkeys-swap-creates-duplicate-unvalidated/](poc/bug-1781214210714-pending-childkeys-swap-creates-duplicate-unvalidated/)
- **Related:** bug-1781215670000

Pass B in `perform_hotkey_swap_on_one_subnet` (`swap_hotkey.rs:489-493`) rewrites entries where `old_hotkey` appears as a child in `PendingChildKeys` by removing and re-inserting within a live `iter_prefix(netuid)` loop — a pattern the rest of the codebase explicitly avoids. When a parent `Q` has both `old_hotkey` and `new_hotkey` already in its pending schedule, Pass B appends `(p_old, new_hotkey)` to a vector already containing `(p_new, new_hotkey)`, producing a duplicate that `do_schedule_children` would reject with `DuplicateChild`. At maturation, `load_relations_from_pending`'s `BTreeMap` last-wins dedup silently discards `p_new` and applies `p_old`, corrupting `Q`'s dividend routing proportions permanently with no error surfaced. Pass B also applies zero consistency validation (unlike Pass A which calls `ensure_pending_consistency`) and overwrites any pending entry `new_hotkey` already held as parent. Proven at L1/L2 (all GREEN); L3 confirmed duplicate storage artifact and wrong applied weight over RPC.

---

### M-08. Hotkey swap overwrites a same-coldkey Lock row on new_hotkey, dropping the lock while its aggregate contribution stays

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1781214096255-hotkey-swap-per-coldkey-lock-overwrite-leaves-orphan-aggregate.md](issues/medium/bug-1781214096255-hotkey-swap-per-coldkey-lock-overwrite-leaves-orphan-aggregate.md)
- **PoC:** [poc/bug-1781214096255-hotkey-swap-per-coldkey-lock-overwrite-leaves-orphan-aggregate/](poc/bug-1781214096255-hotkey-swap-per-coldkey-lock-overwrite-leaves-orphan-aggregate/)
- **Related:** bug-1781214096254

`swap_hotkey_locks_for_netuids` per-coldkey loop (`lock.rs:1524`) calls `insert_lock_state(&coldkey, netuid, new_hotkey, moved)` (plain `Lock::insert` overwrite) when coldkey `C` holds two `Lock` rows on the same subnet. The documented path that creates this dual-row state is `swap_coldkey_locks`, which allows a destination coldkey holding a conviction-only lock to receive a second row; `test_coldkey_swap_allows_destination_conviction_only_lock` asserts `iter_prefix((new_coldkey, netuid)).count() == 2`. A subsequent hotkey swap routing one hotkey onto the other hits the overwrite: one lock is silently discarded but its contribution to `HotkeyLock(N, dest)` is never reduced, permanently breaking the aggregate invariant and feeding corrupted conviction totals into `subnet_king` / `change_subnet_owner_if_needed`. Proven at L1/L2 (all 3 tests GREEN).

---

### M-09. Coldkey-swap precondition (empty StakingHotkeys) does not guarantee clean destination; AutoStakeDestination/RootClaimType clobber or mis-merge

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1781214167521-coldkey-swap-precondition-allows-dirty-destination.md](issues/medium/bug-1781214167521-coldkey-swap-precondition-allows-dirty-destination.md)
- **PoC:** [poc/bug-1781214167521-coldkey-swap-precondition-allows-dirty-destination/](poc/bug-1781214167521-coldkey-swap-precondition-allows-dirty-destination/)

`do_swap_coldkey` guards the destination with `StakingHotkeys(new_coldkey).is_empty()` and `!hotkey_account_exists(new_coldkey)` only. A `new_coldkey` with empty `StakingHotkeys` can simultaneously hold non-empty `AutoStakeDestination`, `RootClaimType`, `SubnetOwner`, `OwnedHotkeys`, and a free TAO balance. `transfer_auto_stake_destination` (`swap_coldkey.rs:69-84`) performs an unconditional `AutoStakeDestination::insert(new_coldkey, netuid, old_auto_stake_hotkey)`, overwriting any destination `new_coldkey` had configured and leaving a stale `AutoStakeDestinationColdkeys` reverse-index. The clobbered `AutoStakeDestination` is read by `run_coinbase.rs:639` every epoch to route incentive, silently misrouting the new coldkey's earned emissions. The scope document explicitly flags destination-populated merges as the highest-risk case; the precondition forbids only the staking-hotkey collision, letting every other coldkey-keyed map silently clobber. Proven at L1 (2/2 GREEN).

---

### M-10. migrate_coldkey_swap_scheduled_to_announcements stores executable block at (when - delay), making in-flight scheduled coldkey swaps executable up to InitialColdkeySwapAnnouncementDelay (5 days) earlier — or instantly — shortening/eliminating the dispute safety window

- **Likelihood:** unlikely
- **Full report:** [issues/medium/bug-1781214242598-announcement-migration-shortens-dispute-window.md](issues/medium/bug-1781214242598-announcement-migration-shortens-dispute-window.md)
- **PoC:** [poc/bug-1781214242598-announcement-migration-shortens-dispute-window/](poc/bug-1781214242598-announcement-migration-shortens-dispute-window/)

`migrate_coldkey_swap_scheduled_to_announcements` writes `ColdkeySwapAnnouncements(who) = (when.saturating_sub(delay), hash)` for each in-flight legacy entry. In the new model this stored value is the earliest executable block (`swap_coldkey_announced` checks `now >= when` with no `+ delay`), making migrated swaps executable up to 5 days earlier than their original `when`. When `0 < when - now < delay`, `when - delay` falls in the past, making the migrated announcement instantly executable and collapsing the remaining dispute window to zero. The dispute window exists so an owner whose coldkey was compromised can `dispute_coldkey_swap` before completion; early execution removes that protection. The developer intent comment is incorrect; the unit test only covers `when - now > delay` and misses the instant-execution edge case. Proven at L1/L2 (4/4 tests GREEN).

---

### M-11. swap_hotkey / swap_hotkey_v2 declare a fixed pre-dispatch weight that does not scale per-subnet, so a large-footprint all-subnets swap is systematically under-charged (fee underpayment + block-fill DoS)

- **Likelihood:** likely
- **Full report:** [issues/medium/bug-1781214965463-swap-hotkey-weight-undercharge-per-subnet.md](issues/medium/bug-1781214965463-swap-hotkey-weight-undercharge-per-subnet.md)
- **PoC:** [poc/bug-1781214965463-swap-hotkey-weight-undercharge-per-subnet/](poc/bug-1781214965463-swap-hotkey-weight-undercharge-per-subnet/)

Both `swap_hotkey` (call_index 70, benchmarked at 4 subnets) and `swap_hotkey_v2` (call_index 72, hand-written `~275M ps + 52r/35w`) declare a fixed pre-dispatch weight independent of subnet count, while the actual work of an all-subnets swap loops over `get_all_subnet_netuids()`. Substrate's `calc_actual_weight` caps charged weight at the declared pre-dispatch constant, so excess accrued weight is silently discarded. At 128 subnets: declared ~5.125B ps, accrued real ~190.9B ps — a 37x under-charge confirmed by live PoC re-run. Fee underpayment is universal for all all-subnets swaps when the chain has more than 4 subnets (production has 60+). For a deliberate block-fill DoS, ~585 distinct coldkeys each submitting one swap can fill a block beyond the weight budget at ~58.5 TAO (non-recoverable `KeySwapCost` burn) per overloaded block. Proven at L1/L2 (both GREEN).

---

### M-12. Root hotkey-swap guard ignores leftover RootClaimed watermark on new_hotkey, causing watermark over-advance and permanent root-dividend loss

- **Likelihood:** almost-impossible
- **Full report:** [issues/medium/bug-1781214331487-root-swap-guard-ignores-rootclaimed-watermark.md](issues/medium/bug-1781214331487-root-swap-guard-ignores-rootclaimed-watermark.md)
- **PoC:** [poc/bug-1781214331487-root-swap-guard-ignores-rootclaimed-watermark/](poc/bug-1781214331487-root-swap-guard-ignores-rootclaimed-watermark/)

The root-clean guard at `swap_hotkey.rs:87-97` inspects `RootClaimable` and root stake but never `RootClaimed`. These are independent maps: `RootClaimable` can be emptied while `RootClaimed` watermarks survive non-zero. When `new_hotkey` carries a leftover non-zero `RootClaimed((subnetX, new_hotkey, coldkeyC))`, `transfer_root_claimed_for_new_keys` (`claim_root.rs:423-436`) sums `old_hotkey`'s watermark onto that residue via `saturating_add`, advancing the watermark beyond what `coldkeyC` ever claimed. `get_root_owed_for_hotkey_coldkey_float` (`claim_root.rs:96-111`) computes `owed = claimable.saturating_sub(root_claimed)`; an over-advanced `root_claimed` permanently reduces the claimable amount. The precondition requires the genesis-hash-gated `migrate_fix_root_claimed_overclaim` to have run and left a residue on one of 12 specific hardcoded hotkeys, AND a root-touching swap targeting one of those exact addresses as `new_hotkey` — effectively impossible in practice. Proven at L1/L2 (both GREEN).

---

## Low

### L-01. Hotkey swap migrates WeightCommits but not TimelockedWeightCommits / CRV3WeightCommitsV2, dropping a validator's pending timelocked weight commit

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781214103001-timelocked-weight-commits-orphaned-on-hotkey-swap.md](issues/low/bug-1781214103001-timelocked-weight-commits-orphaned-on-hotkey-swap.md)
- **PoC:** none

`perform_hotkey_swap_on_one_subnet` migrates the plaintext `WeightCommits(netuid_index, hotkey)` map but not its timelocked siblings `TimelockedWeightCommits(netuid_index, epoch)` and `CRV3WeightCommitsV2(netuid_index, epoch)`, which embed the committing hotkey as `who` inside epoch-keyed queued tuples. On reveal, `reveal_commits.rs:140` matches `decoded_hotkey == who` (still `old_hotkey`), and `do_set_mechanism_weights` is issued as `old_hotkey` — which no longer holds a UID on the subnet after the swap, causing the commit to fail and be silently dropped. A validator that commits weights for a future reveal epoch and then swaps its hotkey loses those weights. Impact is bounded to one or a few pending reveal epochs; the validator can re-commit under `new_hotkey`. Filed as an asymmetry oversight relative to the migrated `WeightCommits` sibling.

---

### L-02. Hotkey swap leaves RootAlphaDividendsPerSubnet and LastHotkeyEmissionOnNetuid stale under old_hotkey (reporting maps only)

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781214103002-root-alpha-dividends-and-last-hotkey-emission-not-swapped.md](issues/low/bug-1781214103002-root-alpha-dividends-and-last-hotkey-emission-not-swapped.md)
- **PoC:** none

`perform_hotkey_swap_on_one_subnet` step 8.2 swaps `AlphaDividendsPerSubnet(netuid, hotkey)` but not sibling maps `RootAlphaDividendsPerSubnet(netuid, hotkey)` or `LastHotkeyEmissionOnNetuid(hotkey, netuid)`. Both are consumed for off-chain reporting only and self-heal at the next epoch: `RootAlphaDividendsPerSubnet` and `AlphaDividendsPerSubnet` are `clear_prefix`'d and recomputed every epoch, so `new_hotkey` simply reports `0` until then; `LastHotkeyEmissionOnNetuid` is overwritten when `new_hotkey` next receives emission. No fund loss, no consensus impact, no invariant violation — a transient one-epoch reporting inconsistency filed because the omission is an asymmetry with the migrated `AlphaDividendsPerSubnet` sibling.

---

### L-03. HotkeySwapped event is emitted identically for keep_stake=true and keep_stake=false, so off-chain consumers mis-track stake/delegation when stake stays behind on the old hotkey

- **Likelihood:** n/a (status: suspected)
- **Full report:** [issues/low/bug-1781214965464-hotkeyswapped-event-implies-full-move-on-keepstake.md](issues/low/bug-1781214965464-hotkeyswapped-event-implies-full-move-on-keepstake.md)
- **PoC:** none

`do_swap_hotkey` always emits `Event::HotkeySwapped { coldkey, old_hotkey, new_hotkey }` (`swap_hotkey.rs:165`) with no `keep_stake` field and no `netuid` field, making the event indistinguishable between a full ownership+stake move (`keep_stake=false`) and a partial move where stake remains on `old_hotkey` (`keep_stake=true`). An off-chain indexer or wallet that re-points delegation and stake tracking from `old_hotkey` to `new_hotkey` on `HotkeySwapped` will incorrectly attribute `old_hotkey`'s retained stake to `new_hotkey`. On-chain storage is internally consistent for the keep_stake split; this is an emitted-data correctness defect only. The state-level keep_stake problem (stake permanently frozen) is filed separately as H-02.
