Summary
During a hotkey swap, the runtime migrates the global delegate take (Delegates) and the childkey-take rate-limit bookkeeping (LastTxBlockChildKeyTake) from the old hotkey to the new hotkey, but it never migrates the actual per-subnet ChildkeyTake(hotkey, netuid) values. Because get_childkey_take falls back to the effective per-subnet minimum when no explicit value exists, the new hotkey silently starts at the subnet floor take on every subnet where the operator had configured a higher take. Until the operator notices and re-runs set_childkey_take, a larger share of validator dividends is redistributed to parents. The old hotkey's ChildkeyTake rows are also orphaned (not removed). This is self-affecting, fully recoverable via set_childkey_take, and never moves value outside the operator's own validator graph.
Details
The swap entrypoint do_swap_hotkey and its helpers move a long list of per-hotkey and per-(hotkey, netuid) storage items, but ChildkeyTake is conspicuously absent.
pallets/subtensor/src/swap/swap_hotkey.rs:117-120 migrates only the rate-limit timestamp for childkey take, not the take value itself:
// 11. Swap LastTxBlockChildKeyTake
let last_tx_block_child_key_take: u64 = Self::get_last_tx_block_childkey_take(old_hotkey);
Self::set_last_tx_block_childkey(new_hotkey, last_tx_block_child_key_take);
weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1));
pallets/subtensor/src/swap/swap_hotkey.rs:263-268 migrates the global Delegates take (and removes the old row), establishing the exact pattern that ChildkeyTake should mirror but does not:
// 10. Swap delegates.
// Delegates( hotkey ) -> take value -- the hotkey delegate take value.
if Delegates::<T>::contains_key(old_hotkey) {
let old_delegate_take = Delegates::<T>::get(old_hotkey);
Delegates::<T>::remove(old_hotkey);
Delegates::<T>::insert(new_hotkey, old_delegate_take);
weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2));
}
perform_hotkey_swap_on_one_subnet (the per-subnet routine, swap_hotkey.rs:391-646) migrates membership, UIDs/Keys, Prometheus, axons, weight commits, loaded emission, neuron certificates, parent/child relations, pending children, auto-stake destination, subnet-owner hotkey, dividend records, alpha, and root claimable — but there is no ChildkeyTake read/insert/remove anywhere in this function, nor in perform_hotkey_swap_on_all_subnets. A repo-wide search confirms the only ChildkeyTake::<T>::insert call site in non-test code is do_set_childkey_take:
pallets/subtensor/src/staking/set_children.rs:765 — ChildkeyTake::<T>::insert(hotkey.clone(), netuid, take); (the only writer)
The storage default is what makes this a silent reset rather than a hard error. get_childkey_take reads the raw value and clamps it up to the effective minimum:
pallets/subtensor/src/staking/set_children.rs:792-794:
pub fn get_childkey_take(hotkey: &T::AccountId, netuid: NetUid) -> u16 {
ChildkeyTake::<T>::get(hotkey, netuid).max(Self::get_effective_min_childkey_take(netuid))
}
ChildkeyTake is ValueQuery returning 0 when absent (pallets/subtensor/src/lib.rs:1196), so for a brand-new hotkey get_childkey_take returns get_effective_min_childkey_take(netuid) — the larger of the global MinChildkeyTake and the per-subnet MinChildkeyTakePerSubnet (pallets/subtensor/src/utils/misc.rs:425-427). The operator's previously configured (higher) take is therefore dropped to the subnet floor.
The economic consequence flows through the dividend split in get_parent_child_dividends_distribution:
pallets/subtensor/src/coinbase/run_coinbase.rs:882-884:
let child_take_proportion: U96F32 =
U96F32::saturating_from_num(Self::get_childkey_take(hotkey, netuid))
.safe_div(U96F32::saturating_from_num(u16::MAX));
A lower child_take_proportion means the validator (childkey operator) retains a smaller cut and a larger share of the validating emission is distributed to parents. So the take reset is not cosmetic: it redirects real emissions away from the operator to the parent hotkeys on every affected subnet until corrected.
Finally, the old ChildkeyTake(old_hotkey, netuid) rows are never removed by the swap. They become orphaned storage. (Note: they are not actively harmful because Owner(old_hotkey) is removed and the old hotkey is deregistered, so emission no longer reads them — but they are stale rows that only get cleaned up if/when the subnet is dissolved; see pallets/subtensor/src/coinbase/root.rs:407-415, which is the subnet-removal cleanup path, not the swap path.)
There is no migration that backfills or moves ChildkeyTake across a swap; migrate_fix_childkeys operates on ChildKeys/ParentKeys adjacency, not on the take value.
Proof of Concept / Attack Scenario
This is a self-inflicted state-loss bug, not an adversary-against-victim exploit. The "victim" and the "actor" are the same coldkey/operator.
Preconditions:
- An operator coldkey
C owns hotkey H_old that is registered on subnet N and acts as a childkey (has parents delegating to it).
C has set a non-minimum childkey take, e.g. set_childkey_take(H_old, N, take = 8000) (call_index 75), so ChildkeyTake(H_old, N) = 8000 while the subnet floor is, say, the global MinChildkeyTake.
Steps:
C submits swap_hotkey(H_old, H_new) (call_index 70) or the subnet-scoped swap_hotkey_v2(H_old, H_new, Some(N)) (call_index 72), signed by C, paying the swap cost.
- The swap moves
Delegates, parent/child links, stake, UIDs, etc., to H_new, but leaves ChildkeyTake(H_old, N) = 8000 in place and writes nothing for H_new.
- From the next coinbase epoch,
get_childkey_take(H_new, N) returns the subnet floor (e.g. the minimum) instead of 8000.
- In
get_parent_child_dividends_distribution, the operator's retained cut shrinks and the difference is paid out to the parent hotkeys of H_new on subnet N, every epoch.
What the operator loses: the delta (8000 - floor)/u16::MAX of the validating emission that would have been retained by their hotkey, redirected to parents, for as many epochs as it takes to notice. What they regain: nothing automatically — they must re-run set_childkey_take(H_new, N, 8000) for every affected subnet (subject to the increase rate limit) to restore the prior configuration.
Impact
- Who: Any operator who swaps a hotkey that had a configured (non-floor) childkey take on one or more subnets.
- Conditions: Triggered by the operator's own
swap_hotkey / swap_hotkey_v2 call; no third party can induce or exploit it against another account.
- Worst case: Per-subnet redirection of a slice of validator dividends to parent hotkeys for the window between the swap and the operator re-setting the take. The magnitude is bounded by
(old_take - subnet_floor)/u16::MAX of the childkey's validating emission per epoch, multiplied by the affected subnets. No funds leave the operator's broader graph in an attacker-controlled way; value flows to the parents the operator themselves delegated to.
- Integrity: Minor, self-affecting state-config loss plus orphaned
ChildkeyTake(old_hotkey, *) rows (storage bloat, not a safety issue).
- Availability / global integrity: None. No total-supply, conservation, or cross-account corruption. No permissionless theft.
- Mitigating factors: Fully and cheaply recoverable via
set_childkey_take; the operator controls both the trigger and the fix; the lost emission goes to the operator's own parents (often the same economic entity for root-validator → subnet-owner delegation chains); the take floor (not zero) limits how far the value can drop.
Affected Code
pallets/subtensor/src/swap/swap_hotkey.rs:117-120 — migrates LastTxBlockChildKeyTake only (rate-limit timestamp), not the take value.
pallets/subtensor/src/swap/swap_hotkey.rs:256-259 — removes LastTxBlockChildKeyTake from old hotkey (again, timestamp only).
pallets/subtensor/src/swap/swap_hotkey.rs:263-268 — migrates Delegates (the pattern ChildkeyTake should follow).
pallets/subtensor/src/swap/swap_hotkey.rs:391-646 — perform_hotkey_swap_on_one_subnet: migrates many per-(hotkey, netuid) items but never ChildkeyTake.
pallets/subtensor/src/staking/set_children.rs:765 — the sole ChildkeyTake::<T>::insert writer (do_set_childkey_take).
pallets/subtensor/src/staking/set_children.rs:792-794 — get_childkey_take floor-clamps to the effective minimum, turning a missing migration into a silent reset.
pallets/subtensor/src/coinbase/run_coinbase.rs:882-884 — emission split consumes get_childkey_take, so the reset redirects dividends.
pallets/subtensor/src/lib.rs:1196 — ChildkeyTake StorageDoubleMap (hotkey, netuid) -> u16, ValueQuery default 0.
pallets/subtensor/src/utils/misc.rs:425-427 — get_effective_min_childkey_take (the floor returned for a fresh hotkey).
Remediation
Migrate ChildkeyTake(hotkey, netuid) during the swap, mirroring Delegates, and remove the orphaned old rows.
For the per-subnet path, add it inside perform_hotkey_swap_on_one_subnet (which already has netuid in scope), so it covers both swap_hotkey (all subnets, called in a loop) and swap_hotkey_v2 (single subnet):
// Swap ChildkeyTake( hotkey, netuid ) -> u16 -- per-subnet childkey take.
if ChildkeyTake::<T>::contains_key(old_hotkey, netuid) {
let take = ChildkeyTake::<T>::take(old_hotkey, netuid); // read + remove
ChildkeyTake::<T>::insert(new_hotkey, netuid, take);
weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2));
}
Placing it in the per-subnet routine (rather than alongside the Delegates block) is preferable because ChildkeyTake is per-(hotkey, netuid): the all-subnets swap iterates every netuid via perform_hotkey_swap_on_one_subnet, and the subnet-scoped swap calls that same routine once for the target netuid. Using ::take both copies the value and clears the old row in one operation, eliminating the orphaned storage.
Notes:
- Only migrate when the key explicitly exists, to preserve default semantics (don't materialize a floor value for hotkeys that never set a take).
- The swap is already wrapped/charged as a single dispatch; no additional
with_storage_layer is required for this mechanical add.
Recommended regression test: in the swap test suite, set ChildkeyTake(old_hotkey, netuid) to a non-floor value via do_set_childkey_take, perform both swap_hotkey and swap_hotkey_v2, then assert ChildkeyTake::<T>::get(new_hotkey, netuid) equals the original take and ChildkeyTake::<T>::contains_key(old_hotkey, netuid) is false (no orphan). Optionally assert get_parent_child_dividends_distribution yields the same split before and after the swap.
Verification
I independently re-verified every claim against source at main @ 49164bd (spec_version 417):
- Confirmed
do_swap_hotkey migrates only LastTxBlockChildKeyTake for childkey-take, not the take value (swap_hotkey.rs:117-120, removal at 256-259).
- Confirmed
Delegates is migrated with the remove+insert pattern at swap_hotkey.rs:263-268, establishing the intended pattern that ChildkeyTake omits.
- Read the entire
perform_hotkey_swap_on_one_subnet (swap_hotkey.rs:391-646) and perform_hotkey_swap_on_all_subnets (203-293); confirmed no ChildkeyTake read/insert/remove anywhere in the swap path.
grep confirmed the only non-test ChildkeyTake::<T>::insert is set_children.rs:765 (do_set_childkey_take); there is no translate/swap/migration that moves it.
- Confirmed the silent-reset mechanism:
ChildkeyTake is ValueQuery default 0 (lib.rs:1196) and get_childkey_take clamps to get_effective_min_childkey_take (set_children.rs:792-794, misc.rs:425-427), so a fresh hotkey returns the subnet floor.
- Confirmed the economic consequence: the emission split reads
get_childkey_take(hotkey, netuid) at run_coinbase.rs:882-884; a lower take routes more emission to parents.
- Confirmed
ChildkeyTake is cleaned only on subnet dissolution (root.rs:407-415), not on swap, so old rows are orphaned but inert (Owner removed, hotkey deregistered) until subnet removal.
- Confirmed call indices:
swap_hotkey = 70, swap_hotkey_v2 = 72, set_childkey_take = 75 (macros/dispatches.rs).
Severity assessment vs. the seed: I concur with Low. The seed's framing is accurate. The issue is self-affecting (origin must own both hotkeys — swap_hotkey.rs:44-55), recoverable via set_childkey_take, redirects value only to the operator's own parents, and never violates conservation or enables cross-account theft. CVSS v3.1 base 3.7 (AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N): network vector, high attack complexity (requires the specific precondition of a non-floor configured take plus a swap, and the impact is a bounded recoverable economic drift rather than a deterministic exploit), low privileges (must own the hotkey), low integrity impact (config/state loss + storage orphan), no confidentiality or availability impact.
Reproduction
Status: REPRODUCED — passing compiled test.
- Test:
tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap
- File:
pallets/subtensor/src/tests/swap_hotkey.rs
- Run:
SKIP_WASM_BUILD=1 cargo test -p pallet-subtensor --lib -- tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap --exact
The test asserts the buggy behavior (and a contrast assertion of the safe/expected behavior); it passes against main @ 49164bd6 (spec_version 417), demonstrating the vulnerability is live.
Reproduction test source (the actual passing test)
// pallets/subtensor/src/tests/swap_hotkey.rs
// ---- GHSA-2026-014 (method: e2e) ----
// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap --exact --nocapture
#[test]
fn ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap() {
new_test_ext(1).execute_with(|| {
let netuid = NetUid::from(1);
let coldkey = U256::from(2);
let old_hotkey = U256::from(1);
let new_hotkey = U256::from(5);
let mut weight = Weight::zero();
add_network(netuid, 1, 0);
// Establish coldkey ownership of old_hotkey (Owner(old_hotkey) = coldkey).
SubtensorModule::create_account_if_non_existent(&coldkey, &old_hotkey)
.expect("account creation should succeed");
// The effective minimum (floor) childkey take in this mock is 0.
let floor_take = SubtensorModule::get_effective_min_childkey_take(netuid);
assert_eq!(floor_take, 0);
// Configure a NON-minimum childkey take on the old hotkey for this subnet.
// 5000 is well above the floor (0) and below the max (11_796).
let configured_childkey_take: u16 = 5000;
assert!(configured_childkey_take > floor_take);
ChildkeyTake::<Test>::insert(old_hotkey, netuid, configured_childkey_take);
// Configure a NON-default delegate take on the old hotkey for contrast.
// (Default delegate take in this mock is 11_796.)
let configured_delegate_take: u16 = 100;
Delegates::<Test>::insert(old_hotkey, configured_delegate_take);
// Sanity: pre-swap the old hotkey carries the configured values.
assert_eq!(
ChildkeyTake::<Test>::get(old_hotkey, netuid),
configured_childkey_take
);
assert_eq!(
SubtensorModule::get_childkey_take(&old_hotkey, netuid),
configured_childkey_take
);
assert_eq!(Delegates::<Test>::get(old_hotkey), configured_delegate_take);
// Perform the real hotkey swap on all subnets.
assert_ok!(SubtensorModule::perform_hotkey_swap_on_all_subnets(
&old_hotkey,
&new_hotkey,
&coldkey,
&mut weight,
false
));
// CONTRAST (safe behavior): Delegates take IS migrated to the new hotkey.
assert!(!Delegates::<Test>::contains_key(old_hotkey));
assert_eq!(Delegates::<Test>::get(new_hotkey), configured_delegate_take);
// BUG (GHSA-2026-014): ChildkeyTake is NOT migrated.
// The new hotkey silently drops to the storage default (0) / floor take,
// instead of carrying over the configured 5000.
assert_eq!(
ChildkeyTake::<Test>::get(new_hotkey, netuid),
0,
"BUG present: ChildkeyTake(new_hotkey) should have inherited the configured take but is default"
);
assert_ne!(
ChildkeyTake::<Test>::get(new_hotkey, netuid),
configured_childkey_take
);
// The effective getter for the new hotkey collapses to the floor take.
assert_eq!(
SubtensorModule::get_childkey_take(&new_hotkey, netuid),
floor_take
);
// BUG: the old hotkey's ChildkeyTake row is orphaned (left behind, not removed).
assert_eq!(
ChildkeyTake::<Test>::get(old_hotkey, netuid),
configured_childkey_take,
"BUG present: ChildkeyTake(old_hotkey) row is orphaned after swap"
);
assert!(ChildkeyTake::<Test>::contains_key(old_hotkey, netuid));
});
}
References
- Source: subtensor main @ 49164bd, spec_version 417
pallets/subtensor/src/swap/swap_hotkey.rs (swap state migration)
pallets/subtensor/src/staking/set_children.rs (ChildkeyTake storage, do_set_childkey_take, get_childkey_take)
pallets/subtensor/src/coinbase/run_coinbase.rs:882-884 (dividend split consumer)
pallets/subtensor/src/coinbase/root.rs:407-415 (subnet-dissolution cleanup of ChildkeyTake)
Patch
Fixed in opentensor/subtensor@d0e9c43bf701 (PR #18).
Live on mainnet as of spec_version 419 (main).
Summary
During a hotkey swap, the runtime migrates the global delegate take (
Delegates) and the childkey-take rate-limit bookkeeping (LastTxBlockChildKeyTake) from the old hotkey to the new hotkey, but it never migrates the actual per-subnetChildkeyTake(hotkey, netuid)values. Becauseget_childkey_takefalls back to the effective per-subnet minimum when no explicit value exists, the new hotkey silently starts at the subnet floor take on every subnet where the operator had configured a higher take. Until the operator notices and re-runsset_childkey_take, a larger share of validator dividends is redistributed to parents. The old hotkey'sChildkeyTakerows are also orphaned (not removed). This is self-affecting, fully recoverable viaset_childkey_take, and never moves value outside the operator's own validator graph.Details
The swap entrypoint
do_swap_hotkeyand its helpers move a long list of per-hotkey and per-(hotkey, netuid) storage items, butChildkeyTakeis conspicuously absent.pallets/subtensor/src/swap/swap_hotkey.rs:117-120migrates only the rate-limit timestamp for childkey take, not the take value itself:pallets/subtensor/src/swap/swap_hotkey.rs:263-268migrates the globalDelegatestake (and removes the old row), establishing the exact pattern thatChildkeyTakeshould mirror but does not:perform_hotkey_swap_on_one_subnet(the per-subnet routine,swap_hotkey.rs:391-646) migrates membership, UIDs/Keys, Prometheus, axons, weight commits, loaded emission, neuron certificates, parent/child relations, pending children, auto-stake destination, subnet-owner hotkey, dividend records, alpha, and root claimable — but there is noChildkeyTakeread/insert/remove anywhere in this function, nor inperform_hotkey_swap_on_all_subnets. A repo-wide search confirms the onlyChildkeyTake::<T>::insertcall site in non-test code isdo_set_childkey_take:pallets/subtensor/src/staking/set_children.rs:765—ChildkeyTake::<T>::insert(hotkey.clone(), netuid, take);(the only writer)The storage default is what makes this a silent reset rather than a hard error.
get_childkey_takereads the raw value and clamps it up to the effective minimum:pallets/subtensor/src/staking/set_children.rs:792-794:ChildkeyTakeisValueQueryreturning0when absent (pallets/subtensor/src/lib.rs:1196), so for a brand-new hotkeyget_childkey_takereturnsget_effective_min_childkey_take(netuid)— the larger of the globalMinChildkeyTakeand the per-subnetMinChildkeyTakePerSubnet(pallets/subtensor/src/utils/misc.rs:425-427). The operator's previously configured (higher) take is therefore dropped to the subnet floor.The economic consequence flows through the dividend split in
get_parent_child_dividends_distribution:pallets/subtensor/src/coinbase/run_coinbase.rs:882-884:A lower
child_take_proportionmeans the validator (childkey operator) retains a smaller cut and a larger share of the validating emission is distributed to parents. So the take reset is not cosmetic: it redirects real emissions away from the operator to the parent hotkeys on every affected subnet until corrected.Finally, the old
ChildkeyTake(old_hotkey, netuid)rows are never removed by the swap. They become orphaned storage. (Note: they are not actively harmful becauseOwner(old_hotkey)is removed and the old hotkey is deregistered, so emission no longer reads them — but they are stale rows that only get cleaned up if/when the subnet is dissolved; seepallets/subtensor/src/coinbase/root.rs:407-415, which is the subnet-removal cleanup path, not the swap path.)There is no migration that backfills or moves
ChildkeyTakeacross a swap;migrate_fix_childkeysoperates onChildKeys/ParentKeysadjacency, not on the take value.Proof of Concept / Attack Scenario
This is a self-inflicted state-loss bug, not an adversary-against-victim exploit. The "victim" and the "actor" are the same coldkey/operator.
Preconditions:
Cowns hotkeyH_oldthat is registered on subnetNand acts as a childkey (has parents delegating to it).Chas set a non-minimum childkey take, e.g.set_childkey_take(H_old, N, take = 8000)(call_index 75), soChildkeyTake(H_old, N) = 8000while the subnet floor is, say, the globalMinChildkeyTake.Steps:
Csubmitsswap_hotkey(H_old, H_new)(call_index 70) or the subnet-scopedswap_hotkey_v2(H_old, H_new, Some(N))(call_index 72), signed byC, paying the swap cost.Delegates, parent/child links, stake, UIDs, etc., toH_new, but leavesChildkeyTake(H_old, N) = 8000in place and writes nothing forH_new.get_childkey_take(H_new, N)returns the subnet floor (e.g. the minimum) instead of8000.get_parent_child_dividends_distribution, the operator's retained cut shrinks and the difference is paid out to the parent hotkeys ofH_newon subnetN, every epoch.What the operator loses: the delta
(8000 - floor)/u16::MAXof the validating emission that would have been retained by their hotkey, redirected to parents, for as many epochs as it takes to notice. What they regain: nothing automatically — they must re-runset_childkey_take(H_new, N, 8000)for every affected subnet (subject to the increase rate limit) to restore the prior configuration.Impact
swap_hotkey/swap_hotkey_v2call; no third party can induce or exploit it against another account.(old_take - subnet_floor)/u16::MAXof the childkey's validating emission per epoch, multiplied by the affected subnets. No funds leave the operator's broader graph in an attacker-controlled way; value flows to the parents the operator themselves delegated to.ChildkeyTake(old_hotkey, *)rows (storage bloat, not a safety issue).set_childkey_take; the operator controls both the trigger and the fix; the lost emission goes to the operator's own parents (often the same economic entity for root-validator → subnet-owner delegation chains); the take floor (not zero) limits how far the value can drop.Affected Code
pallets/subtensor/src/swap/swap_hotkey.rs:117-120— migratesLastTxBlockChildKeyTakeonly (rate-limit timestamp), not the take value.pallets/subtensor/src/swap/swap_hotkey.rs:256-259— removesLastTxBlockChildKeyTakefrom old hotkey (again, timestamp only).pallets/subtensor/src/swap/swap_hotkey.rs:263-268— migratesDelegates(the patternChildkeyTakeshould follow).pallets/subtensor/src/swap/swap_hotkey.rs:391-646—perform_hotkey_swap_on_one_subnet: migrates many per-(hotkey, netuid) items but neverChildkeyTake.pallets/subtensor/src/staking/set_children.rs:765— the soleChildkeyTake::<T>::insertwriter (do_set_childkey_take).pallets/subtensor/src/staking/set_children.rs:792-794—get_childkey_takefloor-clamps to the effective minimum, turning a missing migration into a silent reset.pallets/subtensor/src/coinbase/run_coinbase.rs:882-884— emission split consumesget_childkey_take, so the reset redirects dividends.pallets/subtensor/src/lib.rs:1196—ChildkeyTakeStorageDoubleMap(hotkey, netuid) -> u16,ValueQuerydefault 0.pallets/subtensor/src/utils/misc.rs:425-427—get_effective_min_childkey_take(the floor returned for a fresh hotkey).Remediation
Migrate
ChildkeyTake(hotkey, netuid)during the swap, mirroringDelegates, and remove the orphaned old rows.For the per-subnet path, add it inside
perform_hotkey_swap_on_one_subnet(which already hasnetuidin scope), so it covers bothswap_hotkey(all subnets, called in a loop) andswap_hotkey_v2(single subnet):Placing it in the per-subnet routine (rather than alongside the
Delegatesblock) is preferable becauseChildkeyTakeis per-(hotkey, netuid): the all-subnets swap iterates every netuid viaperform_hotkey_swap_on_one_subnet, and the subnet-scoped swap calls that same routine once for the target netuid. Using::takeboth copies the value and clears the old row in one operation, eliminating the orphaned storage.Notes:
with_storage_layeris required for this mechanical add.Recommended regression test: in the swap test suite, set
ChildkeyTake(old_hotkey, netuid)to a non-floor value viado_set_childkey_take, perform bothswap_hotkeyandswap_hotkey_v2, then assertChildkeyTake::<T>::get(new_hotkey, netuid)equals the original take andChildkeyTake::<T>::contains_key(old_hotkey, netuid)is false (no orphan). Optionally assertget_parent_child_dividends_distributionyields the same split before and after the swap.Verification
I independently re-verified every claim against source at main @ 49164bd (spec_version 417):
do_swap_hotkeymigrates onlyLastTxBlockChildKeyTakefor childkey-take, not the take value (swap_hotkey.rs:117-120, removal at256-259).Delegatesis migrated with the remove+insert pattern atswap_hotkey.rs:263-268, establishing the intended pattern thatChildkeyTakeomits.perform_hotkey_swap_on_one_subnet(swap_hotkey.rs:391-646) andperform_hotkey_swap_on_all_subnets(203-293); confirmed noChildkeyTakeread/insert/remove anywhere in the swap path.grepconfirmed the only non-testChildkeyTake::<T>::insertisset_children.rs:765(do_set_childkey_take); there is notranslate/swap/migration that moves it.ChildkeyTakeisValueQuerydefault 0 (lib.rs:1196) andget_childkey_takeclamps toget_effective_min_childkey_take(set_children.rs:792-794,misc.rs:425-427), so a fresh hotkey returns the subnet floor.get_childkey_take(hotkey, netuid)atrun_coinbase.rs:882-884; a lower take routes more emission to parents.ChildkeyTakeis cleaned only on subnet dissolution (root.rs:407-415), not on swap, so old rows are orphaned but inert (Owner removed, hotkey deregistered) until subnet removal.swap_hotkey= 70,swap_hotkey_v2= 72,set_childkey_take= 75 (macros/dispatches.rs).Severity assessment vs. the seed: I concur with Low. The seed's framing is accurate. The issue is self-affecting (origin must own both hotkeys —
swap_hotkey.rs:44-55), recoverable viaset_childkey_take, redirects value only to the operator's own parents, and never violates conservation or enables cross-account theft. CVSS v3.1 base 3.7 (AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N): network vector, high attack complexity (requires the specific precondition of a non-floor configured take plus a swap, and the impact is a bounded recoverable economic drift rather than a deterministic exploit), low privileges (must own the hotkey), low integrity impact (config/state loss + storage orphan), no confidentiality or availability impact.Reproduction
Status: REPRODUCED — passing compiled test.
tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swappallets/subtensor/src/tests/swap_hotkey.rsSKIP_WASM_BUILD=1 cargo test -p pallet-subtensor --lib -- tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap --exactThe test asserts the buggy behavior (and a contrast assertion of the safe/expected behavior); it passes against
main@49164bd6(spec_version 417), demonstrating the vulnerability is live.Reproduction test source (the actual passing test)
References
pallets/subtensor/src/swap/swap_hotkey.rs(swap state migration)pallets/subtensor/src/staking/set_children.rs(ChildkeyTake storage,do_set_childkey_take,get_childkey_take)pallets/subtensor/src/coinbase/run_coinbase.rs:882-884(dividend split consumer)pallets/subtensor/src/coinbase/root.rs:407-415(subnet-dissolution cleanup ofChildkeyTake)Patch
Fixed in
opentensor/subtensor@d0e9c43bf701(PR #18).Live on mainnet as of spec_version 419 (
main).