Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions node/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ pub struct CloneStateCmd {
#[arg(long, value_name = "BOOTNODE")]
pub bootnodes: Vec<String>,

/// Optional reserved nodes for the sync step. Repeatable.
#[arg(long, value_name = "RESERVED_NODE")]
pub reserved_nodes: Vec<String>,

/// Include Alice in patched validator authorities (default if no validator flags are passed;
/// Sudo is assigned to the first selected validator in Alice->Bob->Charlie order).
#[arg(long, default_value_t = false)]
Expand Down
6 changes: 6 additions & 0 deletions node/src/clone_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ async fn async_run(cmd: &CloneStateCmd, skip_history_backfill: bool) -> CloneRes
sync_args.push(bootnode.clone());
}

for reserved_node in &cmd.reserved_nodes {
sync_args.push("--reserved-nodes".to_string());
sync_args.push(reserved_node.clone());
}

log::info!("build-patched-spec: starting sync node");

let mut sync_child = Command::new(&current_exe)
Expand Down Expand Up @@ -545,6 +550,7 @@ mod tests {
sync_timeout_sec: 10,
sync_lag_blocks: 8,
bootnodes: Vec::new(),
reserved_nodes: Vec::new(),
alice: false,
bob: false,
charlie: false,
Expand Down
69 changes: 67 additions & 2 deletions pallets/admin-utils/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ use frame_support::{
use frame_system::Config;
use pallet_subtensor::{
Error as SubtensorError, Event, MaxRegistrationsPerBlock, SubnetOwner,
TargetRegistrationsPerInterval, Tempo, WeightsVersionKeyRateLimit,
TargetRegistrationsPerInterval, Tempo, WeightsVersionKeyRateLimit, staking::lock::LockState,
subnets::mechanism::MAX_MECHANISM_COUNT_PER_SUBNET, utils::rate_limiting::TransactionType, *,
};
use sp_consensus_grandpa::AuthorityId as GrandpaId;
use sp_core::{Get, Pair, U256, ed25519};
use sp_runtime::PerU16;
use substrate_fixed::types::I96F32;
use substrate_fixed::types::{I96F32, U64F64};
use subtensor_runtime_common::{MechId, NetUid, TaoBalance, Token};
pub mod mock;
use mock::*;
Expand Down Expand Up @@ -2214,11 +2214,58 @@ fn test_set_sn_owner_hotkey_owner() {
fn test_set_sn_owner_hotkey_root() {
new_test_ext().execute_with(|| {
let netuid = NetUid::from(1);
let old_hotkey = U256::from(2);
let hotkey: U256 = U256::from(3);
add_network(netuid, 10);

let owner = U256::from(10);
let old_perpetual_coldkey = U256::from(11);
let old_decaying_coldkey = U256::from(12);
let new_perpetual_coldkey = U256::from(13);
let new_decaying_coldkey = U256::from(14);
pallet_subtensor::SubnetOwner::<Test>::insert(netuid, owner);
pallet_subtensor::SubnetOwnerHotkey::<Test>::insert(netuid, old_hotkey);
let now = SubtensorModule::get_current_block_as_u64();
let old_owner_lock = LockState {
locked_mass: 1_000u64.into(),
conviction: U64F64::from_num(1_000),
last_update: now,
};
let new_owner_lock = LockState {
locked_mass: 2_000u64.into(),
conviction: U64F64::from_num(2_000),
last_update: now,
};
pallet_subtensor::DecayingLock::<Test>::insert(old_perpetual_coldkey, netuid, false);
pallet_subtensor::DecayingLock::<Test>::insert(new_perpetual_coldkey, netuid, false);
SubtensorModule::insert_lock_state(
&old_perpetual_coldkey,
netuid,
&old_hotkey,
old_owner_lock.clone(),
);
SubtensorModule::insert_lock_state(
&old_decaying_coldkey,
netuid,
&old_hotkey,
old_owner_lock.clone(),
);
SubtensorModule::insert_lock_state(
&new_perpetual_coldkey,
netuid,
&hotkey,
new_owner_lock.clone(),
);
SubtensorModule::insert_lock_state(
&new_decaying_coldkey,
netuid,
&hotkey,
new_owner_lock.clone(),
);
SubtensorModule::insert_owner_lock_state(netuid, old_owner_lock.clone());
SubtensorModule::insert_decaying_owner_lock_state(netuid, old_owner_lock);
SubtensorModule::insert_hotkey_lock_state(netuid, &hotkey, new_owner_lock.clone());
SubtensorModule::insert_decaying_hotkey_lock_state(netuid, &hotkey, new_owner_lock);

// Root can set the hotkey
assert_ok!(AdminUtils::sudo_set_sn_owner_hotkey(
Expand All @@ -2230,6 +2277,24 @@ fn test_set_sn_owner_hotkey_root() {
// Check the value
let actual_hotkey = pallet_subtensor::SubnetOwnerHotkey::<Test>::get(netuid);
assert_eq!(actual_hotkey, hotkey);
assert_eq!(
pallet_subtensor::HotkeyLock::<Test>::get(netuid, old_hotkey)
.map(|lock| lock.locked_mass),
Some(1_000u64.into())
);
assert_eq!(
pallet_subtensor::DecayingHotkeyLock::<Test>::get(netuid, old_hotkey)
.map(|lock| lock.locked_mass),
Some(1_000u64.into())
);
assert_eq!(
pallet_subtensor::OwnerLock::<Test>::get(netuid).map(|lock| lock.locked_mass),
Some(2_000u64.into())
);
assert_eq!(
pallet_subtensor::DecayingOwnerLock::<Test>::get(netuid).map(|lock| lock.locked_mass),
Some(2_000u64.into())
);
});
}

Expand Down
4 changes: 3 additions & 1 deletion pallets/subtensor/src/macros/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ mod hooks {
.saturating_add(migrations::migrate_storage_bloat_v2::kickoff_storage_bloat_cleanup::<T>())
// Schedule stale StakingHotkeys relationship cleanup. It runs after storage GC
// and uses only otherwise-unused on_idle weight; normal operations stay enabled.
.saturating_add(migrations::migrate_cleanup_staking_hotkeys::kickoff_staking_hotkeys_cleanup::<T>());
.saturating_add(migrations::migrate_cleanup_staking_hotkeys::kickoff_staking_hotkeys_cleanup::<T>())
// Rebuild the corrupted conviction lock aggregates
.saturating_add(migrations::migrate_rebuild_conviction_aggregates::migrate_rebuild_conviction_aggregates::<T>());
// The beta-baseline seed (`migrate_stamp_beta_baselines`) runs from the
// runtime `Migrations` tuple instead of this hook, so try-runtime validates
// its pre/post-upgrade invariants against real network state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,6 @@ fn is_non_zero_lock(lock: &LockState) -> bool {
!lock.locked_mass.is_zero() || lock.conviction > U64F64::saturating_from_num(0)
}

fn add_lock_state(mut lhs: LockState, rhs: &LockState) -> LockState {
lhs.locked_mass = lhs.locked_mass.saturating_add(rhs.locked_mass);
lhs.conviction = lhs.conviction.saturating_add(rhs.conviction);
lhs.last_update = lhs.last_update.max(rhs.last_update);
lhs
}

fn subtract_lock_state(mut lhs: LockState, rhs: &LockState) -> LockState {
lhs.locked_mass = lhs.locked_mass.saturating_sub(rhs.locked_mass);
lhs.conviction = lhs.conviction.saturating_sub(rhs.conviction);
Expand Down Expand Up @@ -212,25 +205,25 @@ fn add_to_aggregate<T: Config>(
match (owner, perpetual) {
(true, true) => OwnerLock::<T>::mutate(netuid, |maybe_lock| {
*maybe_lock = Some(match maybe_lock.take() {
Some(lock) => add_lock_state(lock, added),
Some(lock) => lock.add(added),
None => added.clone(),
});
}),
(true, false) => DecayingOwnerLock::<T>::mutate(netuid, |maybe_lock| {
*maybe_lock = Some(match maybe_lock.take() {
Some(lock) => add_lock_state(lock, added),
Some(lock) => lock.add(added),
None => added.clone(),
});
}),
(false, true) => HotkeyLock::<T>::mutate(netuid, hotkey, |maybe_lock| {
*maybe_lock = Some(match maybe_lock.take() {
Some(lock) => add_lock_state(lock, added),
Some(lock) => lock.add(added),
None => added.clone(),
});
}),
(false, false) => DecayingHotkeyLock::<T>::mutate(netuid, hotkey, |maybe_lock| {
*maybe_lock = Some(match maybe_lock.take() {
Some(lock) => add_lock_state(lock, added),
Some(lock) => lock.add(added),
None => added.clone(),
});
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub fn migrate_populate_locking_coldkeys<T: Config>() -> Weight {
Subtensor::<T>::read_conviction_model_for_hotkey(&coldkey, netuid, &hotkey, now);
model.roll_forward(now, unlock_rate, maturity_rate);

if model.individual_lock().is_zero() {
if model.individual_lock().is_dust() {
removed_count = removed_count.saturating_add(1);
} else {
indexed_count = indexed_count.saturating_add(1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
use alloc::{collections::BTreeMap, string::String, vec::Vec};
use frame_support::{traits::Get, weights::Weight};

use crate::{
Config, DecayingHotkeyLock, DecayingLock, DecayingOwnerLock, HasMigrationRun, HotkeyLock, Lock,
LockingColdkeys, MaturityRate, OwnerLock, Pallet as Subtensor, SubnetOwnerHotkey, UnlockRate,
staking::lock::{LockState, roll_lock_state},
};
use subtensor_runtime_common::NetUid;

const MIGRATION_NAME: &[u8] = b"migrate_rebuild_conviction_aggregates";

// Mainnet archive scan at block 8_793_919 on 2026-08-07:
// - 352 Lock rows and 352 matching LockingColdkeys rows
// - 193 aggregate rows across the four aggregate maps
// - 125 subnets with locks, with at most 44 Lock rows on any one subnet
//
// This is intentionally a one-shot runtime-upgrade scan of small existing
// state, not an operation placed on a recurring block path. Keep these
// measurements next to the migration so its practical bound and review
// rationale are not lost.
const OBSERVED_MAINNET_LOCK_ROWS: u64 = 352;
const OBSERVED_MAINNET_AGGREGATE_ROWS: u64 = 193;
const OBSERVED_MAINNET_MAX_LOCKS_PER_SUBNET: u64 = 44;
const OBSERVED_MAINNET_BLOCK: u64 = 8_793_919;

fn merge_into<K: Ord>(aggregates: &mut BTreeMap<K, LockState>, key: K, lock: &LockState) {
if let Some(aggregate) = aggregates.get_mut(&key) {
*aggregate = aggregate.add(lock);
} else {
aggregates.insert(key, lock.clone());
}
}

/// Rebuilds conviction aggregates from canonical individual lock rows.
///
/// Runtime v443 could advance an aggregate timestamp after applying only one
/// member's roll delta. Once that happened, the aggregate was no longer the
/// sum of its members at its advertised timestamp. There is no safe way to
/// repair such a bucket incrementally, so this migration ignores every stored
/// aggregate and reconstructs all four maps from `Lock`.
///
/// Each individual is first rolled to the runtime-upgrade block using its
/// current lock mode and owner role. The rolled row is persisted (or removed
/// if it has become dust), then merged into its appropriate new aggregate.
/// This preserves earned conviction while establishing one common timestamp
/// for every individual and aggregate contribution.
pub fn migrate_rebuild_conviction_aggregates<T: Config>() -> Weight {
let mut weight = T::DbWeight::get().reads(1);

if HasMigrationRun::<T>::get(MIGRATION_NAME) {
log::info!(
"Migration '{}' already executed - skipping",
String::from_utf8_lossy(MIGRATION_NAME)
);
return weight;
}

log::info!(
"Running migration '{}'. Mainnet scan at block {} observed {} individual locks, \
{} aggregate rows, and at most {} locks on one subnet",
String::from_utf8_lossy(MIGRATION_NAME),
OBSERVED_MAINNET_BLOCK,
OBSERVED_MAINNET_LOCK_ROWS,
OBSERVED_MAINNET_AGGREGATE_ROWS,
OBSERVED_MAINNET_MAX_LOCKS_PER_SUBNET,
);

let now = Subtensor::<T>::get_current_block_as_u64();
let unlock_rate = UnlockRate::<T>::get();
let maturity_rate = MaturityRate::<T>::get();
weight = weight.saturating_add(T::DbWeight::get().reads(3));

// Collect before rewriting Lock so mutation cannot disturb the iterator.
let locks: Vec<_> = Lock::<T>::iter().collect();

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 materializes every lock without a hard bound

on_runtime_upgrade collects the entire permissionlessly growable Lock map into WASM memory, then clears several maps and rewrites every retained row in the same upgrade block. The archive snapshot is not a protocol bound and state can grow before deployment; returning the consumed weight only after execution cannot prevent an overweight or memory-exhausting upgrade. Stage this migration with a cursor and per-block limit, or enforce and validate a hard storage bound before performing the rebuild.

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 materializes every lock without a hard bound

This runtime upgrade collects the entire Lock map and then clears and rebuilds several maps in one block. The observed mainnet row count is not an enforced bound; unexpected state growth can make the upgrade exceed block resources and jeopardize chain liveness. Stage the migration across blocks or enforce a proven hard bound before upgrading.

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 materializes every lock without a hard bound

This migration is now invoked synchronously from on_runtime_upgrade, yet it collects every Lock row and subsequently clears/rebuilds several complete maps with u32::MAX limits. The archived count is only an observation, not an enforced bound; lock state can grow before deployment. An oversized upgrade can exceed block resources and halt runtime-upgrade execution. Stage this repair across bounded pages or enforce a protocol-level maximum proven within the upgrade budget.

let scanned_count = locks.len() as u64;
weight = weight.saturating_add(T::DbWeight::get().reads(scanned_count));

let locking_coldkeys_removal = LockingColdkeys::<T>::clear(u32::MAX, None);
weight = weight.saturating_add(T::DbWeight::get().reads_writes(
locking_coldkeys_removal.loops as u64,
locking_coldkeys_removal.backend as u64,
));

let hotkey_removal = HotkeyLock::<T>::clear(u32::MAX, None);
weight = weight.saturating_add(
T::DbWeight::get().reads_writes(hotkey_removal.loops as u64, hotkey_removal.backend as u64),
);

let decaying_hotkey_removal = DecayingHotkeyLock::<T>::clear(u32::MAX, None);
weight = weight.saturating_add(T::DbWeight::get().reads_writes(
decaying_hotkey_removal.loops as u64,
decaying_hotkey_removal.backend as u64,
));

let owner_removal = OwnerLock::<T>::clear(u32::MAX, None);
weight = weight.saturating_add(
T::DbWeight::get().reads_writes(owner_removal.loops as u64, owner_removal.backend as u64),
);

let decaying_owner_removal = DecayingOwnerLock::<T>::clear(u32::MAX, None);
weight = weight.saturating_add(T::DbWeight::get().reads_writes(
decaying_owner_removal.loops as u64,
decaying_owner_removal.backend as u64,
));

let mut perpetual_general = BTreeMap::<(NetUid, T::AccountId), LockState>::new();
let mut decaying_general = BTreeMap::<(NetUid, T::AccountId), LockState>::new();
let mut perpetual_owner = BTreeMap::<NetUid, LockState>::new();
let mut decaying_owner = BTreeMap::<NetUid, LockState>::new();
let mut retained_count = 0u64;
let mut removed_dust_count = 0u64;

for ((coldkey, netuid, hotkey), lock) in locks {
let owner_lock = SubnetOwnerHotkey::<T>::get(netuid) == hotkey;
let perpetual_lock = DecayingLock::<T>::get(&coldkey, netuid) == Some(false);
weight = weight.saturating_add(T::DbWeight::get().reads(2));

let rolled = roll_lock_state(
lock,
now,
unlock_rate,
maturity_rate,
owner_lock,
perpetual_lock,
);

if rolled.is_dust() {
Lock::<T>::remove((&coldkey, netuid, &hotkey));
removed_dust_count = removed_dust_count.saturating_add(1);
weight = weight.saturating_add(T::DbWeight::get().writes(1));
continue;
}

Lock::<T>::insert((&coldkey, netuid, &hotkey), rolled.clone());
LockingColdkeys::<T>::insert((netuid, &hotkey, &coldkey), ());
retained_count = retained_count.saturating_add(1);
weight = weight.saturating_add(T::DbWeight::get().writes(2));

match (owner_lock, perpetual_lock) {
(true, true) => merge_into(&mut perpetual_owner, netuid, &rolled),
(true, false) => merge_into(&mut decaying_owner, netuid, &rolled),
(false, true) => {
merge_into(&mut perpetual_general, (netuid, hotkey), &rolled);
}
(false, false) => {
merge_into(&mut decaying_general, (netuid, hotkey), &rolled);
}
}
}

let aggregate_count = perpetual_general
.len()
.saturating_add(decaying_general.len())
.saturating_add(perpetual_owner.len())
.saturating_add(decaying_owner.len()) as u64;

for ((netuid, hotkey), lock) in perpetual_general {
HotkeyLock::<T>::insert(netuid, hotkey, lock);
}
for ((netuid, hotkey), lock) in decaying_general {
DecayingHotkeyLock::<T>::insert(netuid, hotkey, lock);
}
for (netuid, lock) in perpetual_owner {
OwnerLock::<T>::insert(netuid, lock);
}
for (netuid, lock) in decaying_owner {
DecayingOwnerLock::<T>::insert(netuid, lock);
}
weight = weight.saturating_add(T::DbWeight::get().writes(aggregate_count));

HasMigrationRun::<T>::insert(MIGRATION_NAME, true);
weight = weight.saturating_add(T::DbWeight::get().writes(1));

log::info!(
"Migration '{}' completed. scanned_entries={}, retained_entries={}, \
removed_dust_entries={}, rebuilt_aggregate_entries={}",
String::from_utf8_lossy(MIGRATION_NAME),
scanned_count,
retained_count,
removed_dust_count,
aggregate_count,
);

weight
}
1 change: 1 addition & 0 deletions pallets/subtensor/src/migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub mod migrate_rao;
pub mod migrate_rate_limit_keys;
pub mod migrate_rate_limiting_last_blocks;
pub mod migrate_rebase_recycled_alpha_asset_counters;
pub mod migrate_rebuild_conviction_aggregates;
pub mod migrate_remove_add_stake_burn_rate_limit;
pub mod migrate_remove_commitments_rate_limit;
pub mod migrate_remove_deprecated_conviction_maps;
Expand Down
Loading
Loading