Skip to content

Fix hotkey swap conviction locks#2731

Merged
sam0x17 merged 4 commits into
mainfrom
hotfix/hotkey-swap-conviction
Jun 8, 2026
Merged

Fix hotkey swap conviction locks#2731
sam0x17 merged 4 commits into
mainfrom
hotfix/hotkey-swap-conviction

Conversation

@gztensor

@gztensor gztensor commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Description

Hotfix: Hotkey swap v2 did not move conviction. This PR fixes the issue on mainnet as a hotfix.

Related Issue(s)

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other (please describe):

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run ./scripts/fix_rust.sh to ensure my code is formatted and linted correctly
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@gztensor gztensor self-assigned this Jun 8, 2026
@github-actions github-actions Bot added the hotfix This PR needs to be merged very quickly and will likely skip testing on devnet and testnet label Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🚨🚨🚨 HOTFIX DETECTED 🚨🚨🚨

It looks like you are trying to merge a hotfix PR into main. If this isn't what you wanted to do, and you just wanted to make a regular PR, please close this PR, base your changes off the devnet-ready branch and open a new PR into devnet ready.

If you are trying to merge a hotfix PR, please complete the following essential steps:

  1. go ahead and get this PR into main merged, so we can get the change in as quickly as possible!
  2. merge main into testnet, bumping spec_version
  3. deploy testnet
  4. merge testnet into devnet, bumping spec_version
  5. deploy devnet
  6. merge devnet into devnet-ready

If you do not complete these steps, your hotfix may be inadvertently removed in the future when branches are promoted to main, so it is essential that you do so.

@gztensor gztensor added skip-cargo-audit This PR fails cargo audit but needs to be merged anyway and removed hotfix This PR needs to be merged very quickly and will likely skip testing on devnet and testnet labels Jun 8, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.


// 9. Perform the hotkey swap
// 9. Swap the stake locks
let (reads, writes) = Self::swap_hotkey_locks(old_hotkey, new_hotkey);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Subnet hotkey swap moves locks on every subnet

swap_hotkey_on_subnet is only supposed to operate on the requested netuid, but swap_hotkey_locks(old_hotkey, new_hotkey) scans all subnets and moves every Lock for old_hotkey to new_hotkey. The caller only checks that new_hotkey is unregistered on the selected subnet, so this can mutate unrelated subnet locks/conviction and potentially interact with registrations or ownership on other subnets. Use a subnet-scoped lock mover that only touches (coldkey, netuid, old_hotkey) plus that subnet's aggregate lock state.

