Fix swap_hotkey_v2: moving locks and conviction#2729
Conversation
🛡️ AI Review — Skeptic (security review)VERDICT: VULNERABLE Baseline scrutiny: author has write permission and substantial prior subtensor history; branch fix/hotkey-swap-conviction -> devnet-ready; no .github, dependency, build-script, or lockfile changes. Static review only, per Skeptic constraints. The previous global-lock-migration finding is addressed by the new subnet-scoped lock swap path, but the added lock movement still introduces unbounded storage scans in runtime paths. Findings
Prior-comment reconciliation
ConclusionThe PR is vulnerable because the new subnet hotkey-swap lock migration can scan the entire lock map under a fixed normal extrinsic weight, and the runtime upgrade migration also scans the full map while under-reporting that work. Both paths need bounded/indexed iteration or conservative weight handling before merge. 📜 Previous run (superseded)
# 🔍 AI Review — Auditor (domain review) has not yet run on this PR. |
|
🔄 AI review updated — Skeptic: VULNERABLE |
|
🔄 AI review updated — Skeptic: VULNERABLE |
|
|
||
| // 9. Perform the hotkey swap | ||
| // 9. Swap the stake locks | ||
| let (reads, writes) = Self::swap_hotkey_locks_on_subnet(old_hotkey, new_hotkey, netuid); |
There was a problem hiding this comment.
[HIGH] Subnet hotkey swap scans the entire lock map under fixed weight
This added call reaches swap_hotkey_locks_for_netuids, which uses Lock::<T>::iter() whenever the old hotkey has a relevant aggregate lock on the subnet. swap_hotkey_v2 is still declared with a fixed normal-class pre-dispatch weight, so a single subnet-scoped swap can force a full scan of all (coldkey, netuid, hotkey) lock entries while only being admitted to the block at the static weight. Since Lock is keyed by coldkey first, this cannot be bounded by the target subnet/hotkey without another index or a capped migration-style path. This is a transaction-level DoS/overweight-block risk. Use a bounded/reverse index for locks to old_hotkey on netuid, or do not expose this full-map scan from the normal extrinsic path.
| let locks: Vec<(T::AccountId, LockState)> = Lock::<T>::iter() | ||
| .filter_map(|((coldkey, lock_netuid, hotkey), lock)| { | ||
| (lock_netuid == netuid && hotkey == old_hotkey).then_some((coldkey, lock)) | ||
| }) | ||
| .collect(); | ||
| for (coldkey, _) in &locks { | ||
| Lock::<T>::remove((coldkey.clone(), netuid, old_hotkey.clone())); | ||
| } | ||
| locks | ||
| }; | ||
| let locks_to_fix_count = locks_to_fix.len() as u64; | ||
| weight = weight.saturating_add( | ||
| T::DbWeight::get() | ||
| .reads_writes(locks_to_fix_count.saturating_add(1), locks_to_fix_count), | ||
| ); |
There was a problem hiding this comment.
[HIGH] Runtime upgrade migration undercounts a full Lock iteration
For the coldkey: None fix, this branch iterates every Lock entry and only filters after reading each item. The returned migration weight then charges locks_to_fix_count + 1 reads, which counts only matching locks, not the full map scan. On a large lock map, the runtime upgrade can do substantially more work than it reports, creating an overweight runtime-upgrade block risk. Either avoid the global iterator by enumerating exact coldkeys, or account for every scanned entry and keep the migration within a bounded, known-safe limit.
|
🔄 AI review updated — Skeptic: VULNERABLE |
Description
Hotkey swap v2 did not move conviction. This PR fixes the issue.
Related Issue(s)
Type of Change
Checklist
./scripts/fix_rust.shto ensure my code is formatted and linted correctly