Skip to content

Bug: swap_hotkey on a single subnet wipes all root dividend accumulations #2515

@bdmason

Description

@bdmason

Bug: swap_hotkey on a single subnet wipes all root dividend accumulations

Summary

perform_hotkey_swap_on_one_subnet (swap_hotkey.rs:510) unconditionally calls
transfer_root_claimable_for_new_hotkey, which transfers the entire
RootClaimable BTreeMap (accumulated dividend rates for all subnets) from the
old hotkey to the new hotkey — even when only swapping on a single non-root subnet.

Meanwhile, RootClaimed is only transferred for the one subnet being swapped
(line 515). The old hotkey retains its root stake and RootClaimed entries for all
other subnets, putting it into a permanently overclaimed state where
claimed >> claimable, yielding effectively zero root dividends.

Impact

Any validator who performs a single-subnet hotkey swap (not an all-subnet swap)
will have their root dividends frozen to near-zero for weeks or months until the
re-accumulating RootClaimable rates catch up to the orphaned RootClaimed
watermarks.

On-Chain Proof

Hotkey: 5HK5tp6t2S59DywmHRWPBVJeJ86T61KjurYqeooqj8sREpeN
Coldkey: 5EAdYiz1M8BznMNE9WvTajEq64UpGKBFGZDJBNvMDj5fWpeG
Block: 7,670,707 (finney, ~March 4 2026)
Extrinsic: SubtensorModule::swap_hotkey (extrinsic #8 in block)

Metric Before swap (block 7,670,706) After swap (block 7,670,707) 15 days later
RootClaimable rate_sum 2.139 (127 subnets) 0 (null — wiped) 0.296 (re-accumulating)
RootClaimed total ~35,692 α ~35,692 α (unchanged) ~35,692 α
Root stake ~17,065 TAO ~17,065 TAO (unchanged) ~17,065 TAO
Claimable (rate × stake) ~36,500 α 0 ~5,051 α
Owed (claimable - claimed) ~800 α 0 ~43 α
Overclaimed subnets 0 / 126 126 / 126 122 / 126

The swap transferred the old hotkey to a new hotkey on a single subnet (not root).
Root stake remained on the old hotkey. But all RootClaimable rates were wiped.

Root Cause

File: pallets/subtensor/src/swap/swap_hotkey.rs

In perform_hotkey_swap_on_one_subnet (line 321), which is called for both
single-subnet swaps and as a loop body for all-subnet swaps:

// Line 510: Transfers ALL subnets' accumulated rates — entire BTreeMap
Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey);

// Lines 513-517: Transfers RootClaimed for ONLY the one subnet being swapped
for ((coldkey, netuid_alpha), alpha) in old_alpha_values {
    if netuid == netuid_alpha {
        Self::transfer_root_claimed_for_new_keys(
            netuid, old_hotkey, new_hotkey, &coldkey, &coldkey,
        );
        // ...
    }
}

The mismatch