.map(|lock| vec![(coldkey, lock)])
.unwrap_or_default()
} else {
let locks: Vec<(T::AccountId, LockState)> = Lock::<T>::iter()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Runtime upgrade performs an unbounded Lock scan

For the coldkey: None fix, the migration iterates the entire Lock storage map during on_runtime_upgrade, collects matching entries, then only charges weight based on the number of matches. Lock is keyed by coldkey first, so this is a full-map scan whose execution time scales with all live locks, not the handful of affected rows. A mainnet runtime upgrade must avoid unbounded storage scans or account for them with a proven bounded input; enumerate the affected coldkeys or split the repair into bounded keys instead.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🛡️ AI Review — Skeptic (security review)

VERDICT: VULNERABLE

LOW scrutiny: write-permission contributor with established subtensor history, author/committer match; direct-to-main hotfix is explicitly justified.

No .github/ai-review/*, .github/copilot-instructions.md, dependency, build-script, or lockfile changes are present.

Findings

Sev File Finding
HIGH pallets/subtensor/src/migrations/migrate_fix_subnet_hotkey_lock_swaps.rs:285 Runtime upgrade still scans the entire Lock map inline
HIGH pallets/subtensor/src/swap/swap_hotkey.rs:354 Subnet hotkey swap can scan all locks under a fixed dispatch weight inline

Prior-comment reconciliation

  • 487bcbfb: not addressed — The migration still contains the Lock::<T>::iter() path for fixes without an explicit coldkey, and weight still tracks matching locks rather than all scanned entries.
  • 0389108a: not addressed — The subnet swap now scopes moved locks to the selected netuid, but the dispatch path still scans the full Lock map under the fixed extrinsic weight.

Conclusion

The PR appears legitimate, but the prior resource-exhaustion issues remain: both the runtime migration and the subnet hotkey-swap dispatch can still scan the full Lock map. These paths need a bounded lookup/indexing strategy before this hotfix is safe to merge to main.


📜 Previous run (superseded)
Sev File Finding Status
HIGH pallets/subtensor/src/migrations/migrate_fix_subnet_hotkey_lock_swaps.rs:285 Runtime upgrade still scans the entire Lock map ➡️ Carried forward to current findings
The migration still contains the Lock::<T>::iter() path for fixes without an explicit coldkey, and weight still tracks matching locks rather than all scanned entries.
HIGH pallets/subtensor/src/swap/swap_hotkey.rs:354 Subnet hotkey swap can scan all locks under a fixed dispatch weight ➡️ Carried forward to current findings
The subnet swap now scopes moved locks to the selected netuid, but the dispatch path still scans the full Lock map under the fixed extrinsic weight.

# 🔍 AI Review — Auditor (domain review) has not yet run on this PR.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

.map(|lock| vec![(coldkey, lock)])
.unwrap_or_default()
} else {
let locks: Vec<(T::AccountId, LockState)> = Lock::<T>::iter()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Runtime upgrade still scans the entire Lock map

This Lock::<T>::iter() path remains unbounded in on_runtime_upgrade. It scans every Lock entry just to find the one hotkey/netuid pair with an unknown coldkey, and the reported weight only accounts for locks_to_fix_count, not for every scanned entry. On mainnet, a large lock map can make the runtime upgrade exceed block limits or materially underreport upgrade weight. Require an explicit coldkey for this fix, use a bounded/paged migration, or otherwise avoid whole-map iteration during the upgrade.


// 9. Perform the hotkey swap
// 9. Swap the stake locks
let (reads, writes) = Self::swap_hotkey_locks_on_subnet(old_hotkey, new_hotkey, netuid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Subnet hotkey swap can scan all locks under a fixed dispatch weight

This new subnet-swap path calls swap_hotkey_locks_on_subnet, which delegates to a helper that runs Lock::<T>::iter() over the entire lock map whenever the old hotkey has any relevant aggregate lock on the subnet. The dispatch is admitted with the static swap_hotkey_v2 weight, so a normal user can make a single-subnet hotkey swap perform work proportional to all locks in storage. Move the per-coldkey locks through indexed/bounded storage for the target (netuid, old_hotkey) or otherwise cap the work before exposing it from this dispatch.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@gztensor gztensor marked this pull request as draft June 8, 2026 17:24

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

.map(|lock| vec![(coldkey, lock)])
.unwrap_or_default()
} else {
let locks: Vec<(T::AccountId, LockState)> = Lock::<T>::iter()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Runtime upgrade still scans the entire Lock map

For the coldkey: None fix, this migration iterates Lock::<T>::iter() and collects every matching entry by filtering on (netuid, old_hotkey). Lock is keyed by (coldkey, netuid, hotkey), so this scans the whole map during on_runtime_upgrade; the returned weight only accounts for locks_to_fix_count, not the total entries scanned. On mainnet this can make the upgrade block exceed its real work budget. Make every fix target explicit coldkeys, or add/use a bounded index that can be iterated by (netuid, hotkey) with weight charged for every scanned entry.


// 9. Perform the hotkey swap
// 9. Swap the stake locks
let (reads, writes) = Self::swap_hotkey_locks_on_subnet(old_hotkey, new_hotkey, netuid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Subnet hotkey swap can scan all locks under a fixed dispatch weight

This call enters swap_hotkey_locks_on_subnet, which still uses Lock::<T>::iter() whenever the old hotkey has lock aggregate state on the selected subnet. The extrinsic is declared with a fixed #[pallet::weight], so block admission does not know it may scan the entire lock map; returning the measured post-dispatch weight after execution does not bound the pre-dispatch resource use. A user can trigger an unbounded storage scan from a normal subnet-scoped hotkey swap. This needs a bounded lookup path, a hard cap with continuation, or a declared maximum weight that actually covers the worst-case scan.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

@sam0x17 sam0x17 marked this pull request as ready for review June 8, 2026 19:04
@sam0x17 sam0x17 merged commit 3c5aff0 into main Jun 8, 2026
218 of 222 checks passed

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

.map(|lock| vec![(coldkey, lock)])
.unwrap_or_default()
} else {
let locks: Vec<(T::AccountId, LockState)> = Lock::<T>::iter()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Runtime upgrade still scans the entire Lock map

This coldkey: None migration path still iterates Lock::<T>::iter() and collects every matching lock for one hard-coded hotkey. Because this runs during on_runtime_upgrade, the work is bounded by total Lock storage size rather than the number of known fixes, and the returned weight only charges locks_to_fix_count + 1 reads instead of every scanned entry. On a large mainnet Lock map, that can make the runtime upgrade exceed the block budget and stall the chain. Require an explicit coldkey for this fix or use a storage layout/prefix that bounds the scan to the affected keyspace and accounts every read.


// 9. Perform the hotkey swap
// 9. Swap the stake locks
let (reads, writes) = Self::swap_hotkey_locks_on_subnet(old_hotkey, new_hotkey, netuid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Subnet hotkey swap can scan all locks under a fixed dispatch weight

swap_hotkey_locks_on_subnet delegates into swap_hotkey_locks_for_netuids, which still calls Lock::<T>::iter() when the selected subnet has any relevant aggregate lock. The extrinsic’s declared pre-dispatch weight is still fixed, so an accepted normal transaction can perform work proportional to the entire Lock map before post-dispatch accounting reports the larger dynamic weight. An attacker only needs to choose an old hotkey with aggregate lock state on the target subnet to force a full-map scan. This needs a bounded per-hotkey/per-subnet index or another statically bounded transfer path before it is safe in a dispatchable call.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: VULNERABLE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-cargo-audit This PR fails cargo audit but needs to be merged anyway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hotkey swap doesn't move conviction to the new hotkey

3 participants