Storage What gets transferred Scope
RootClaimable (line 510) Entire BTreeMap (all subnets' rates) All subnets
RootClaimed (line 515) One entry per coldkey Only the swapped subnet

transfer_root_claimable_for_new_hotkey (claim_root.rs:373-389)

pub fn transfer_root_claimable_for_new_hotkey(
    old_hotkey: &T::AccountId,
    new_hotkey: &T::AccountId,
) {
    let src_root_claimable = RootClaimable::<T>::get(old_hotkey);
    let mut dst_root_claimable = RootClaimable::<T>::get(new_hotkey);
    RootClaimable::<T>::remove(old_hotkey);  // ← Removes ENTIRE map

    for (netuid, claimable_rate) in src_root_claimable.into_iter() {
        dst_root_claimable
            .entry(netuid)
            .and_modify(|total| *total = total.saturating_add(claimable_rate))
            .or_insert(claimable_rate);
    }
    RootClaimable::<T>::insert(new_hotkey, dst_root_claimable);
}

transfer_root_claimed_for_new_keys (claim_root.rs:359-372)

pub fn transfer_root_claimed_for_new_keys(
    netuid: NetUid,  // ← Only one subnet
    old_hotkey: &T::AccountId,
    new_hotkey: &T::AccountId,
    old_coldkey: &T::AccountId,
    new_coldkey: &T::AccountId,
) {
    let old_root_claimed = RootClaimed::<T>::get((netuid, old_hotkey, old_coldkey));
    RootClaimed::<T>::remove((netuid, old_hotkey, old_coldkey));
    RootClaimed::<T>::mutate((netuid, new_hotkey, new_coldkey), |new_root_claimed| {
        *new_root_claimed = old_root_claimed.saturating_add(*new_root_claimed);
    });
}

Why it works for all-subnet swaps (but not single-subnet)

In perform_hotkey_swap_on_all_subnets (line 158), the per-subnet function is
called in a loop:

for netuid in Self::get_all_subnet_netuids() {
    Self::perform_hotkey_swap_on_one_subnet(old_hotkey, new_hotkey, weight, netuid)?;
}
  • First iteration: transfer_root_claimable_for_new_hotkey transfers all rates.
    transfer_root_claimed_for_new_keys transfers RootClaimed for subnet 0.
  • Subsequent iterations: transfer_root_claimable_for_new_hotkey is a no-op
    (old hotkey already empty). transfer_root_claimed_for_new_keys transfers
    RootClaimed for each subsequent subnet.
  • End result: Both RootClaimable and RootClaimed fully transferred. ✅

For single-subnet swap via swap_hotkey_on_subnet (line 239):

Self::perform_hotkey_swap_on_one_subnet(old_hotkey, new_hotkey, &mut weight, netuid)?;
  • transfer_root_claimable_for_new_hotkey transfers all rates → old hotkey wiped
  • transfer_root_claimed_for_new_keys transfers RootClaimed for one subnet only
  • Old hotkey keeps root stake + RootClaimed for ~125 other subnets
  • End result: claimed >> claimable → zero dividends ❌

Proposed Fix

Code change

Move transfer_root_claimable_for_new_hotkey out of perform_hotkey_swap_on_one_subnet
and into perform_hotkey_swap_on_all_subnets only. For single-subnet swaps, the old
hotkey retains its root stake, so it must retain its accumulated RootClaimable rates.

--- a/pallets/subtensor/src/swap/swap_hotkey.rs
+++ b/pallets/subtensor/src/swap/swap_hotkey.rs
@@ -189,6 +189,9 @@ impl<T: Config> Pallet<T> {
         // 5. execute the hotkey swap on all subnets
         for netuid in Self::get_all_subnet_netuids() {
             Self::perform_hotkey_swap_on_one_subnet(old_hotkey, new_hotkey, weight, netuid)?;
         }
+
+        // 5.1. Transfer root claimable (all subnets at once, only for full swap)
+        Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey);

@@ -507,9 +510,6 @@ impl<T: Config> Pallet<T> {

-        // 9.1. Transfer root claimable
-
-        Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey);
-
         // 9.2.  Insert the new alpha values.

Migration

A storage migration is needed to repair currently-affected hotkeys:

For each hotkey where RootClaimed(netuid, hotkey, coldkey) > RootClaimable(hotkey)[netuid] × root_stake(hotkey, coldkey):

  • Reset RootClaimed down to the current claimable value, so owed returns to zero
    (rather than staying stuck in overclaimed state).

Alternatively, simply clear RootClaimed for affected hotkeys — they will lose any
small pending owed amounts but will immediately resume earning dividends.

Investigation Timeline

Step Finding
Queried RootClaimable + RootClaimed for hotkey 122/126 subnets overclaimed; total claimed 7× claimable
Historical root stake check Root stake was never > 25k TAO — cannot explain 35k claimed
Historical RootClaimable rate check rate_sum was 1.86 at 30d ago, 0.296 now — rates were reset
Binary search for reset block Narrowed to block 7,670,707
Block-by-block trace RootClaimable present at 7,670,706, null at 7,670,707
Other hotkeys check NOT affected — this was hotkey-specific, not global
Extrinsic decode Block 7,670,707 extrinsic #8 = SubtensorModule::swap_hotkey
Code analysis transfer_root_claimable_for_new_hotkey transfers all subnets; transfer_root_claimed_for_new_keys transfers only one

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions