diff --git a/eco-tests/src/helpers.rs b/eco-tests/src/helpers.rs index c6fa0ec72d..146c3c17e5 100644 --- a/eco-tests/src/helpers.rs +++ b/eco-tests/src/helpers.rs @@ -87,9 +87,9 @@ pub fn next_block_no_epoch(netuid: NetUid) -> u64 { let high_tempo: u16 = u16::MAX - 1; let old_tempo: u16 = SubtensorModule::get_tempo(netuid); - SubtensorModule::set_tempo(netuid, high_tempo); + SubtensorModule::set_tempo_unchecked(netuid, high_tempo); let new_block = next_block(); - SubtensorModule::set_tempo(netuid, old_tempo); + SubtensorModule::set_tempo_unchecked(netuid, old_tempo); new_block } @@ -99,14 +99,14 @@ pub fn run_to_block_no_epoch(netuid: NetUid, n: u64) { let high_tempo: u16 = u16::MAX - 1; let old_tempo: u16 = SubtensorModule::get_tempo(netuid); - SubtensorModule::set_tempo(netuid, high_tempo); + SubtensorModule::set_tempo_unchecked(netuid, high_tempo); run_to_block(n); - SubtensorModule::set_tempo(netuid, old_tempo); + SubtensorModule::set_tempo_unchecked(netuid, old_tempo); } pub fn step_epochs(count: u16, netuid: NetUid) { for _ in 0..count { - let blocks_to_next_epoch = SubtensorModule::blocks_until_next_epoch( + let blocks_to_next_epoch = SubtensorModule::blocks_until_next_auto_epoch( netuid, SubtensorModule::get_tempo(netuid), SubtensorModule::get_current_block_as_u64(), diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index ccf047b2b3..0d2de73c62 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -983,7 +983,7 @@ pub mod pallet { pallet_subtensor::Pallet::::if_subnet_exist(netuid), Error::::SubnetDoesNotExist ); - pallet_subtensor::Pallet::::set_tempo(netuid, tempo); + pallet_subtensor::Pallet::::apply_tempo_with_cycle_reset(netuid, tempo); log::debug!("TempoSet( netuid: {netuid:?} tempo: {tempo:?} ) "); Ok(()) } diff --git a/pallets/admin-utils/src/tests/mod.rs b/pallets/admin-utils/src/tests/mod.rs index 61b9662492..f3373c3bf6 100644 --- a/pallets/admin-utils/src/tests/mod.rs +++ b/pallets/admin-utils/src/tests/mod.rs @@ -2042,7 +2042,7 @@ fn test_sudo_set_admin_freeze_window_and_rate() { fn test_freeze_window_blocks_root_and_owner() { new_test_ext().execute_with(|| { let netuid = NetUid::from(1); - let tempo = 10; + let tempo: u16 = 10; // Create subnet with tempo 10 add_network(netuid, tempo); // Set freeze window to 3 blocks @@ -2050,8 +2050,12 @@ fn test_freeze_window_blocks_root_and_owner() { <::RuntimeOrigin>::root(), 3 )); - // Advance to a block where remaining < 3 - run_to_block((tempo - 2).into()); + // Pin the state-based scheduler so the next auto-epoch lands at + // `LastEpochBlock + tempo`. Freeze window covers blocks (next_auto - 3, next_auto]. + pallet_subtensor::LastEpochBlock::::insert(netuid, 0); + let next_auto = tempo as u64; + // Advance to a block inside the freeze window (remaining < 3). + run_to_block(next_auto - 2); // Root should be blocked during freeze window assert_noop!( @@ -2147,7 +2151,7 @@ fn test_owner_hyperparam_update_rate_limit_enforced() { SubnetOwner::::insert(netuid, owner); // Set tempo to 1 so owner hyperparam RL = 2 tempos = 2 blocks - SubtensorModule::set_tempo(netuid, 1); + SubtensorModule::set_tempo_unchecked(netuid, 1); // Disable admin freeze window to avoid blocking on small tempo assert_ok!(AdminUtils::sudo_set_admin_freeze_window( <::RuntimeOrigin>::root(), @@ -2202,7 +2206,7 @@ fn test_hyperparam_rate_limit_enforced_by_tempo() { SubnetOwner::::insert(netuid, owner); // Set tempo to 1 so RL = 2 blocks - SubtensorModule::set_tempo(netuid, 1); + SubtensorModule::set_tempo_unchecked(netuid, 1); // Disable admin freeze window to avoid blocking on small tempo assert_ok!(AdminUtils::sudo_set_admin_freeze_window( <::RuntimeOrigin>::root(), @@ -2250,7 +2254,7 @@ fn test_owner_hyperparam_rate_limit_independent_per_param() { SubnetOwner::::insert(netuid, owner); // Use small tempo to make RL short and deterministic (2 blocks when tempo=1) - SubtensorModule::set_tempo(netuid, 1); + SubtensorModule::set_tempo_unchecked(netuid, 1); // Disable admin freeze window so it doesn't interfere with small tempo assert_ok!(AdminUtils::sudo_set_admin_freeze_window( <::RuntimeOrigin>::root(), diff --git a/pallets/subtensor/runtime-api/src/lib.rs b/pallets/subtensor/runtime-api/src/lib.rs index 741facfc87..e627aa5fa9 100644 --- a/pallets/subtensor/runtime-api/src/lib.rs +++ b/pallets/subtensor/runtime-api/src/lib.rs @@ -50,6 +50,7 @@ sp_api::decl_runtime_apis! { fn get_selective_mechagraph(netuid: NetUid, subid: MechId, metagraph_indexes: Vec) -> Option>; fn get_subnet_to_prune() -> Option; fn get_subnet_account_id(netuid: NetUid) -> Option; + fn get_next_epoch_start_block(netuid: NetUid) -> Option; } pub trait StakeInfoRuntimeApi { diff --git a/pallets/subtensor/src/benchmarks.rs b/pallets/subtensor/src/benchmarks.rs index 1dd62bab0b..b8aec160bf 100644 --- a/pallets/subtensor/src/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks.rs @@ -440,19 +440,15 @@ mod pallet_benchmarks { salt.clone(), version_key, )); - let commit_block = Subtensor::::get_current_block_as_u64(); assert_ok!(Subtensor::::commit_weights( RawOrigin::Signed(hotkey.clone()).into(), netuid, commit_hash, )); - let (first_reveal_block, _) = Subtensor::::get_reveal_blocks(netuid, commit_block); - let reveal_block: BlockNumberFor = first_reveal_block - .try_into() - .ok() - .expect("can't convert to block number"); - frame_system::Pallet::::set_block_number(reveal_block); + // Advance the epoch counter into the commit's reveal window. + let reveal_period = Subtensor::::get_reveal_period(netuid); + SubnetEpochIndex::::mutate(netuid, |e| *e = e.saturating_add(reveal_period)); #[extrinsic_call] _( @@ -676,7 +672,6 @@ mod pallet_benchmarks { let mut salts_list = Vec::new(); let mut version_keys = Vec::new(); - let commit_block = Subtensor::::get_current_block_as_u64(); for i in 0..num_commits { let uids = vec![0u16]; let values = vec![i as u16]; @@ -704,12 +699,9 @@ mod pallet_benchmarks { version_keys.push(version_key_i); } - let (first_reveal_block, _) = Subtensor::::get_reveal_blocks(netuid, commit_block); - let reveal_block: BlockNumberFor = first_reveal_block - .try_into() - .ok() - .expect("can't convert to block number"); - frame_system::Pallet::::set_block_number(reveal_block); + // Advance the epoch counter into the reveal window for these commits. + let reveal_period = Subtensor::::get_reveal_period(netuid); + SubnetEpochIndex::::mutate(netuid, |e| *e = e.saturating_add(reveal_period)); #[extrinsic_call] _( @@ -2148,6 +2140,54 @@ mod pallet_benchmarks { ); } + #[benchmark] + fn set_tempo() { + let netuid = NetUid::from(1); + let coldkey: T::AccountId = account("Owner", 0, 1); + + Subtensor::::init_new_network(netuid, 1u16); + SubnetOwner::::insert(netuid, coldkey.clone()); + SubtokenEnabled::::insert(netuid, true); + Subtensor::::set_commit_reveal_weights_enabled(netuid, false); + Subtensor::::set_admin_freeze_window(0); + + #[extrinsic_call] + _(RawOrigin::Signed(coldkey.clone()), netuid, MIN_TEMPO); + } + + #[benchmark] + fn set_activity_cutoff_factor() { + let netuid = NetUid::from(1); + let coldkey: T::AccountId = account("Owner", 0, 1); + + Subtensor::::init_new_network(netuid, 1u16); + SubnetOwner::::insert(netuid, coldkey.clone()); + SubtokenEnabled::::insert(netuid, true); + Subtensor::::set_admin_freeze_window(0); + + #[extrinsic_call] + _( + RawOrigin::Signed(coldkey.clone()), + netuid, + INITIAL_ACTIVITY_CUTOFF_FACTOR_MILLI, + ); + } + + #[benchmark] + fn trigger_epoch() { + let netuid = NetUid::from(1); + let coldkey: T::AccountId = account("Owner", 0, 1); + + Subtensor::::init_new_network(netuid, 1u16); + SubnetOwner::::insert(netuid, coldkey.clone()); + SubtokenEnabled::::insert(netuid, true); + Subtensor::::set_commit_reveal_weights_enabled(netuid, false); + Subtensor::::set_admin_freeze_window(0); + + #[extrinsic_call] + _(RawOrigin::Signed(coldkey.clone()), netuid); + } + impl_benchmark_test_suite!( Subtensor, crate::tests::mock::new_test_ext(1), diff --git a/pallets/subtensor/src/coinbase/block_step.rs b/pallets/subtensor/src/coinbase/block_step.rs index fac924ccf4..0eadbf5bf2 100644 --- a/pallets/subtensor/src/coinbase/block_step.rs +++ b/pallets/subtensor/src/coinbase/block_step.rs @@ -36,9 +36,11 @@ impl Pallet { } fn try_set_pending_children(block_number: u64) { + // Called *after* `run_coinbase` has advanced `LastEpochBlock` for any + // subnet whose epoch slot fired this block — `should_run_epoch` is no + // longer true. Detect "epoch just fired" by `LastEpochBlock == block`. for netuid in Self::get_all_subnet_netuids() { - if Self::should_run_epoch(netuid, block_number) { - // Set pending children on the epoch. + if LastEpochBlock::::get(netuid) == block_number { Self::do_set_pending_children(netuid); } } diff --git a/pallets/subtensor/src/coinbase/mod.rs b/pallets/subtensor/src/coinbase/mod.rs index c51bf58d1d..5184e2e3c0 100644 --- a/pallets/subtensor/src/coinbase/mod.rs +++ b/pallets/subtensor/src/coinbase/mod.rs @@ -7,3 +7,4 @@ pub mod root; pub mod run_coinbase; pub mod subnet_emissions; pub mod tao; +pub mod tempo_control; diff --git a/pallets/subtensor/src/coinbase/reveal_commits.rs b/pallets/subtensor/src/coinbase/reveal_commits.rs index 3d43cfba29..a5cddd6856 100644 --- a/pallets/subtensor/src/coinbase/reveal_commits.rs +++ b/pallets/subtensor/src/coinbase/reveal_commits.rs @@ -38,8 +38,9 @@ impl Pallet { /// The `reveal_crv3_commits` function is run at the very beginning of epoch `n`, pub fn reveal_crv3_commits_for_subnet(netuid: NetUid) -> dispatch::DispatchResult { let reveal_period = Self::get_reveal_period(netuid); - let cur_block = Self::get_current_block_as_u64(); - let cur_epoch = Self::get_epoch_index(netuid, cur_block); + // If the subnet is deferred past this block the + // commits are taken once here and the later block(s) become no-ops. + let cur_epoch = Self::current_epoch_with_lookahead(netuid); // Weights revealed must have been committed during epoch `cur_epoch - reveal_period`. let reveal_epoch = cur_epoch.saturating_sub(reveal_period); diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index b44d76175a..bc90077832 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -284,6 +284,10 @@ impl Pallet { MaxAllowedUids::::remove(netuid); ImmunityPeriod::::remove(netuid); ActivityCutoff::::remove(netuid); + ActivityCutoffFactorMilli::::remove(netuid); + LastEpochBlock::::remove(netuid); + PendingEpochAt::::remove(netuid); + SubnetEpochIndex::::remove(netuid); MinAllowedWeights::::remove(netuid); RegistrationsThisInterval::::remove(netuid); POWRegistrationsThisInterval::::remove(netuid); diff --git a/pallets/subtensor/src/coinbase/run_coinbase.rs b/pallets/subtensor/src/coinbase/run_coinbase.rs index e3e98c7a88..f9c1862887 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -64,7 +64,14 @@ impl Pallet { let emissions_to_distribute = Self::drain_pending(&subnets, current_block); // --- 6. Distribute the emissions to the subnets. + // Bonds masking inside `distribute_emission` reads `LastMechansimStepBlock` and + // must see the previous successful run, so we delay the write until after. Self::distribute_emissions_to_subnets(&emissions_to_distribute); + + // --- 7. Mark each successful epoch run as the last mechanism step. + for netuid in emissions_to_distribute.keys() { + LastMechansimStepBlock::::insert(*netuid, current_block); + } } pub fn inject_and_maybe_swap( @@ -325,19 +332,35 @@ impl Pallet { NetUid, (AlphaBalance, AlphaBalance, AlphaBalance, AlphaBalance), > = BTreeMap::new(); - // --- Drain pending emissions for all subnets hat are at their tempo. - // Run the epoch for *all* subnets, even if we don't emit anything. + // Per-block cap on number of epochs that may run; the rest are deferred 1 block forward + // by setting `PendingEpochAt`. + let mut epochs_run_this_block: u32 = 0; + for &netuid in subnets.iter() { - // Increment blocks since last step. + // Increment blocks since last *successful* step (existing semantics). BlocksSinceLastStep::::mutate(netuid, |total| *total = total.saturating_add(1)); - // Run the epoch if applicable. - if Self::should_run_epoch(netuid, current_block) - && Self::is_epoch_input_state_consistent(netuid) - { - // Restart counters. + if !Self::should_run_epoch(netuid, current_block) { + continue; + } + + // Per-block cap — defer if already at limit. + if epochs_run_this_block >= MAX_EPOCHS_PER_BLOCK { + let next_block = current_block.saturating_add(1); + PendingEpochAt::::insert(netuid, next_block); + Self::deposit_event(Event::EpochDeferred { + netuid, + from_block: current_block, + to_block: next_block, + }); + continue; + } + + if Self::is_epoch_input_state_consistent(netuid) { + // Reset blocks-since counter; LastMechansimStepBlock is written + // post-distribute (see the caller), so bonds masking can read the + // previous successful run. BlocksSinceLastStep::::insert(netuid, 0); - LastMechansimStepBlock::::insert(netuid, current_block); // Get and drain the subnet pending emission. let pending_server_alpha = PendingServerEmission::::get(netuid); @@ -364,11 +387,24 @@ impl Pallet { owner_cut, ), ); + epochs_run_this_block = epochs_run_this_block.saturating_add(1); // Reserved for potential future enhancements. // Ownership update logic based on conviction is currently inactive by design. // Self::change_subnet_owner_if_needed(netuid); + } else { + // Schedule advances below; execution skipped. Pending emissions accumulate + // and will be drained by the next successful epoch. + Self::deposit_event(Event::EpochSkippedDueToInconsistentState { + netuid, + block: current_block, + }); } + + // Advance the schedule unconditionally — the slot is consumed. + LastEpochBlock::::insert(netuid, current_block); + PendingEpochAt::::insert(netuid, 0); + SubnetEpochIndex::::mutate(netuid, |idx| *idx = idx.saturating_add(1)); } emissions_to_distribute } @@ -1007,28 +1043,57 @@ impl Pallet { /// # Returns /// * `bool` - True if the epoch should run, false otherwise. pub fn should_run_epoch(netuid: NetUid, current_block: u64) -> bool { - Self::blocks_until_next_epoch(netuid, Self::get_tempo(netuid), current_block) == 0 + let tempo = Self::get_tempo(netuid); + if tempo == 0 { + return false; + } + let pending = PendingEpochAt::::get(netuid); + if pending > 0 && current_block >= pending { + return true; + } + if BlocksSinceLastStep::::get(netuid) > MAX_TEMPO as u64 { + return true; + } + let last = LastEpochBlock::::get(netuid); + let blocks_since = current_block.saturating_sub(last); + blocks_since >= tempo as u64 } - /// Helper function which returns the number of blocks remaining before we will run the epoch on this - /// network. Networks run their epoch when (block_number + netuid + 1 ) % (tempo + 1) = 0 - /// tempo | netuid | # first epoch block - /// 1 0 0 - /// 1 1 1 - /// 2 0 1 - /// 2 1 0 - /// 100 0 99 - /// 100 1 98 - /// Special case: tempo = 0, the network never runs. - /// - pub fn blocks_until_next_epoch(netuid: NetUid, tempo: u16, block_number: u64) -> u64 { + /// Returns the number of blocks remaining before the next automatic epoch under the + /// stateful scheduler (period `tempo`, anchored on `LastEpochBlock`). Does NOT account for: + /// - `PendingEpochAt` (owner-triggered manual fire — could happen sooner), + /// - `BlocksSinceLastStep > MAX_TEMPO` safety-net, + /// - per-block-cap defer (could push the actual fire one or more blocks later) + /// Used by the admin-freeze-window predicate and external tooling. Returns `u64::MAX` when + /// `tempo == 0` (legacy defensive short-circuit). + pub fn blocks_until_next_auto_epoch(netuid: NetUid, tempo: u16, block_number: u64) -> u64 { if tempo == 0 { return u64::MAX; } - let netuid_plus_one = (u16::from(netuid) as u64).saturating_add(1); - let tempo_plus_one = (tempo as u64).saturating_add(1); - let adjusted_block = block_number.wrapping_add(netuid_plus_one); - let remainder = adjusted_block.checked_rem(tempo_plus_one).unwrap_or(0); - (tempo as u64).saturating_sub(remainder) + let last = LastEpochBlock::::get(netuid); + // Period is `tempo`: next firing at `last + tempo`. + let next_auto = last.saturating_add(tempo as u64); + next_auto.saturating_sub(block_number) + } + + /// Returns the absolute block number at which the next epoch is expected to fire for the + /// given subnet, considering both the automatic schedule (`LastEpochBlock + tempo`) and + /// any owner-triggered `PendingEpochAt`. Returns `None` if `tempo == 0` (subnet does not run). + /// Does NOT account for the per-block cap deferral or the `BlocksSinceLastStep > MAX_TEMPO` + /// safety-net (which can fire earlier under extreme drift). + pub fn get_next_epoch_start_block(netuid: NetUid) -> Option { + let tempo = Self::get_tempo(netuid); + if tempo == 0 { + return None; + } + let last = LastEpochBlock::::get(netuid); + let auto_next = last.saturating_add(tempo as u64); + + let pending = PendingEpochAt::::get(netuid); + if pending > 0 { + Some(auto_next.min(pending)) + } else { + Some(auto_next) + } } } diff --git a/pallets/subtensor/src/coinbase/tempo_control.rs b/pallets/subtensor/src/coinbase/tempo_control.rs new file mode 100644 index 0000000000..6e3f325d41 --- /dev/null +++ b/pallets/subtensor/src/coinbase/tempo_control.rs @@ -0,0 +1,98 @@ +use super::*; +use crate::Error; +use frame_support::pallet_prelude::DispatchResult; +use sp_runtime::DispatchError; +use subtensor_runtime_common::NetUid; + +use crate::system::pallet_prelude::OriginFor; +use crate::utils::rate_limiting::{Hyperparameter, TransactionType}; + +impl Pallet { + /// Owner-side `set_tempo` implementation. + pub fn do_set_tempo(origin: OriginFor, netuid: NetUid, tempo: u16) -> DispatchResult { + let who = Self::ensure_subnet_owner(origin, netuid)?; + + ensure!( + (MIN_TEMPO..=MAX_TEMPO).contains(&tempo), + Error::::TempoOutOfBounds + ); + + Self::ensure_admin_window_open(netuid)?; + + let tx = TransactionType::TempoUpdate; + ensure!( + tx.passes_rate_limit_on_subnet::(&who, netuid), + Error::::TxRateLimitExceeded + ); + + let now = Self::get_current_block_as_u64(); + + Self::apply_tempo_with_cycle_reset(netuid, tempo); + + tx.set_last_block_on_subnet::(&who, netuid, now); + Ok(()) + } + + /// Owner-side `set_activity_cutoff_factor` implementation. + pub fn do_set_activity_cutoff_factor( + origin: OriginFor, + netuid: NetUid, + factor_milli: u32, + ) -> DispatchResult { + let who = Self::ensure_subnet_owner(origin, netuid)?; + + ensure!( + (MIN_ACTIVITY_CUTOFF_FACTOR_MILLI..=MAX_ACTIVITY_CUTOFF_FACTOR_MILLI) + .contains(&factor_milli), + Error::::ActivityCutoffFactorMilliOutOfBounds + ); + + Self::ensure_admin_window_open(netuid)?; + + let tx = TransactionType::OwnerHyperparamUpdate(Hyperparameter::ActivityCutoffFactorMilli); + ensure!( + tx.passes_rate_limit_on_subnet::(&who, netuid), + Error::::TxRateLimitExceeded + ); + + let now = Self::get_current_block_as_u64(); + + Self::set_activity_cutoff_factor_milli(netuid, factor_milli); + tx.set_last_block_on_subnet::(&who, netuid, now); + + Ok(()) + } + + /// Owner-side `trigger_epoch` implementation. + /// Schedules the triggered epoch to fire after `AdminFreezeWindow` blocks; that + /// countdown engages the freeze window for the subnet via `is_in_admin_freeze_window`. + pub fn do_trigger_epoch(origin: OriginFor, netuid: NetUid) -> Result<(), DispatchError> { + let who = Self::ensure_subnet_owner(origin, netuid)?; + + // No `ensure_admin_window_open` here: trigger *defines* the next epoch. + ensure!( + PendingEpochAt::::get(netuid) == 0, + Error::::EpochTriggerAlreadyPending + ); + + let tx = TransactionType::OwnerHyperparamUpdate(Hyperparameter::TriggerEpoch); + ensure!( + tx.passes_rate_limit_on_subnet::(&who, netuid), + Error::::TxRateLimitExceeded + ); + + let now = Self::get_current_block_as_u64(); + let window = AdminFreezeWindow::::get() as u64; + let fires_at = now.saturating_add(window); + + PendingEpochAt::::insert(netuid, fires_at); + tx.set_last_block_on_subnet::(&who, netuid, now); + + Self::deposit_event(Event::EpochTriggered { + netuid, + by: who, + fires_at, + }); + Ok(()) + } +} diff --git a/pallets/subtensor/src/epoch/run_epoch.rs b/pallets/subtensor/src/epoch/run_epoch.rs index 962c5bbbb4..cbfdc5a0fd 100644 --- a/pallets/subtensor/src/epoch/run_epoch.rs +++ b/pallets/subtensor/src/epoch/run_epoch.rs @@ -169,7 +169,7 @@ impl Pallet { log::trace!("tempo: {tempo:?}"); // Get activity cutoff. - let activity_cutoff: u64 = Self::get_activity_cutoff(netuid) as u64; + let activity_cutoff: u64 = Self::get_activity_cutoff_blocks(netuid); log::trace!("activity_cutoff: {activity_cutoff:?}"); // Last update vector. @@ -205,7 +205,13 @@ impl Pallet { // Recently registered matrix, recently_ij=True if last_tempo was *before* j was last registered. // Mask if: the last tempo block happened *before* the registration block // ==> last_tempo <= registered - let last_tempo: u64 = current_block.saturating_sub(tempo); + // For dynamic tempo - we pick previous-successful-epoch block: `LastMechansimStepBlock + 1` + let lms = LastMechansimStepBlock::::get(netuid); + let last_tempo: u64 = if lms == 0 { + current_block.saturating_sub(tempo) + } else { + lms.saturating_add(1) + }; let recently_registered: Vec = block_at_registration .iter() .map(|registered| last_tempo <= *registered) @@ -595,7 +601,7 @@ impl Pallet { log::trace!("tempo:\n{tempo:?}\n"); // Get activity cutoff. - let activity_cutoff: u64 = Self::get_activity_cutoff(netuid) as u64; + let activity_cutoff: u64 = Self::get_activity_cutoff_blocks(netuid); log::trace!("activity_cutoff: {activity_cutoff:?}"); // Last update vector. @@ -729,24 +735,30 @@ impl Pallet { let uid_of = |acct: &T::AccountId| terms_map.get(acct).map(|t| t.uid); // ---------- v2 ------------------------------------------------------ + // `WeightCommits` tuple: (hash, commit_epoch, commit_block, _). + // Expiry keys off `commit_epoch`; the column mask compares the absolute + // `commit_block` against `block_at_registration` (both block numbers). for (who, q) in WeightCommits::::iter_prefix(netuid_index) { - for (_, cb, _, _) in q.iter() { - if !Self::is_commit_expired(netuid, *cb) { + for (_, commit_epoch, commit_block, _) in q.iter() { + if !Self::is_commit_expired(netuid, *commit_epoch) { if let Some(cell) = uid_of(&who).and_then(|i| commit_blocks.get_mut(i)) { - *cell = (*cell).min(*cb); + *cell = (*cell).min(*commit_block); } break; // earliest active found } } } - // ---------- v3 ------------------------------------------------------ - for (_epoch, q) in TimelockedWeightCommits::::iter_prefix(netuid_index) { - for (who, cb, ..) in q.iter() { - if !Self::is_commit_expired(netuid, *cb) - && let Some(cell) = uid_of(who).and_then(|i| commit_blocks.get_mut(i)) - { - *cell = (*cell).min(*cb); + // ---------- v4 ------------------------------------------------------ + // `TimelockedWeightCommits` is keyed by `commit_epoch`; the value tuple + // carries the absolute `commit_block` in field 1. + for (commit_epoch, q) in TimelockedWeightCommits::::iter_prefix(netuid_index) { + if Self::is_commit_expired(netuid, commit_epoch) { + continue; + } + for (who, commit_block, ..) in q.iter() { + if let Some(cell) = uid_of(who).and_then(|i| commit_blocks.get_mut(i)) { + *cell = (*cell).min(*commit_block); } } } @@ -819,7 +831,13 @@ impl Pallet { // Remove bonds referring to neurons that have registered since last tempo. // Mask if: the last tempo block happened *before* the registration block // ==> last_tempo <= registered - let last_tempo: u64 = current_block.saturating_sub(tempo); + // For dynamic tempo - we pick previous-successful-epoch block: `LastMechansimStepBlock + 1` + let lms = LastMechansimStepBlock::::get(netuid); + let last_tempo: u64 = if lms == 0 { + current_block.saturating_sub(tempo) + } else { + lms.saturating_add(1) + }; bonds = scalar_vec_mask_sparse_matrix( &bonds, last_tempo, @@ -859,7 +877,13 @@ impl Pallet { // Remove bonds referring to neurons that have registered since last tempo. // Mask if: the last tempo block happened *before* the registration block // ==> last_tempo <= registered - let last_tempo: u64 = current_block.saturating_sub(tempo); + // For dynamic tempo - we pick previous-successful-epoch block: `LastMechansimStepBlock + 1` + let lms = LastMechansimStepBlock::::get(netuid); + let last_tempo: u64 = if lms == 0 { + current_block.saturating_sub(tempo) + } else { + lms.saturating_add(1) + }; bonds = scalar_vec_mask_sparse_matrix( &bonds, last_tempo, diff --git a/pallets/subtensor/src/extensions/subtensor.rs b/pallets/subtensor/src/extensions/subtensor.rs index 797ab68216..6ec4a346de 100644 --- a/pallets/subtensor/src/extensions/subtensor.rs +++ b/pallets/subtensor/src/extensions/subtensor.rs @@ -153,9 +153,9 @@ where salt, *version_key, ); - match Pallet::::find_commit_block_via_hash(provided_hash) { - Some(commit_block) => { - if Pallet::::is_reveal_block_range(*netuid, commit_block) { + match Pallet::::find_commit_epoch_via_hash(provided_hash) { + Some(commit_epoch) => { + if Pallet::::is_reveal_block_range(*netuid, commit_epoch) { Ok((Default::default(), (), origin)) } else { Err(CustomTransactionError::CommitBlockNotInRevealRange.into()) @@ -183,9 +183,9 @@ where salt, *version_key, ); - match Pallet::::find_commit_block_via_hash(provided_hash) { - Some(commit_block) => { - if Pallet::::is_reveal_block_range(*netuid, commit_block) { + match Pallet::::find_commit_epoch_via_hash(provided_hash) { + Some(commit_epoch) => { + if Pallet::::is_reveal_block_range(*netuid, commit_epoch) { Ok((Default::default(), (), origin)) } else { Err(CustomTransactionError::CommitBlockNotInRevealRange.into()) @@ -223,13 +223,13 @@ where }) .collect::>(); - let batch_reveal_block = provided_hashes + let batch_reveal_epoch = provided_hashes .iter() - .filter_map(|hash| Pallet::::find_commit_block_via_hash(*hash)) + .filter_map(|hash| Pallet::::find_commit_epoch_via_hash(*hash)) .collect::>(); - if provided_hashes.len() == batch_reveal_block.len() { - if Pallet::::is_batch_reveal_block_range(*netuid, batch_reveal_block) { + if provided_hashes.len() == batch_reveal_epoch.len() { + if Pallet::::is_batch_reveal_epoch_range(*netuid, batch_reveal_epoch) { Ok((Default::default(), (), origin)) } else { Err(CustomTransactionError::CommitBlockNotInRevealRange.into()) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 7c664fc1c1..d3533c6b97 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1767,6 +1767,52 @@ pub mod pallet { #[pallet::storage] pub type Tempo = StorageMap<_, Identity, NetUid, u16, ValueQuery, DefaultTempo>; + /// Lower bound for owner-set tempo. Also the fixed cooldown for `set_tempo`. + pub const MIN_TEMPO: u16 = 360; + /// Upper bound for owner-set tempo (≈ 7 days at 12 s/block). + pub const MAX_TEMPO: u16 = 50_400; + /// Lower bound for activity-cutoff factor (per-mille). 1_000 = one full tempo. + pub const MIN_ACTIVITY_CUTOFF_FACTOR_MILLI: u32 = 1_000; + /// Upper bound for activity-cutoff factor (per-mille). 50_000 = 50 tempos. + pub const MAX_ACTIVITY_CUTOFF_FACTOR_MILLI: u32 = 50_000; + /// Default activity-cutoff factor (per-mille). 13_889 ≈ legacy 5000-block cutoff + /// at default tempo 360 (`13_889 * 360 / 1000 = 5_000`, exact via ceiling rounding). + pub const INITIAL_ACTIVITY_CUTOFF_FACTOR_MILLI: u32 = 13_889; + /// Per-block cap on number of epochs that may execute in a single `block_step`. + pub const MAX_EPOCHS_PER_BLOCK: u32 = prod_or_fast!(2, 32); + + /// Default value for activity-cutoff factor (per-mille). + #[pallet::type_value] + pub fn DefaultActivityCutoffFactorMilli() -> u32 { + INITIAL_ACTIVITY_CUTOFF_FACTOR_MILLI + } + + /// --- MAP ( netuid ) --> last epoch attempt block (consumed slot). + /// Drives normal-cadence scheduling and the admin freeze window. + /// Advances on every `should_run_epoch == true` slot — including consistency-skipped slots — + /// and on a successful `set_tempo` (cycle reset). + #[pallet::storage] + pub type LastEpochBlock = + StorageMap<_, Identity, NetUid, u64, ValueQuery, DefaultZeroU64>; + + /// --- MAP ( netuid ) --> block at which a manually triggered epoch should fire. + /// `0` means no trigger pending. Cleared after the triggered epoch runs. + #[pallet::storage] + pub type PendingEpochAt = + StorageMap<_, Identity, NetUid, u64, ValueQuery, DefaultZeroU64>; + + /// --- MAP ( netuid ) --> monotonic epoch counter. + /// Incremented by exactly one each time the subnet's epoch slot is consumed in `run_coinbase` + #[pallet::storage] + pub type SubnetEpochIndex = + StorageMap<_, Identity, NetUid, u64, ValueQuery, DefaultZeroU64>; + + /// --- MAP ( netuid ) --> activity-cutoff factor in per-mille epochs (1/1000 granularity). + /// Effective cutoff in blocks = `(factor × tempo) / 1000`, clamped to ≥ 1. + #[pallet::storage] + pub type ActivityCutoffFactorMilli = + StorageMap<_, Identity, NetUid, u32, ValueQuery, DefaultActivityCutoffFactorMilli>; + /// ============================ /// ==== Subnet Parameters ===== /// ============================ @@ -2351,7 +2397,8 @@ pub mod pallet { #[pallet::storage] pub type StakeThreshold = StorageValue<_, u64, ValueQuery, DefaultStakeThreshold>; - /// --- MAP (netuid, who) --> VecDeque<(hash, commit_block, first_reveal_block, last_reveal_block)> | Stores a queue of commits for an account on a given netuid. + /// --- MAP (netuid, who) --> VecDeque<(hash, commit_epoch, commit_block, _unused)> + /// Stores a queue of commit-reveal-v2 commits for an account on a given netuid. #[pallet::storage] pub type WeightCommits = StorageDoubleMap< _, diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 9ad1225d49..55eccf7fac 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2585,7 +2585,7 @@ mod dispatches { /// When enabled, the caller's individual lock does not unlock through /// locked-mass decay. Passing `false` removes the flag, returning the /// caller's lock to normal decay. - #[pallet::call_index(138)] + #[pallet::call_index(139)] #[pallet::weight(::DbWeight::get().reads_writes(4, 3))] pub fn set_perpetual_lock( origin: OriginFor, @@ -2595,5 +2595,36 @@ mod dispatches { let coldkey = ensure_signed(origin)?; Self::do_set_perpetual_lock(&coldkey, netuid, enabled) } + + /// Owner-side `set_tempo`. Validates `[MinTempo, MaxTempo]`, applies a fixed + /// `MinTempo`-block cooldown via `TransactionType::TempoUpdate`, respects the admin + /// freeze window, and resets the cycle (`LastEpochBlock = current_block`) on success. + #[pallet::call_index(140)] + #[pallet::weight(::WeightInfo::set_tempo())] + pub fn set_tempo(origin: OriginFor, netuid: NetUid, tempo: u16) -> DispatchResult { + Self::do_set_tempo(origin, netuid, tempo) + } + + /// Owner-side `set_activity_cutoff_factor`. Per-mille (1/1000) units; `cutoff_blocks + /// = (factor × tempo) / 1000`. Validates `[MinActivityCutoffFactorMilli, + /// MaxActivityCutoffFactorMilli]`, rate-limited via the existing + /// `OwnerHyperparamUpdate` pattern, respects the admin freeze window. + #[pallet::call_index(141)] + #[pallet::weight(::WeightInfo::set_activity_cutoff_factor())] + pub fn set_activity_cutoff_factor( + origin: OriginFor, + netuid: NetUid, + factor_milli: u32, + ) -> DispatchResult { + Self::do_set_activity_cutoff_factor(origin, netuid, factor_milli) + } + + /// Owner-side `trigger_epoch`. Schedules an epoch to fire after `AdminFreezeWindow` + /// blocks. Rate-limited via the existing `OwnerHyperparamUpdate` pattern. + #[pallet::call_index(142)] + #[pallet::weight(::WeightInfo::trigger_epoch())] + pub fn trigger_epoch(origin: OriginFor, netuid: NetUid) -> DispatchResult { + Self::do_trigger_epoch(origin, netuid) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index cb120b56b5..e5537816cb 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -305,5 +305,11 @@ mod errors { CannotUseSystemAccount, /// Trying to unlock more than locked UnlockAmountTooHigh, + /// Tempo value out of `[MinTempo, MaxTempo]` bounds. + TempoOutOfBounds, + /// Activity-cutoff factor out of `[MinActivityCutoffFactorMilli, MaxActivityCutoffFactorMilli]` bounds. + ActivityCutoffFactorMilliOutOfBounds, + /// `trigger_epoch` called while a previously triggered epoch is still pending. + EpochTriggerAlreadyPending, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 918baf1107..33a7b85037 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -612,6 +612,38 @@ mod events { netuid: NetUid, }, + /// Activity-cutoff factor (per-mille) set on a subnet by its owner. + ActivityCutoffFactorMilliSet(NetUid, u32), + + /// Owner manually triggered an epoch for their subnet. + EpochTriggered { + /// The subnet identifier. + netuid: NetUid, + /// The account that triggered the epoch. + by: T::AccountId, + /// The earliest block at which the triggered epoch may execute. + fires_at: u64, + }, + + /// An epoch slot was deferred to the next block due to the per-block epoch cap. + EpochDeferred { + /// The subnet identifier. + netuid: NetUid, + /// Block at which the epoch was originally scheduled. + from_block: u64, + /// Block to which the epoch was deferred. + to_block: u64, + }, + + /// `should_run_epoch` returned true but `is_epoch_input_state_consistent` returned false; + /// schedule advanced, epoch execution skipped. + EpochSkippedDueToInconsistentState { + /// The subnet identifier. + netuid: NetUid, + /// The block at which the slot was consumed. + block: u64, + }, + /// Subnet ownership was reassigned by lock conviction. SubnetOwnerChanged { /// The subnet whose owner changed. diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 55f6bd84a9..61ac6f1009 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -178,7 +178,9 @@ mod hooks { // Fix testnet Subtensor TotalIssuance after the EVM fees issue. .saturating_add(migrations::migrate_fix_total_issuance_evm_fees::migrate_fix_total_issuance_evm_fees::()) // Remove deprecated conviction lock storage. - .saturating_add(migrations::migrate_remove_deprecated_conviction_maps::migrate_remove_deprecated_conviction_maps::()); + .saturating_add(migrations::migrate_remove_deprecated_conviction_maps::migrate_remove_deprecated_conviction_maps::()) + // Seed LastEpochBlock for dynamic-tempo / owner-triggered-epochs feature + .saturating_add(migrations::migrate_dynamic_tempo::migrate_dynamic_tempo::()); weight } diff --git a/pallets/subtensor/src/migrations/migrate_dynamic_tempo.rs b/pallets/subtensor/src/migrations/migrate_dynamic_tempo.rs new file mode 100644 index 0000000000..c359b96c2f --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_dynamic_tempo.rs @@ -0,0 +1,163 @@ +use super::*; +use frame_support::{traits::Get, weights::Weight}; +use log; +use scale_info::prelude::string::String; +use sp_core::H256; +use sp_std::collections::vec_deque::VecDeque; + +/// One-shot migration for the dynamic-tempo / owner-triggered-epochs feature. +/// +/// 1. Back-fills `LastEpochBlock[netuid]` for every existing subnet so the first +/// post-upgrade epoch lands on the same block as the legacy modulo formula +/// `(block + netuid + 1) % (tempo + 1) == 0`. The new scheduler period is +/// `tempo` (next firing at `LastEpochBlock + tempo`). +/// Existing `Tempo[netuid]` values are preserved as-is regardless of whether +/// they fall inside `[MIN_TEMPO, MAX_TEMPO]`. Owner-side `set_tempo` enforces +/// the bounds for new updates; root-side `sudo_set_tempo` can still write any +/// `u16`. Subnets with `Tempo == 0` are left as-is — the legacy short-circuit +/// keeps them dormant and matches their pre-upgrade behaviour. +/// 2. Converts each subnet's existing `ActivityCutoff[netuid]` (absolute block count) +/// into `ActivityCutoffFactorMilli[netuid]` (per-mille of `tempo`) so that +/// `factor * tempo / 1000 ≈ old_cutoff` post-upgrade. Production defaults +/// (`tempo=360`, `cutoff=5000`) round-trip to 5000 blocks exactly via ceiling +/// division. Out-of-range factors are clamped to +/// `[MIN_ACTIVITY_CUTOFF_FACTOR_MILLI, MAX_ACTIVITY_CUTOFF_FACTOR_MILLI]` — +/// extreme historical cutoffs may shift to the nearest representable factor. +/// 3. Seeds `SubnetEpochIndex[netuid]` (the new stateful epoch counter) with the +/// legacy modulo epoch index `(block + netuid + 1) / (tempo + 1)` so that +/// existing commit-reveal commit keys — `TimelockedWeightCommits` (CR-v4) keyed +/// by epoch, and `WeightCommits` (CR-v2) tagged with `commit_epoch` — stay +/// valid and continuous across the upgrade. +/// 4. Rewrites every CR-v2 `WeightCommits` entry to `(hash, commit_epoch, +/// commit_block, _)`: field 1 (previously the absolute `commit_block`) becomes +/// `commit_epoch` under the legacy modulo formula; field 2 keeps the absolute +/// `commit_block` (used by the epoch's commit-reveal weight column-mask). +pub fn migrate_dynamic_tempo() -> Weight { + let mig_name: Vec = b"dynamic_tempo_v1".to_vec(); + let mig_name_str = String::from_utf8_lossy(&mig_name); + + let mut total_weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(&mig_name) { + log::info!("Migration '{mig_name_str}' already executed - skipping"); + return total_weight; + } + + log::info!("Running migration '{mig_name_str}'"); + + let current_block = Pallet::::get_current_block_as_u64(); + let mut visited: u64 = 0; + let mut last_epoch_seeded: u64 = 0; + let mut epoch_index_seeded: u64 = 0; + let mut activity_factor_seeded: u64 = 0; + let mut activity_factor_clamped: u64 = 0; + let mut crv2_commits_converted: u64 = 0; + let mut reads: u64 = 0; + let mut writes: u64 = 0; + + let netuids: Vec = Tempo::::iter_keys().collect(); + reads = reads.saturating_add(netuids.len() as u64); + + for netuid in netuids.into_iter() { + visited = visited.saturating_add(1); + let tempo = Tempo::::get(netuid); + reads = reads.saturating_add(1); + + if tempo == 0 { + // Legacy `tempo == 0` short-circuit preserved; do not seed `LastEpochBlock`. + continue; + } + + // Compute next-epoch block under the *legacy* modulo formula and back-fill + // `LastEpochBlock` so the *new* scheduler fires its first epoch on the same + // block the legacy chain would have. + // Legacy `blocks_until_next_epoch` (pre-upgrade behaviour, period `tempo + 1`): + // adjusted = current_block + netuid + 1 + // remainder = adjusted % (tempo + 1) + // blocks_until_next = tempo - remainder + // New scheduler period is `tempo`, next firing at `LastEpochBlock + tempo`. + // Solve for `LastEpochBlock`: + // LastEpochBlock = current_block + blocks_until_next - tempo + // = current_block - (tempo - blocks_until_next) + let netuid_plus_one = (u16::from(netuid) as u64).saturating_add(1); + let tempo_plus_one = (tempo as u64).saturating_add(1); + let adjusted = current_block.wrapping_add(netuid_plus_one); + let remainder = adjusted.checked_rem(tempo_plus_one).unwrap_or(0); + let blocks_until_next = (tempo as u64).saturating_sub(remainder); + let offset = (tempo as u64).saturating_sub(blocks_until_next); + let last_epoch = current_block.saturating_sub(offset); + + LastEpochBlock::::insert(netuid, last_epoch); + last_epoch_seeded = last_epoch_seeded.saturating_add(1); + writes = writes.saturating_add(1); + + // Seed the stateful epoch counter with the legacy modulo epoch index + // `(current_block + netuid + 1) / (tempo + 1)` so CR commit keys + // (TimelockedWeightCommits epoch keys, WeightCommits commit_epoch) stay + // continuous across the upgrade. + let legacy_epoch = adjusted.checked_div(tempo_plus_one).unwrap_or(0); + SubnetEpochIndex::::insert(netuid, legacy_epoch); + epoch_index_seeded = epoch_index_seeded.saturating_add(1); + writes = writes.saturating_add(1); + + // Convert legacy absolute `ActivityCutoff` into per-mille `ActivityCutoffFactorMilli` + let old_cutoff = ActivityCutoff::::get(netuid) as u64; + reads = reads.saturating_add(1); + let tempo_u64 = tempo as u64; + let raw_factor = old_cutoff + .saturating_mul(1_000) + .saturating_add(tempo_u64.saturating_sub(1)) + .checked_div(tempo_u64) + .unwrap_or(INITIAL_ACTIVITY_CUTOFF_FACTOR_MILLI as u64); + let clamped = raw_factor + .max(MIN_ACTIVITY_CUTOFF_FACTOR_MILLI as u64) + .min(MAX_ACTIVITY_CUTOFF_FACTOR_MILLI as u64) as u32; + if clamped as u64 != raw_factor { + activity_factor_clamped = activity_factor_clamped.saturating_add(1); + } + ActivityCutoffFactorMilli::::insert(netuid, clamped); + activity_factor_seeded = activity_factor_seeded.saturating_add(1); + writes = writes.saturating_add(1); + } + + // --- CR-v2: rewrite every `WeightCommits` entry to the + // `(hash, commit_epoch, commit_block, _)` layout. Field 1 was the absolute + // `commit_block`; it becomes `commit_epoch` (legacy modulo epoch). Field 2 + // keeps the absolute `commit_block` (used by the epoch column-mask). + let crv2_entries: Vec<_> = WeightCommits::::iter().collect(); + reads = reads.saturating_add(crv2_entries.len() as u64); + for (netuid_index, account, commits) in crv2_entries.into_iter() { + let (netuid, _) = Pallet::::get_netuid_and_subid(netuid_index).unwrap_or_default(); + let tempo = Tempo::::get(netuid); + reads = reads.saturating_add(1); + let tempo_plus_one = (tempo as u64).saturating_add(1); + let netuid_plus_one = (u16::from(netuid) as u64).saturating_add(1); + + let converted: VecDeque<(H256, u64, u64, u64)> = commits + .into_iter() + .map(|(hash, commit_block, _, _)| { + let commit_epoch = commit_block + .saturating_add(netuid_plus_one) + .checked_div(tempo_plus_one) + .unwrap_or(0); + (hash, commit_epoch, commit_block, 0u64) + }) + .collect(); + WeightCommits::::insert(netuid_index, account, converted); + crv2_commits_converted = crv2_commits_converted.saturating_add(1); + writes = writes.saturating_add(1); + } + + total_weight = total_weight.saturating_add(T::DbWeight::get().reads_writes(reads, writes)); + + log::info!( + "Dynamic tempo migration: visited={visited}, last_epoch_seeded={last_epoch_seeded}, epoch_index_seeded={epoch_index_seeded}, activity_factor_seeded={activity_factor_seeded}, activity_factor_clamped={activity_factor_clamped}, crv2_commits_converted={crv2_commits_converted}" + ); + + HasMigrationRun::::insert(&mig_name, true); + total_weight = total_weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!("Migration '{mig_name_str}' completed"); + + total_weight +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index f582a631fc..4548c53769 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -16,6 +16,7 @@ pub mod migrate_crv3_v2_to_timelocked; pub mod migrate_delete_subnet_21; pub mod migrate_delete_subnet_3; pub mod migrate_disable_commit_reveal; +pub mod migrate_dynamic_tempo; pub mod migrate_fix_bad_hk_swap; pub mod migrate_fix_childkeys; pub mod migrate_fix_is_network_member; diff --git a/pallets/subtensor/src/subnets/subnet.rs b/pallets/subtensor/src/subnets/subnet.rs index 46a834946b..9805244f8e 100644 --- a/pallets/subtensor/src/subnets/subnet.rs +++ b/pallets/subtensor/src/subnets/subnet.rs @@ -302,6 +302,12 @@ impl Pallet { // --- 3. Fill tempo memory item. Tempo::::insert(netuid, tempo); + // --- 3.1. Initialise `LastEpochBlock` with a per-netuid stagger + let now = Self::get_current_block_as_u64(); + let period = (tempo as u64).max(1); + let stagger = (u16::from(netuid) as u64).checked_rem(period).unwrap_or(0); + LastEpochBlock::::insert(netuid, now.saturating_sub(stagger)); + // --- 4. Increase total network count. TotalNetworks::::mutate(|n| *n = n.saturating_add(1)); diff --git a/pallets/subtensor/src/subnets/weights.rs b/pallets/subtensor/src/subnets/weights.rs index 39bcfb80b5..7a8dc50a60 100644 --- a/pallets/subtensor/src/subnets/weights.rs +++ b/pallets/subtensor/src/subnets/weights.rs @@ -96,17 +96,21 @@ impl Pallet { Error::::CommittingWeightsTooFast ); - // 5. Calculate the reveal blocks based on network tempo and reveal period. - let (first_reveal_block, last_reveal_block) = Self::get_reveal_blocks(netuid, commit_block); + // 5. Resolve the epoch this commit belongs to under the stateful counter. + let commit_epoch = Self::current_epoch_with_lookahead(netuid); // 6. Retrieve or initialize the VecDeque of commits for the hotkey. WeightCommits::::try_mutate(netuid_index, &who, |maybe_commits| -> DispatchResult { + // Tuple shape `(hash, commit_epoch, commit_block, _)`. `commit_epoch` + // drives reveal-window timing; `commit_block` is kept for the epoch's + // commit-reveal weight column-mask. The 4th field is a legacy + // reveal-block bound, now unused and left at 0. let mut commits: VecDeque<(H256, u64, u64, u64)> = maybe_commits.take().unwrap_or_default(); // 7. Remove any expired commits from the front of the queue. - while let Some((_, commit_block_existing, _, _)) = commits.front() { - if Self::is_commit_expired(netuid, *commit_block_existing) { + while let Some((_, commit_epoch_existing, _, _)) = commits.front() { + if Self::is_commit_expired(netuid, *commit_epoch_existing) { commits.pop_front(); } else { break; @@ -116,13 +120,8 @@ impl Pallet { // 8. Verify that the number of unrevealed commits is within the allowed limit. ensure!(commits.len() < 10, Error::::TooManyUnrevealedCommits); - // 9. Append the new commit with calculated reveal blocks. - commits.push_back(( - commit_hash, - commit_block, - first_reveal_block, - last_reveal_block, - )); + // 9. Append the new commit, tagged with its epoch and block. + commits.push_back((commit_hash, commit_epoch, commit_block, 0)); // 10. Store the updated commits queue back to storage. *maybe_commits = Some(commits); @@ -342,10 +341,8 @@ impl Pallet { // 6. Retrieve or initialize the VecDeque of commits for the hotkey. let cur_block = Self::get_current_block_as_u64(); - let cur_epoch = match Self::should_run_epoch(netuid, commit_block) { - true => Self::get_epoch_index(netuid, cur_block).saturating_add(1), - false => Self::get_epoch_index(netuid, cur_block), - }; + // Key the commit by the epoch it belongs to under the stateful counter. + let cur_epoch = Self::current_epoch_with_lookahead(netuid); TimelockedWeightCommits::::try_mutate( netuid_index, @@ -1249,49 +1246,49 @@ impl Pallet { uids.len() <= subnetwork_n as usize } - pub fn is_reveal_block_range(netuid: NetUid, commit_block: u64) -> bool { - let current_block: u64 = Self::get_current_block_as_u64(); - let commit_epoch: u64 = Self::get_epoch_index(netuid, commit_block); - let current_epoch: u64 = Self::get_epoch_index(netuid, current_block); + /// True when the current epoch is exactly `commit_epoch + reveal_period`. + /// + /// `commit_epoch` is the `SubnetEpochIndex` value stored with the commit (CR-v2 + /// `WeightCommits` tuple field 1). The current epoch uses the look-ahead value + /// so a reveal submitted on a fire-block is judged against the about-to-fire + /// epoch, consistent with how the commit was tagged. + pub fn is_reveal_block_range(netuid: NetUid, commit_epoch: u64) -> bool { + let current_epoch: u64 = Self::current_epoch_with_lookahead(netuid); let reveal_period: u64 = Self::get_reveal_period(netuid); - // Reveal is allowed only in the exact epoch `commit_epoch + reveal_period` current_epoch == commit_epoch.saturating_add(reveal_period) } - pub fn get_epoch_index(netuid: NetUid, block_number: u64) -> u64 { - let tempo: u64 = Self::get_tempo(netuid) as u64; - let tempo_plus_one: u64 = tempo.saturating_add(1); - let netuid_plus_one: u64 = (u16::from(netuid) as u64).saturating_add(1); - let block_with_offset: u64 = block_number.saturating_add(netuid_plus_one); - - block_with_offset.checked_div(tempo_plus_one).unwrap_or(0) + /// Canonical epoch index for a subnet — the monotonic `SubnetEpochIndex` counter. + pub fn get_epoch_index(netuid: NetUid, _block_number: u64) -> u64 { + SubnetEpochIndex::::get(netuid) } - pub fn is_commit_expired(netuid: NetUid, commit_block: u64) -> bool { - let current_block: u64 = Self::get_current_block_as_u64(); - let current_epoch: u64 = Self::get_epoch_index(netuid, current_block); - let commit_epoch: u64 = Self::get_epoch_index(netuid, commit_block); - let reveal_period: u64 = Self::get_reveal_period(netuid); - - current_epoch > commit_epoch.saturating_add(reveal_period) + /// Epoch index that a commit or reveal happening at the *current* block + /// belongs to: the `SubnetEpochIndex` counter, plus one if an epoch slot is + /// due to fire this block. + /// + /// The look-ahead is needed because `block_step` runs in `on_initialize`: + /// `reveal_crv3_commits` (which must see the about-to-fire epoch) runs before + /// `run_coinbase` increments the counter, and a commit extrinsic submitted on + /// a deferred fire-block belongs to the next epoch, not the current one. + pub fn current_epoch_with_lookahead(netuid: NetUid) -> u64 { + let block = Self::get_current_block_as_u64(); + let base = SubnetEpochIndex::::get(netuid); + if Self::should_run_epoch(netuid, block) { + base.saturating_add(1) + } else { + base + } } - pub fn get_reveal_blocks(netuid: NetUid, commit_block: u64) -> (u64, u64) { + /// True once the current epoch has moved past the commit's reveal epoch + /// (`commit_epoch + reveal_period`). `commit_epoch` is the stored counter value. + pub fn is_commit_expired(netuid: NetUid, commit_epoch: u64) -> bool { + let current_epoch: u64 = Self::current_epoch_with_lookahead(netuid); let reveal_period: u64 = Self::get_reveal_period(netuid); - let tempo: u64 = Self::get_tempo(netuid) as u64; - let tempo_plus_one: u64 = tempo.saturating_add(1); - let netuid_plus_one: u64 = (u16::from(netuid) as u64).saturating_add(1); - let commit_epoch: u64 = Self::get_epoch_index(netuid, commit_block); - let reveal_epoch: u64 = commit_epoch.saturating_add(reveal_period); - - let first_reveal_block = reveal_epoch - .saturating_mul(tempo_plus_one) - .saturating_sub(netuid_plus_one); - let last_reveal_block = first_reveal_block.saturating_add(tempo); - - (first_reveal_block, last_reveal_block) + current_epoch > commit_epoch.saturating_add(reveal_period) } pub fn set_reveal_period(netuid: NetUid, reveal_period: u64) -> DispatchResult { @@ -1314,6 +1311,11 @@ impl Pallet { RevealPeriodEpochs::::get(netuid) } + /// Legacy modulo first-block-of-epoch: `epoch * (tempo + 1) - (netuid + 1)`. + /// + /// NOT used by live commit-reveal logic — that keys off the stateful + /// `SubnetEpochIndex` counter. Retained solely so the already-executed, + /// one-shot `migrate_crv3_commits_add_block` migration stays untouched. pub fn get_first_block_of_epoch(netuid: NetUid, epoch: u64) -> u64 { let tempo: u64 = Self::get_tempo(netuid) as u64; let tempo_plus_one: u64 = tempo.saturating_add(1); @@ -1334,18 +1336,20 @@ impl Pallet { BlakeTwo256::hash_of(&(who.clone(), netuid_index, uids, values, salt, version_key)) } - pub fn find_commit_block_via_hash(hash: H256) -> Option { + /// Returns the stored `commit_epoch` (CR-v2 `WeightCommits` tuple field 1) for + /// the commit with the given hash, if any. + pub fn find_commit_epoch_via_hash(hash: H256) -> Option { WeightCommits::::iter().find_map(|(_, _, commits)| { commits .iter() .find(|(h, _, _, _)| *h == hash) - .map(|(_, commit_block, _, _)| *commit_block) + .map(|(_, commit_epoch, _, _)| *commit_epoch) }) } - pub fn is_batch_reveal_block_range(netuid: NetUid, commit_block: Vec) -> bool { - commit_block + pub fn is_batch_reveal_epoch_range(netuid: NetUid, commit_epochs: Vec) -> bool { + commit_epochs .iter() - .all(|block| Self::is_reveal_block_range(netuid, *block)) + .all(|epoch| Self::is_reveal_block_range(netuid, *epoch)) } } diff --git a/pallets/subtensor/src/tests/children.rs b/pallets/subtensor/src/tests/children.rs index ec191ba0e7..1e7e0ddd88 100644 --- a/pallets/subtensor/src/tests/children.rs +++ b/pallets/subtensor/src/tests/children.rs @@ -3144,6 +3144,9 @@ fn test_parent_child_chain_emission() { PendingValidatorEmission::::insert(netuid, AlphaBalance::ZERO); PendingServerEmission::::insert(netuid, AlphaBalance::ZERO); + // To trigger the epoch, block should be > tempo. So we advance it before + System::set_block_number(2); + // Run epoch with emission value let emission_value = u64::from(emission.peek()); SubtensorModule::run_coinbase(emission); diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index d708b88706..79797a4e36 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -901,6 +901,9 @@ fn test_claim_root_with_run_coinbase() { .into(); assert_eq!(initial_stake, 0u64); + // To trigger the epoch, block should be > tempo. So we advance it before + System::set_block_number(2); + let block_emissions = SubtensorModule::mint_tao(1_000_000u64.into()); SubtensorModule::run_coinbase(block_emissions); @@ -1087,6 +1090,7 @@ fn test_populate_staking_maps() { }); } +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::claim_root::test_claim_root_coinbase_distribution --exact --show-output #[test] fn test_claim_root_coinbase_distribution() { new_test_ext(1).execute_with(|| { @@ -1095,7 +1099,10 @@ fn test_claim_root_coinbase_distribution() { let coldkey = U256::from(1003); let netuid = add_dynamic_network(&hotkey, &owner_coldkey); - Tempo::::insert(netuid, 1); + // Period is `tempo`; with `tempo = 2` and the scheduler re-anchored at the + // current block, the epoch fires two steps later (at `run_to_block(3)`). + Tempo::::insert(netuid, 2); + crate::LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 let root_stake = 200_000_000u64; diff --git a/pallets/subtensor/src/tests/coinbase.rs b/pallets/subtensor/src/tests/coinbase.rs index b89041c98c..e06b3c7cd5 100644 --- a/pallets/subtensor/src/tests/coinbase.rs +++ b/pallets/subtensor/src/tests/coinbase.rs @@ -854,7 +854,7 @@ fn test_owner_cut_base() { 1_000_000_000_000_u64.into(), 1_000_000_000_000_u64.into(), ); - SubtensorModule::set_tempo(netuid, 10000); // Large number (dont drain) + SubtensorModule::set_tempo_unchecked(netuid, 10000); // Large number (dont drain) SubtensorModule::set_subnet_owner_cut(0); SubtensorModule::run_coinbase(SubtensorModule::mint_tao(0.into())); assert_eq!(PendingOwnerCut::::get(netuid), 0.into()); // No cut @@ -864,7 +864,7 @@ fn test_owner_cut_base() { }); } -// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::coinbase::test_pending_swapped --exact --show-output --nocapture +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::coinbase::test_pending_emission --exact --show-output --nocapture #[test] fn test_pending_emission() { new_test_ext(1).execute_with(|| { @@ -876,10 +876,13 @@ fn test_pending_emission() { FirstEmissionBlockNumber::::insert(netuid, 0); mock::setup_reserves(netuid, 1_000_000.into(), 1.into()); + LastEpochBlock::::insert(netuid, 0); + System::set_block_number(10); SubtensorModule::run_coinbase(SubtensorModule::mint_tao(0.into())); SubnetTAO::::insert(NetUid::ROOT, TaoBalance::from(1_000_000_000)); // Add root weight. + System::set_block_number(12); SubtensorModule::run_coinbase(SubtensorModule::mint_tao(0.into())); - SubtensorModule::set_tempo(netuid, 10000); // Large number (dont drain) + SubtensorModule::set_tempo_unchecked(netuid, 10000); // Large number (dont drain) SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 // Set moving price > 1.0 @@ -2656,7 +2659,7 @@ fn test_distribute_emission_zero_emission() { let miner_ck = U256::from(6); let init_stake: u64 = 100_000_000_000_000; let tempo = 2; - SubtensorModule::set_tempo(netuid, tempo); + SubtensorModule::set_tempo_unchecked(netuid, tempo); // Set weight-set limit to 0. SubtensorModule::set_weights_set_rate_limit(netuid, 0); @@ -2744,7 +2747,7 @@ fn test_run_coinbase_not_started() { let miner_ck = U256::from(6); let init_stake: u64 = 100_000_000_000_000; let tempo = 2; - SubtensorModule::set_tempo(netuid, tempo); + SubtensorModule::set_tempo_unchecked(netuid, tempo); // Set weight-set limit to 0. SubtensorModule::set_weights_set_rate_limit(netuid, 0); @@ -2839,7 +2842,7 @@ fn test_run_coinbase_not_started_start_after() { let miner_ck = U256::from(6); let init_stake: u64 = 100_000_000_000_000; let tempo = 2; - SubtensorModule::set_tempo(netuid, tempo); + SubtensorModule::set_tempo_unchecked(netuid, tempo); // Set weight-set limit to 0. SubtensorModule::set_weights_set_rate_limit(netuid, 0); @@ -2907,6 +2910,12 @@ fn test_run_coinbase_not_started_start_after() { Some(current_block + 1) ); + // Advance the block past `LastEpochBlock + tempo` so the state-based + // scheduler is due again (the previous `run_coinbase` advanced it). + next_block_no_epoch(netuid); + next_block_no_epoch(netuid); + next_block_no_epoch(netuid); + // Run coinbase with emission. let emission_credit = SubtensorModule::mint_tao(100_000_000.into()); SubtensorModule::run_coinbase(emission_credit); @@ -3170,6 +3179,7 @@ fn test_zero_shares_zero_emission() { }); } +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::coinbase::test_mining_emission_distribution_with_no_root_sell --exact --show-output --nocapture #[test] fn test_mining_emission_distribution_with_no_root_sell() { new_test_ext(1).execute_with(|| { @@ -3297,17 +3307,20 @@ fn test_mining_emission_distribution_with_no_root_sell() { AlphaBalance::ZERO, "Root alpha divs should be zero" ); + step_block(1); + // Drain to a clean epoch boundary so accumulation starts fresh. + step_epochs(1, netuid); let miner_stake_before_epoch = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( &miner_hotkey, &miner_coldkey, netuid, ); // Run again but with some root stake - step_block(subnet_tempo - 2); + step_block(subnet_tempo - 1); assert_abs_diff_eq!( PendingServerEmission::::get(netuid).to_u64(), U96F32::saturating_from_num(per_block_emission) - .saturating_mul(U96F32::saturating_from_num(subnet_tempo as u64)) + .saturating_mul(U96F32::saturating_from_num((subnet_tempo - 1) as u64)) .saturating_mul(U96F32::saturating_from_num(0.5)) // miner cut .saturating_mul(U96F32::saturating_from_num(0.90)) .saturating_to_num::(), @@ -3354,7 +3367,7 @@ fn test_mining_emission_distribution_with_no_root_sell() { U96F32::saturating_from_num(miner_incentive) .saturating_div(u16::MAX.into()) .saturating_mul(U96F32::saturating_from_num(per_block_emission)) - .saturating_mul(U96F32::saturating_from_num(subnet_tempo + 1)) + .saturating_mul(U96F32::saturating_from_num(subnet_tempo)) .saturating_mul(U96F32::saturating_from_num(0.45)) // miner cut .saturating_to_num::(), epsilon = 1_000_000_u64 @@ -3385,7 +3398,9 @@ fn test_mining_emission_distribution_with_root_sell() { let owner_hotkey = U256::from(10); let owner_coldkey = U256::from(11); let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); - Tempo::::insert(netuid, 1); + // Period is `tempo`; `tempo = 2` keeps a one-block gap between epochs so + // pending root-alpha-divs can be observed accumulating before a drain. + Tempo::::insert(netuid, 2); FirstEmissionBlockNumber::::insert(netuid, 0); // Setup large LPs to prevent slippage @@ -3473,6 +3488,7 @@ fn test_mining_emission_distribution_with_root_sell() { // Run run_coinbase until emissions are drained step_block(subnet_tempo); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); let old_root_alpha_divs = PendingRootAlphaDivs::::get(netuid); let miner_stake_before_epoch = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( &miner_hotkey, @@ -3528,7 +3544,7 @@ fn test_mining_emission_distribution_with_root_sell() { U96F32::saturating_from_num(miner_incentive) .saturating_div(u16::MAX.into()) .saturating_mul(U96F32::saturating_from_num(per_block_emission)) - .saturating_mul(U96F32::saturating_from_num(subnet_tempo + 1)) + .saturating_mul(U96F32::saturating_from_num(subnet_tempo)) .saturating_mul(U96F32::saturating_from_num(0.45)) // miner cut .saturating_to_num::(), epsilon = 1_000_000_u64 @@ -3782,8 +3798,8 @@ fn test_coinbase_drain_pending_resets_blockssincelaststep() { let zero = U96F32::saturating_from_num(0); let netuid0 = add_dynamic_network(&U256::from(1), &U256::from(2)); Tempo::::insert(netuid0, 100); - // Ensure the block number we use is the tempo block - let block_number = 98; + LastEpochBlock::::insert(netuid0, 0); + let block_number = 102; assert!(SubtensorModule::should_run_epoch(netuid0, block_number)); let blocks_since_last_step_before = 12345678; @@ -3795,8 +3811,7 @@ fn test_coinbase_drain_pending_resets_blockssincelaststep() { let blocks_since_last_step_after = BlocksSinceLastStep::::get(netuid0); assert_eq!(blocks_since_last_step_after, 0); - // Also check LastMechansimStepBlock is set to the block number we ran on - assert_eq!(LastMechansimStepBlock::::get(netuid0), block_number); + assert_eq!(LastMechansimStepBlock::::get(netuid0), 12345); }); } @@ -3806,8 +3821,8 @@ fn test_coinbase_drain_pending_gets_counters_and_resets_them() { let zero = U96F32::saturating_from_num(0); let netuid0 = add_dynamic_network(&U256::from(1), &U256::from(2)); Tempo::::insert(netuid0, 100); - // Ensure the block number we use is the tempo block - let block_number = 98; + LastEpochBlock::::insert(netuid0, 0); + let block_number = 102; assert!(SubtensorModule::should_run_epoch(netuid0, block_number)); let pending_server_em = AlphaBalance::from(123434534); @@ -4045,10 +4060,11 @@ fn test_disabling_owner_cut_sends_subnet_emission_to_miners_and_validators() { let miner_coldkey = U256::from(5); let miner_hotkey = U256::from(6); let netuid = add_dynamic_network(&subnet_owner_hotkey, &subnet_owner_coldkey); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); let subnet_tempo = 10; let stake = 100_000_000_000u64; - SubtensorModule::set_tempo(netuid, subnet_tempo); + SubtensorModule::set_tempo_unchecked(netuid, subnet_tempo); setup_reserves(netuid, (stake * 10_000).into(), (stake * 10_000).into()); register_ok_neuron(netuid, validator_hotkey, validator_coldkey, 0); diff --git a/pallets/subtensor/src/tests/emission.rs b/pallets/subtensor/src/tests/emission.rs index ecd2df544b..151fd3cddb 100644 --- a/pallets/subtensor/src/tests/emission.rs +++ b/pallets/subtensor/src/tests/emission.rs @@ -1,6 +1,7 @@ use subtensor_runtime_common::NetUid; use super::mock::*; +use crate::LastEpochBlock; // 1. Test Zero Tempo // Description: Verify that when tempo is 0, the function returns u64::MAX. @@ -9,7 +10,7 @@ use super::mock::*; fn test_zero_tempo() { new_test_ext(1).execute_with(|| { assert_eq!( - SubtensorModule::blocks_until_next_epoch(1.into(), 0, 100), + SubtensorModule::blocks_until_next_auto_epoch(1.into(), 0, 100), u64::MAX ); }); @@ -21,14 +22,21 @@ fn test_zero_tempo() { #[test] fn test_regular_case() { new_test_ext(1).execute_with(|| { - assert_eq!(SubtensorModule::blocks_until_next_epoch(1.into(), 10, 5), 3); + LastEpochBlock::::insert(NetUid::from(1), 0); + LastEpochBlock::::insert(NetUid::from(2), 0); + LastEpochBlock::::insert(NetUid::from(3), 0); + // (LastEpochBlock + tempo) - block. assert_eq!( - SubtensorModule::blocks_until_next_epoch(2.into(), 20, 15), - 2 + SubtensorModule::blocks_until_next_auto_epoch(1.into(), 10, 5), + 5 ); assert_eq!( - SubtensorModule::blocks_until_next_epoch(3.into(), 30, 25), - 1 + SubtensorModule::blocks_until_next_auto_epoch(2.into(), 20, 15), + 5 + ); + assert_eq!( + SubtensorModule::blocks_until_next_auto_epoch(3.into(), 30, 25), + 5 ); }); } @@ -39,12 +47,16 @@ fn test_regular_case() { #[test] fn test_boundary_conditions() { new_test_ext(1).execute_with(|| { + let netuid = NetUid::from(u16::MAX); + LastEpochBlock::::insert(netuid, 0); + // Far past the next-auto block — saturating to 0. assert_eq!( - SubtensorModule::blocks_until_next_epoch(u16::MAX.into(), u16::MAX, u64::MAX), + SubtensorModule::blocks_until_next_auto_epoch(netuid, u16::MAX, u64::MAX), 0 ); + // Block 0 — full period until next auto epoch. assert_eq!( - SubtensorModule::blocks_until_next_epoch(u16::MAX.into(), u16::MAX, 0), + SubtensorModule::blocks_until_next_auto_epoch(netuid, u16::MAX, 0), u16::MAX as u64 ); }); @@ -56,9 +68,11 @@ fn test_boundary_conditions() { #[test] fn test_overflow_handling() { new_test_ext(1).execute_with(|| { + let netuid = NetUid::from(u16::MAX); + LastEpochBlock::::insert(netuid, 0); assert_eq!( - SubtensorModule::blocks_until_next_epoch(u16::MAX.into(), u16::MAX, u64::MAX - 1), - 1 + SubtensorModule::blocks_until_next_auto_epoch(netuid, u16::MAX, u64::MAX - 1), + 0 ); }); } @@ -69,13 +83,17 @@ fn test_overflow_handling() { #[test] fn test_epoch_alignment() { new_test_ext(1).execute_with(|| { + LastEpochBlock::::insert(NetUid::from(1), 0); + LastEpochBlock::::insert(NetUid::from(2), 0); + // (LastEpochBlock + tempo) - block_number. assert_eq!( - SubtensorModule::blocks_until_next_epoch(1.into(), 10, 9), - 10 + SubtensorModule::blocks_until_next_auto_epoch(1.into(), 10, 9), + 1 ); + // Block exactly at next-auto — returns 0. assert_eq!( - SubtensorModule::blocks_until_next_epoch(2.into(), 20, 21), - 17 + SubtensorModule::blocks_until_next_auto_epoch(2.into(), 20, 21), + 0 ); }); } @@ -86,9 +104,23 @@ fn test_epoch_alignment() { #[test] fn test_different_network_ids() { new_test_ext(1).execute_with(|| { - assert_eq!(SubtensorModule::blocks_until_next_epoch(1.into(), 10, 5), 3); - assert_eq!(SubtensorModule::blocks_until_next_epoch(2.into(), 10, 5), 2); - assert_eq!(SubtensorModule::blocks_until_next_epoch(3.into(), 10, 5), 1); + // Anchor each subnet identically — proves the new formula does NOT + // depend on `netuid` (only on the per-subnet `LastEpochBlock`). + LastEpochBlock::::insert(NetUid::from(1), 0); + LastEpochBlock::::insert(NetUid::from(2), 0); + LastEpochBlock::::insert(NetUid::from(3), 0); + assert_eq!( + SubtensorModule::blocks_until_next_auto_epoch(1.into(), 10, 5), + 5 + ); + assert_eq!( + SubtensorModule::blocks_until_next_auto_epoch(2.into(), 10, 5), + 5 + ); + assert_eq!( + SubtensorModule::blocks_until_next_auto_epoch(3.into(), 10, 5), + 5 + ); }); } @@ -98,9 +130,11 @@ fn test_different_network_ids() { #[test] fn test_large_tempo_values() { new_test_ext(1).execute_with(|| { + let netuid = NetUid::from(1); + LastEpochBlock::::insert(netuid, 0); assert_eq!( - SubtensorModule::blocks_until_next_epoch(1.into(), u16::MAX - 1, 100), - u16::MAX as u64 - 103 + SubtensorModule::blocks_until_next_auto_epoch(netuid, u16::MAX - 1, 100), + (u16::MAX as u64).saturating_sub(1).saturating_sub(100) ); }); } @@ -113,9 +147,11 @@ fn test_consecutive_blocks() { new_test_ext(1).execute_with(|| { let tempo = 10; let netuid = NetUid::from(1); - let mut last_result = SubtensorModule::blocks_until_next_epoch(netuid, tempo, 0); + LastEpochBlock::::insert(netuid, 0); + let mut last_result = SubtensorModule::blocks_until_next_auto_epoch(netuid, tempo, 0); for i in 1..tempo - 1 { - let current_result = SubtensorModule::blocks_until_next_epoch(netuid, tempo, i as u64); + let current_result = + SubtensorModule::blocks_until_next_auto_epoch(netuid, tempo, i as u64); assert_eq!(current_result, last_result - 1); last_result = current_result; } @@ -128,13 +164,16 @@ fn test_consecutive_blocks() { #[test] fn test_wrap_around_behavior() { new_test_ext(1).execute_with(|| { + let netuid = NetUid::from(1); + LastEpochBlock::::insert(netuid, 0); + // `next_auto - block_number` saturates to 0 for far-future blocks. assert_eq!( - SubtensorModule::blocks_until_next_epoch(1.into(), 10, u64::MAX), - 9 + SubtensorModule::blocks_until_next_auto_epoch(netuid, 10, u64::MAX), + 0 ); assert_eq!( - SubtensorModule::blocks_until_next_epoch(1.into(), 10, u64::MAX - 1), - 10 + SubtensorModule::blocks_until_next_auto_epoch(netuid, 10, u64::MAX - 1), + 0 ); }); } diff --git a/pallets/subtensor/src/tests/ensure.rs b/pallets/subtensor/src/tests/ensure.rs index 1253285306..008be48b15 100644 --- a/pallets/subtensor/src/tests/ensure.rs +++ b/pallets/subtensor/src/tests/ensure.rs @@ -66,16 +66,22 @@ fn ensure_subnet_owner_or_root_distinguishes_root_and_owner() { fn ensure_admin_window_open_blocks_in_freeze_window() { new_test_ext(1).execute_with(|| { let netuid = NetUid::from(0); - let tempo = 10; - add_network(netuid, 10, 0); + let tempo: u16 = 10; + add_network(netuid, tempo, 0); - let freeze_window = 3; + let freeze_window: u16 = 3; crate::Pallet::::set_admin_freeze_window(freeze_window); - System::set_block_number((tempo - freeze_window).into()); + crate::LastEpochBlock::::insert(netuid, 0); + // Period is `tempo`: next auto-epoch fires at `LastEpochBlock + tempo`. + let next_auto = tempo as u64; + + // Inside freeze window: `next_auto - freeze_window + 1`. + System::set_block_number(next_auto - freeze_window as u64 + 1); assert!(crate::Pallet::::ensure_admin_window_open(netuid).is_err()); - System::set_block_number((tempo - freeze_window - 1).into()); + // Outside freeze window: `next_auto - freeze_window`. + System::set_block_number(next_auto - freeze_window as u64); assert!(crate::Pallet::::ensure_admin_window_open(netuid).is_ok()); }); } @@ -93,7 +99,7 @@ fn ensure_owner_or_root_with_limits_checks_rl_and_freeze() { crate::Pallet::::set_admin_freeze_window(0); // Set tempo to 1 so owner hyperparam RL = 2 blocks - crate::Pallet::::set_tempo(netuid, 1); + crate::Pallet::::set_tempo_unchecked(netuid, 1); assert_eq!(OwnerHyperparamRateLimit::::get(), 2); @@ -135,12 +141,12 @@ fn ensure_owner_or_root_with_limits_checks_rl_and_freeze() { // (using loop for clarity, because epoch calculation function uses netuid) // Restore tempo and configure freeze window for this part let freeze_window = 3; - crate::Pallet::::set_tempo(netuid, tempo); + crate::Pallet::::set_tempo_unchecked(netuid, tempo); crate::Pallet::::set_admin_freeze_window(freeze_window); let freeze_window = freeze_window as u64; loop { let cur = crate::Pallet::::get_current_block_as_u64(); - let rem = crate::Pallet::::blocks_until_next_epoch(netuid, tempo, cur); + let rem = crate::Pallet::::blocks_until_next_auto_epoch(netuid, tempo, cur); if rem < freeze_window { break; } diff --git a/pallets/subtensor/src/tests/epoch.rs b/pallets/subtensor/src/tests/epoch.rs index 02236d892d..b0383521a8 100644 --- a/pallets/subtensor/src/tests/epoch.rs +++ b/pallets/subtensor/src/tests/epoch.rs @@ -2052,14 +2052,14 @@ fn test_deregistered_miner_bonds() { } // Set tempo high so we don't automatically run epochs - SubtensorModule::set_tempo(netuid, high_tempo); + SubtensorModule::set_tempo_unchecked(netuid, high_tempo); // Run 2 blocks next_block(); next_block(); // set tempo to 2 blocks - SubtensorModule::set_tempo(netuid, 2); + SubtensorModule::set_tempo_unchecked(netuid, 2); // Run epoch if sparse { SubtensorModule::epoch(netuid, 1_000_000_000.into()); @@ -2077,7 +2077,7 @@ fn test_deregistered_miner_bonds() { assert!(bond_0_3 > 0); // Set tempo high so we don't automatically run epochs - SubtensorModule::set_tempo(netuid, high_tempo); + SubtensorModule::set_tempo_unchecked(netuid, high_tempo); // Run one more block next_block(); @@ -2137,7 +2137,7 @@ fn test_deregistered_miner_bonds() { ); // set tempo to 2 blocks - SubtensorModule::set_tempo(netuid, 2); + SubtensorModule::set_tempo_unchecked(netuid, 2); // Run epoch again. if sparse { SubtensorModule::epoch(netuid, 1_000_000_000.into()); @@ -2465,7 +2465,7 @@ fn test_blocks_since_last_step() { assert!(new_blocks > original_blocks); assert_eq!(new_blocks, 5); - let blocks_to_step: u16 = SubtensorModule::blocks_until_next_epoch( + let blocks_to_step: u16 = SubtensorModule::blocks_until_next_auto_epoch( netuid, tempo, SubtensorModule::get_current_block_as_u64(), @@ -2477,7 +2477,7 @@ fn test_blocks_since_last_step() { assert_eq!(post_blocks, 10); - let blocks_to_step: u16 = SubtensorModule::blocks_until_next_epoch( + let blocks_to_step: u16 = SubtensorModule::blocks_until_next_auto_epoch( netuid, tempo, SubtensorModule::get_current_block_as_u64(), @@ -3784,7 +3784,7 @@ fn test_epoch_does_not_mask_outside_window_but_masks_inside() { SubtensorModule::set_validator_permit_for_uid(netuid, 0, true); SubtensorModule::set_max_allowed_validators(netuid, 1); - run_to_block(tempo as u64 + 1); + run_to_block(tempo as u64); /* first commit */ commit_dummy(v_hot, netuid); @@ -3801,7 +3801,7 @@ fn test_epoch_does_not_mask_outside_window_but_masks_inside() { /* let first commit expire for UID‑1 */ for _ in 0..(reveal + 1) { - run_to_block(System::block_number() + tempo as u64 + 1); + run_to_block(System::block_number() + tempo as u64); } /* second commit — will mask UID‑2 & UID‑3 */ diff --git a/pallets/subtensor/src/tests/locks.rs b/pallets/subtensor/src/tests/locks.rs index f4eede30e4..a75f0b705f 100644 --- a/pallets/subtensor/src/tests/locks.rs +++ b/pallets/subtensor/src/tests/locks.rs @@ -3009,7 +3009,7 @@ fn test_epoch_distribution_auto_locks_owner_cut() { let subnet_tempo = 10; let stake = 100_000_000_000u64; - SubtensorModule::set_tempo(netuid, subnet_tempo); + SubtensorModule::set_tempo_unchecked(netuid, subnet_tempo); SubtensorModule::set_ck_burn(0); setup_reserves(netuid, (stake * 10_000).into(), (stake * 10_000).into()); @@ -3072,7 +3072,7 @@ fn test_epoch_distribution_auto_locks_owner_cut() { ); // Advance to the next epoch so owner cut is distributed and auto-locked. - step_block(subnet_tempo); + step_epochs(1, netuid); let owner_stake_after = get_alpha(&subnet_owner_hotkey, &subnet_owner_coldkey, netuid); let owner_cut_locked = owner_stake_after - owner_stake_before; diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index a4c68e9d1b..841dff201a 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -4394,3 +4394,119 @@ fn test_migrate_fix_total_issuance_evm_fees() { ); }); } + +#[test] +fn test_migrate_dynamic_tempo_aligns_first_post_upgrade_fire() { + new_test_ext(1).execute_with(|| { + const MIGRATION_NAME: &str = "dynamic_tempo_v1"; + let netuid = NetUid::from(7u16); + let tempo: u16 = 360; + + add_network(netuid, tempo, 0); + let current_block = 1234u64; + run_to_block(current_block); + + // Compute next-fire block + let netuid_plus_one = (u16::from(netuid) as u64) + 1; + let tempo_plus_one = (tempo as u64) + 1; + let adjusted = current_block + netuid_plus_one; + let remainder = adjusted % tempo_plus_one; + let legacy_blocks_until_next = (tempo as u64) - remainder; + let expected_next_fire = current_block + legacy_blocks_until_next; + + crate::migrations::migrate_dynamic_tempo::migrate_dynamic_tempo::(); + + // New formula: next fire = LastEpochBlock + tempo. + let last_epoch = LastEpochBlock::::get(netuid); + assert_eq!( + last_epoch + tempo as u64, + expected_next_fire, + "back-fill should make new scheduler fire at the same block as legacy modulo" + ); + assert!(HasMigrationRun::::get( + MIGRATION_NAME.as_bytes().to_vec() + )); + }); +} + +#[test] +fn test_migrate_dynamic_tempo_preserves_non_standard_tempo() { + new_test_ext(1).execute_with(|| { + // Three subnets — one standard, two with non-standard tempo + // (simulates the 2 mainnet subnets root configured outside MIN/MAX bounds). + let standard = NetUid::from(1u16); + let small = NetUid::from(2u16); + let large = NetUid::from(3u16); + + add_network(standard, 360, 0); + add_network(small, 10, 0); // < MIN_TEMPO (360) + add_network(large, 60_000, 0); // > MAX_TEMPO (50_400) + + crate::migrations::migrate_dynamic_tempo::migrate_dynamic_tempo::(); + + // Tempo values preserved as-is — no clamp. + assert_eq!(Tempo::::get(standard), 360); + assert_eq!(Tempo::::get(small), 10); + assert_eq!(Tempo::::get(large), 60_000); + + // All non-zero tempos got LastEpochBlock seeded. + assert!(LastEpochBlock::::contains_key(standard)); + assert!(LastEpochBlock::::contains_key(small)); + assert!(LastEpochBlock::::contains_key(large)); + }); +} + +#[test] +fn test_migrate_dynamic_tempo_activity_cutoff_round_trips_production_values() { + new_test_ext(1).execute_with(|| { + // (cutoff_blocks, tempo) combinations from production data. + let cases: [(u16, u16); 6] = [ + (5000, 360), + (6000, 360), + (7200, 360), + (12000, 360), + (1000, 360), + (360, 360), + ]; + + for (i, &(cutoff, tempo)) in cases.iter().enumerate() { + let netuid = NetUid::from((i + 1) as u16); + add_network(netuid, tempo, 0); + ActivityCutoff::::insert(netuid, cutoff); + } + + crate::migrations::migrate_dynamic_tempo::migrate_dynamic_tempo::(); + + for (i, &(cutoff, _)) in cases.iter().enumerate() { + let netuid = NetUid::from((i + 1) as u16); + // get_activity_cutoff_blocks = factor * tempo / 1000 must equal original cutoff exactly. + assert_eq!( + crate::Pallet::::get_activity_cutoff_blocks(netuid), + cutoff as u64, + "ceiling division must round-trip cutoff exactly for netuid {}", + u16::from(netuid) + ); + } + }); +} + +#[test] +fn test_migrate_dynamic_tempo_idempotent() { + new_test_ext(1).execute_with(|| { + let netuid = NetUid::from(1u16); + add_network(netuid, 360, 0); + + crate::migrations::migrate_dynamic_tempo::migrate_dynamic_tempo::(); + let last_epoch_first = LastEpochBlock::::get(netuid); + + // Mutate state to verify second run is a no-op. + run_to_block(crate::Pallet::::get_current_block_as_u64() + 100); + crate::migrations::migrate_dynamic_tempo::migrate_dynamic_tempo::(); + + assert_eq!( + LastEpochBlock::::get(netuid), + last_epoch_first, + "second migration call must be a no-op" + ); + }); +} diff --git a/pallets/subtensor/src/tests/mock.rs b/pallets/subtensor/src/tests/mock.rs index 8c553e3ee8..c925332a2f 100644 --- a/pallets/subtensor/src/tests/mock.rs +++ b/pallets/subtensor/src/tests/mock.rs @@ -694,9 +694,9 @@ pub(crate) fn next_block_no_epoch(netuid: NetUid) -> u64 { let high_tempo: u16 = u16::MAX - 1; let old_tempo: u16 = SubtensorModule::get_tempo(netuid); - SubtensorModule::set_tempo(netuid, high_tempo); + SubtensorModule::set_tempo_unchecked(netuid, high_tempo); let new_block = next_block(); - SubtensorModule::set_tempo(netuid, old_tempo); + SubtensorModule::set_tempo_unchecked(netuid, old_tempo); new_block } @@ -707,27 +707,27 @@ pub(crate) fn run_to_block_no_epoch(netuid: NetUid, n: u64) { let high_tempo: u16 = u16::MAX - 1; let old_tempo: u16 = SubtensorModule::get_tempo(netuid); - SubtensorModule::set_tempo(netuid, high_tempo); + SubtensorModule::set_tempo_unchecked(netuid, high_tempo); run_to_block(n); - SubtensorModule::set_tempo(netuid, old_tempo); + SubtensorModule::set_tempo_unchecked(netuid, old_tempo); } #[allow(dead_code)] pub(crate) fn step_epochs(count: u16, netuid: NetUid) { - for _ in 0..count { - let blocks_to_next_epoch = SubtensorModule::blocks_until_next_epoch( - netuid, - SubtensorModule::get_tempo(netuid), - SubtensorModule::get_current_block_as_u64(), - ); - log::info!("Blocks to next epoch: {blocks_to_next_epoch:?}"); - step_block(blocks_to_next_epoch as u16); - - assert!(SubtensorModule::should_run_epoch( - netuid, - SubtensorModule::get_current_block_as_u64() - )); + const STEP_EPOCHS_MAX_BLOCKS: u32 = 50_000; + + // Advance block-by-block until exactly `count` more epoch slots have been + // consumed for `netuid`, observed via the `SubnetEpochIndex` counter. Robust + // to any tempo (including `tempo == 1`) and to the per-block epoch cap. + let target = crate::SubnetEpochIndex::::get(netuid).saturating_add(count as u64); + let mut blocks_advanced: u32 = 0; + while crate::SubnetEpochIndex::::get(netuid) < target { step_block(1); + blocks_advanced = blocks_advanced.saturating_add(1); + assert!( + blocks_advanced < STEP_EPOCHS_MAX_BLOCKS, + "step_epochs: epoch counter never advanced (tempo == 0?)" + ); } } diff --git a/pallets/subtensor/src/tests/mod.rs b/pallets/subtensor/src/tests/mod.rs index 91a89129a6..0dc02aca69 100644 --- a/pallets/subtensor/src/tests/mod.rs +++ b/pallets/subtensor/src/tests/mod.rs @@ -31,6 +31,7 @@ mod swap_coldkey; mod swap_hotkey; mod swap_hotkey_with_subnet; mod tao; +mod tempo_control; mod transaction_extension_pays_no; mod uids; mod voting_power; diff --git a/pallets/subtensor/src/tests/staking.rs b/pallets/subtensor/src/tests/staking.rs index 0fe951a29b..cc13ae9e46 100644 --- a/pallets/subtensor/src/tests/staking.rs +++ b/pallets/subtensor/src/tests/staking.rs @@ -1103,7 +1103,7 @@ fn test_staking_sets_div_variables() { ); // Wait for 1 epoch - step_block(tempo + 1); + step_epochs(1, netuid); // Verify that divident variables have been set let stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( diff --git a/pallets/subtensor/src/tests/tempo_control.rs b/pallets/subtensor/src/tests/tempo_control.rs new file mode 100644 index 0000000000..698187bb3e --- /dev/null +++ b/pallets/subtensor/src/tests/tempo_control.rs @@ -0,0 +1,159 @@ +#![allow(clippy::expect_used)] +use frame_support::assert_ok; +use frame_system::Config; +use sp_core::U256; +use subtensor_runtime_common::NetUid; + +use super::mock::*; +use crate::{ + AdminFreezeWindow, CommitRevealWeightsEnabled, LastEpochBlock, PendingEpochAt, SubnetOwner, + SubtokenEnabled, Tempo, +}; + +const DEFAULT_TEMPO: u16 = 360; +const NEW_TEMPO: u16 = 720; + +fn setup_subnet(owner: U256) -> NetUid { + let netuid = NetUid::from(1); + add_network(netuid, DEFAULT_TEMPO, 0); + SubnetOwner::::insert(netuid, owner); + SubtokenEnabled::::insert(netuid, true); + crate::Pallet::::set_admin_freeze_window(0); + netuid +} + +#[test] +fn do_set_tempo_works_with_commit_reveal_enabled() { + new_test_ext(1).execute_with(|| { + let owner = U256::from(1); + let netuid = setup_subnet(owner); + + // CR is enabled by default; `set_tempo` is no longer blocked for CR + // subnets — CR timing keys off the stateful `SubnetEpochIndex` counter. + assert!(CommitRevealWeightsEnabled::::get(netuid)); + + assert_ok!(crate::Pallet::::do_set_tempo( + <::RuntimeOrigin>::signed(owner), + netuid, + NEW_TEMPO, + )); + + assert_eq!(Tempo::::get(netuid), NEW_TEMPO); + }); +} + +#[test] +fn do_trigger_epoch_works_with_commit_reveal_enabled() { + new_test_ext(1).execute_with(|| { + let owner = U256::from(1); + let netuid = setup_subnet(owner); + + // CR enabled by default; `trigger_epoch` is no longer blocked. + assert!(CommitRevealWeightsEnabled::::get(netuid)); + AdminFreezeWindow::::set(5); + + assert_ok!(crate::Pallet::::do_trigger_epoch( + <::RuntimeOrigin>::signed(owner), + netuid, + )); + + let now = crate::Pallet::::get_current_block_as_u64(); + assert_eq!(PendingEpochAt::::get(netuid), now + 5); + }); +} + +#[test] +fn get_next_epoch_start_block_returns_none_when_tempo_zero() { + new_test_ext(1).execute_with(|| { + let owner = U256::from(1); + let netuid = setup_subnet(owner); + + Tempo::::insert(netuid, 0); + + assert_eq!( + crate::Pallet::::get_next_epoch_start_block(netuid), + None + ); + }); +} + +#[test] +fn get_next_epoch_start_block_uses_last_epoch_block_plus_tempo() { + new_test_ext(1).execute_with(|| { + let owner = U256::from(1); + let netuid = setup_subnet(owner); + + LastEpochBlock::::insert(netuid, 100u64); + Tempo::::insert(netuid, 50u16); + PendingEpochAt::::insert(netuid, 0u64); + + // last (100) + tempo (50) = 150 + assert_eq!( + crate::Pallet::::get_next_epoch_start_block(netuid), + Some(150) + ); + }); +} + +#[test] +fn get_next_epoch_start_block_returns_pending_when_pending_is_earlier() { + new_test_ext(1).execute_with(|| { + let owner = U256::from(1); + let netuid = setup_subnet(owner); + + LastEpochBlock::::insert(netuid, 100u64); + Tempo::::insert(netuid, 50u16); + // Owner-triggered manual fire scheduled before automatic next. + PendingEpochAt::::insert(netuid, 120u64); + + // min(150, 120) = 120 + assert_eq!( + crate::Pallet::::get_next_epoch_start_block(netuid), + Some(120) + ); + }); +} + +#[test] +fn get_next_epoch_start_block_ignores_pending_when_auto_is_earlier() { + new_test_ext(1).execute_with(|| { + let owner = U256::from(1); + let netuid = setup_subnet(owner); + + LastEpochBlock::::insert(netuid, 100u64); + Tempo::::insert(netuid, 50u16); + // Pending scheduled after the next automatic fire. + PendingEpochAt::::insert(netuid, 200u64); + + // min(150, 200) = 150 + assert_eq!( + crate::Pallet::::get_next_epoch_start_block(netuid), + Some(150) + ); + }); +} + +#[test] +fn get_next_epoch_start_block_reflects_set_tempo_cycle_reset() { + new_test_ext(1).execute_with(|| { + let owner = U256::from(1); + let netuid = setup_subnet(owner); + + run_to_block(10); + let new_tempo: u16 = 720; + + assert_ok!(crate::Pallet::::do_set_tempo( + <::RuntimeOrigin>::signed(owner), + netuid, + new_tempo, + )); + + let now = crate::Pallet::::get_current_block_as_u64(); + // apply_tempo_with_cycle_reset sets LastEpochBlock = now; + // next fire is now + tempo. + assert_eq!( + crate::Pallet::::get_next_epoch_start_block(netuid), + Some(now + new_tempo as u64) + ); + }); +} diff --git a/pallets/subtensor/src/tests/weights.rs b/pallets/subtensor/src/tests/weights.rs index f9afd96033..eb84324770 100644 --- a/pallets/subtensor/src/tests/weights.rs +++ b/pallets/subtensor/src/tests/weights.rs @@ -351,9 +351,8 @@ fn test_reveal_weights_validate() { &salt, version_key, ); - let commit_block = SubtensorModule::get_current_block_as_u64(); - let (first_reveal_block, last_reveal_block) = - SubtensorModule::get_reveal_blocks(netuid, commit_block); + // Counter is 0 on a fresh subnet; tag the commit with epoch 0. + let commit_epoch: u64 = 0; // Create netuid add_network(netuid, tempo, 0); @@ -424,12 +423,7 @@ fn test_reveal_weights_validate() { WeightCommits::::mutate(NetUidStorageIndex::from(netuid), hotkey, |maybe_commits| { let mut commits: VecDeque<(H256, u64, u64, u64)> = maybe_commits.take().unwrap_or_default(); - commits.push_back(( - commit_hash, - commit_block, - first_reveal_block, - last_reveal_block, - )); + commits.push_back((commit_hash, commit_epoch, 0, 0)); *maybe_commits = Some(commits); }); @@ -448,7 +442,13 @@ fn test_reveal_weights_validate() { CustomTransactionError::CommitBlockNotInRevealRange.into() ); - System::set_block_number(commit_block + 2 * tempo as u64); + // Advance the epoch counter into the commit's reveal epoch + // (`commit_epoch + reveal_period`); pin the scheduler so the look-ahead + // does not overshoot. + let reveal_period = SubtensorModule::get_reveal_period(netuid); + SubnetEpochIndex::::insert(netuid, commit_epoch + reveal_period); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); + PendingEpochAt::::insert(netuid, 0); // Submit to the signed extension validate function let result_valid_stake = extension.validate( @@ -486,7 +486,9 @@ fn test_reveal_weights_validate() { // The call should still pass assert_ok!(result_more_stake); - System::set_block_number(commit_block + 10 * tempo as u64); + // Advance the counter past the commit's reveal epoch — now too late. + SubnetEpochIndex::::insert(netuid, commit_epoch + reveal_period + 1); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); // Submit to the signed extension validate function let result_too_late = extension.validate( @@ -651,7 +653,12 @@ fn test_batch_reveal_weights_validate() { )); } - let commit_block = SubtensorModule::get_current_block_as_u64(); + // Epoch all the commits were tagged with (committed in a tight loop). + let commit_epoch = + crate::WeightCommits::::get(NetUidStorageIndex::from(netuid), hotkey) + .and_then(|q| q.back().map(|(_, e, _, _)| *e)) + .expect("commit stored"); + let reveal_period = SubtensorModule::get_reveal_period(netuid); // Test 5: CommitBlockNotInRevealRange - Try to reveal too early let result_too_early = extension.validate( @@ -668,8 +675,10 @@ fn test_batch_reveal_weights_validate() { CustomTransactionError::CommitBlockNotInRevealRange.into() ); - // Move to valid reveal period - System::set_block_number(commit_block + 2 * tempo as u64); + // Advance the epoch counter into the commits' reveal epoch. + SubnetEpochIndex::::insert(netuid, commit_epoch + reveal_period); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); + PendingEpochAt::::insert(netuid, 0); // Now the call should pass the signed extension validation let result_valid_time = extension.validate( @@ -683,8 +692,9 @@ fn test_batch_reveal_weights_validate() { ); assert_ok!(result_valid_time); - // Test 6: CommitBlockNotInRevealRange - Try to reveal too late - System::set_block_number(commit_block + 10 * tempo as u64); + // Test 6: CommitBlockNotInRevealRange - reveal too late (counter past reveal epoch) + SubnetEpochIndex::::insert(netuid, commit_epoch + reveal_period + 1); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); let result_too_late = extension.validate( RawOrigin::Signed(who).into(), @@ -2231,7 +2241,7 @@ fn test_tempo_change_during_commit_reveal_process() { let tempo_before_next_reveal: u16 = 200; log::info!("Changing tempo to {tempo_before_next_reveal}"); - SubtensorModule::set_tempo(netuid, tempo_before_next_reveal); + SubtensorModule::set_tempo_unchecked(netuid, tempo_before_next_reveal); step_epochs(1, netuid); log::info!( @@ -2264,7 +2274,7 @@ fn test_tempo_change_during_commit_reveal_process() { let tempo: u16 = 150; log::info!("Changing tempo to {tempo}"); - SubtensorModule::set_tempo(netuid, tempo); + SubtensorModule::set_tempo_unchecked(netuid, tempo); step_epochs(1, netuid); log::info!( @@ -2287,7 +2297,7 @@ fn test_tempo_change_during_commit_reveal_process() { let tempo: u16 = 1050; log::info!("Changing tempo to {tempo}"); - SubtensorModule::set_tempo(netuid, tempo); + SubtensorModule::set_tempo_unchecked(netuid, tempo); assert_ok!(SubtensorModule::commit_weights( RuntimeOrigin::signed(hotkey), @@ -2301,7 +2311,7 @@ fn test_tempo_change_during_commit_reveal_process() { let tempo: u16 = 805; log::info!("Changing tempo to {tempo}"); - SubtensorModule::set_tempo(netuid, tempo); + SubtensorModule::set_tempo_unchecked(netuid, tempo); step_epochs(1, netuid); log::info!( @@ -3149,7 +3159,7 @@ fn test_tempo_and_reveal_period_change_during_commit_reveal_process() { // Step 2: Change tempo and reveal period after commit let new_tempo: u16 = 50; let new_reveal_period: u64 = 2; - SubtensorModule::set_tempo(netuid, new_tempo); + SubtensorModule::set_tempo_unchecked(netuid, new_tempo); assert_ok!(SubtensorModule::set_reveal_period(netuid, new_reveal_period)); log::info!( "Changed tempo to {new_tempo} and reveal period to {new_reveal_period}" @@ -3203,7 +3213,7 @@ fn test_tempo_and_reveal_period_change_during_commit_reveal_process() { // Step 4: Change tempo and reveal period again after reveal let new_tempo_after_reveal: u16 = 200; let new_reveal_period_after_reveal: u64 = 1; - SubtensorModule::set_tempo(netuid, new_tempo_after_reveal); + SubtensorModule::set_tempo_unchecked(netuid, new_tempo_after_reveal); assert_ok!(SubtensorModule::set_reveal_period( netuid, new_reveal_period_after_reveal @@ -3427,49 +3437,31 @@ fn test_reveal_at_exact_block() { commit_hash )); - let commit_block = SubtensorModule::get_current_block_as_u64(); - let commit_epoch = SubtensorModule::get_epoch_index(netuid, commit_block); - let reveal_epoch = commit_epoch.saturating_add(reveal_period); + // Epoch the commit was tagged with (counter is the canonical index). + let commit_epoch = + crate::WeightCommits::::get(NetUidStorageIndex::from(netuid), hotkey) + .and_then(|q| q.back().map(|(_, e, _, _)| *e)) + .expect("commit stored"); - // Calculate the block number where the reveal epoch starts - let tempo_plus_one = (tempo as u64).saturating_add(1); - let netuid_plus_one = (u16::from(netuid) as u64).saturating_add(1); - let reveal_epoch_start_block = reveal_epoch - .saturating_mul(tempo_plus_one) - .saturating_sub(netuid_plus_one); - - // Attempt to reveal before the reveal epoch starts - let current_block = SubtensorModule::get_current_block_as_u64(); - if current_block < reveal_epoch_start_block { - // Advance to one block before the reveal epoch starts - let blocks_to_advance = reveal_epoch_start_block - current_block; - if blocks_to_advance > 1 { - // Advance to one block before the reveal epoch - let new_block_number = current_block + blocks_to_advance - 1; - System::set_block_number(new_block_number); - } - - // Attempt to reveal too early - assert_err!( - SubtensorModule::reveal_weights( - RuntimeOrigin::signed(hotkey), - netuid, - uids.clone(), - weight_values.clone(), - salt.clone(), - version_key - ), - Error::::RevealTooEarly - ); + // Attempt to reveal before the reveal epoch — too early. + assert_err!( + SubtensorModule::reveal_weights( + RuntimeOrigin::signed(hotkey), + netuid, + uids.clone(), + weight_values.clone(), + salt.clone(), + version_key + ), + Error::::RevealTooEarly + ); - // Advance one more block to reach the exact reveal epoch start block - System::set_block_number(reveal_epoch_start_block); - } else { - // If we're already at or past the reveal epoch start block - System::set_block_number(reveal_epoch_start_block); - } + // Advance the epoch counter into the reveal epoch; pin the scheduler. + SubnetEpochIndex::::insert(netuid, commit_epoch + reveal_period); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); + PendingEpochAt::::insert(netuid, 0); - // Reveal at the exact allowed block + // Reveal at the exact allowed epoch assert_ok!(SubtensorModule::reveal_weights( RuntimeOrigin::signed(hotkey), netuid, @@ -3508,18 +3500,13 @@ fn test_reveal_at_exact_block() { new_commit_hash )); - // Advance blocks to after the commit expires - let commit_block = SubtensorModule::get_current_block_as_u64(); - let commit_epoch = SubtensorModule::get_epoch_index(netuid, commit_block); - let reveal_epoch = commit_epoch.saturating_add(reveal_period); - let expiration_epoch = reveal_epoch.saturating_add(1); - let expiration_epoch_start_block = expiration_epoch * tempo_plus_one - netuid_plus_one; - - let current_block = SubtensorModule::get_current_block_as_u64(); - if current_block < expiration_epoch_start_block { - // Advance to the block where the commit expires - System::set_block_number(expiration_epoch_start_block); - } + // Advance the epoch counter past the reveal epoch — commit expired. + let new_commit_epoch = + crate::WeightCommits::::get(NetUidStorageIndex::from(netuid), hotkey) + .and_then(|q| q.back().map(|(_, e, _, _)| *e)) + .expect("commit stored"); + SubnetEpochIndex::::insert(netuid, new_commit_epoch + reveal_period + 1); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); // Attempt to reveal after the commit has expired assert_err!( @@ -4272,7 +4259,7 @@ fn test_highly_concurrent_commits_and_reveals_with_multiple_hotkeys() { } // ==== Modify Network Parameters During Commits ==== - SubtensorModule::set_tempo(netuid, 150); + SubtensorModule::set_tempo_unchecked(netuid, 150); assert_ok!(SubtensorModule::set_reveal_period(netuid, 7)); log::info!("Changed tempo to 150 and reveal_period to 7 during commits."); @@ -4318,7 +4305,7 @@ fn test_highly_concurrent_commits_and_reveals_with_multiple_hotkeys() { } // ==== Change Network Parameters Again ==== - SubtensorModule::set_tempo(netuid, 200); + SubtensorModule::set_tempo_unchecked(netuid, 200); assert_ok!(SubtensorModule::set_reveal_period(netuid, 10)); log::info!("Changed tempo to 200 and reveal_period to 10 after initial reveals."); @@ -4421,146 +4408,6 @@ fn test_highly_concurrent_commits_and_reveals_with_multiple_hotkeys() { }) } -// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::weights::test_get_reveal_blocks --exact --show-output --nocapture -#[test] -fn test_get_reveal_blocks() { - new_test_ext(1).execute_with(|| { - // **1. Define Test Parameters** - let netuid = NetUid::from(1); - let uids: Vec = vec![0, 1]; - let weight_values: Vec = vec![10, 10]; - let salt: Vec = vec![1, 2, 3, 4, 5, 6, 7, 8]; - let version_key: u64 = 0; - let hotkey: U256 = U256::from(1); - - // **2. Generate the Commit Hash** - let commit_hash: H256 = BlakeTwo256::hash_of(&( - hotkey, - netuid, - uids.clone(), - weight_values.clone(), - salt.clone(), - version_key, - )); - - // **3. Initialize the Block Number to 0** - System::set_block_number(0); - - // **4. Define Network Parameters** - let tempo: u16 = 5; - add_network(netuid, tempo, 0); - - // **5. Register Neurons and Configure the Network** - register_ok_neuron(netuid, U256::from(3), U256::from(4), 300_000); - register_ok_neuron(netuid, U256::from(1), U256::from(2), 100_000); - SubtensorModule::set_stake_threshold(0); - SubtensorModule::set_weights_set_rate_limit(netuid, 5); - SubtensorModule::set_validator_permit_for_uid(netuid, 0, true); - SubtensorModule::set_validator_permit_for_uid(netuid, 1, true); - SubtensorModule::set_commit_reveal_weights_enabled(netuid, true); - add_balance_to_coldkey_account(&U256::from(0), 1.into()); - add_balance_to_coldkey_account(&U256::from(1), 1.into()); - SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( - &(U256::from(0)), - &(U256::from(0)), - netuid, - 1.into(), - ); - SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( - &(U256::from(1)), - &(U256::from(1)), - netuid, - 1.into(), - ); - - // **6. Commit Weights at Block 0** - assert_ok!(SubtensorModule::commit_weights( - RuntimeOrigin::signed(hotkey), - netuid, - commit_hash - )); - - // **7. Retrieve the Reveal Blocks Using `get_reveal_blocks`** - let (first_reveal_block, last_reveal_block) = SubtensorModule::get_reveal_blocks(netuid, 0); - - // **8. Assert Correct Calculation of Reveal Blocks** - // With tempo=5, netuid=1, reveal_period=1: - // commit_epoch = (0 + 2) / 6 = 0 - // reveal_epoch = 0 + 1 = 1 - // first_reveal_block = 1 * 6 - 2 = 4 - // last_reveal_block = 4 + 5 = 9 - assert_eq!(first_reveal_block, 4); - assert_eq!(last_reveal_block, 9); - - // **9. Attempt to Reveal Before `first_reveal_block` (Block 3)** - step_block(3); // Advance to block 3 - let result = SubtensorModule::reveal_weights( - RuntimeOrigin::signed(hotkey), - netuid, - uids.clone(), - weight_values.clone(), - salt.clone(), - version_key, - ); - assert_err!(result, Error::::RevealTooEarly); - - // **10. Advance to `first_reveal_block` (Block 4)** - step_block(1); // Advance to block 4 - let result = SubtensorModule::reveal_weights( - RuntimeOrigin::signed(hotkey), - netuid, - uids.clone(), - weight_values.clone(), - salt.clone(), - version_key, - ); - assert_ok!(result); - - // **11. Attempt to Reveal Again at Block 4 (Should Fail)** - let result = SubtensorModule::reveal_weights( - RuntimeOrigin::signed(hotkey), - netuid, - uids.clone(), - weight_values.clone(), - salt.clone(), - version_key, - ); - assert_err!(result, Error::::NoWeightsCommitFound); - - // **12. Advance to After `last_reveal_block` (Block 10)** - step_block(6); // Advance from block 4 to block 10 - - // **13. Attempt to Reveal at Block 10 (Should Fail)** - let result = SubtensorModule::reveal_weights( - RuntimeOrigin::signed(hotkey), - netuid, - uids.clone(), - weight_values.clone(), - salt.clone(), - version_key, - ); - assert_err!(result, Error::::NoWeightsCommitFound); - - // **14. Attempt to Reveal Outside of Any Reveal Window (No Commit)** - let result = SubtensorModule::reveal_weights( - RuntimeOrigin::signed(hotkey), - netuid, - uids.clone(), - weight_values.clone(), - salt.clone(), - version_key, - ); - assert_err!(result, Error::::NoWeightsCommitFound); - - // **15. Verify that All Commits Have Been Removed from Storage** - let commits = crate::WeightCommits::::get(NetUidStorageIndex::from(netuid), hotkey); - assert!( - commits.is_none(), - "Commits should be cleared after successful reveal" - ); - }) -} - // SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::weights::test_commit_weights_rate_limit --exact --show-output --nocapture #[test] fn test_commit_weights_rate_limit() { @@ -5946,8 +5793,13 @@ fn test_reveal_crv3_commits_removes_past_epoch_commits() { // --------------------------------------------------------------------- // Put dummy commits into the two epochs immediately *before* current. // --------------------------------------------------------------------- + // Establish a non-zero epoch counter and pin the scheduler so the reveal + // pass sees exactly this epoch (no look-ahead increment). + let cur_epoch: u64 = 10; + SubnetEpochIndex::::insert(netuid, cur_epoch); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); + PendingEpochAt::::insert(netuid, 0); let cur_block = SubtensorModule::get_current_block_as_u64(); - let cur_epoch = SubtensorModule::get_epoch_index(netuid, cur_block); let past_epoch = cur_epoch.saturating_sub(2); // definitely < reveal_epoch let reveal_epoch = cur_epoch.saturating_sub(1); // == cur_epoch - reveal_period @@ -6226,18 +6078,16 @@ fn test_reveal_crv3_commits_max_neurons() { }); } +// `get_first_block_of_epoch` is a legacy modulo helper — NOT used by live +// commit-reveal logic #[test] fn test_get_first_block_of_epoch_epoch_zero() { new_test_ext(1).execute_with(|| { let netuid: NetUid = NetUid::from(1); - let tempo: u16 = 10; - add_network(netuid, tempo, 0); - - let first_block = SubtensorModule::get_first_block_of_epoch(netuid, 0); - assert_eq!(first_block, 0); + add_network(netuid, 10, 0); - // Cross-check: epoch at block 0 should be 0 - assert_eq!(SubtensorModule::get_epoch_index(netuid, 0), 0); + // 0 * 11 - 2, saturating at 0. + assert_eq!(SubtensorModule::get_first_block_of_epoch(netuid, 0), 0); }); } @@ -6245,15 +6095,10 @@ fn test_get_first_block_of_epoch_epoch_zero() { fn test_get_first_block_of_epoch_small_epoch() { new_test_ext(1).execute_with(|| { let netuid: NetUid = NetUid::from(0); - let tempo: u16 = 1; - add_network(netuid, tempo, 0); - - let first_block = SubtensorModule::get_first_block_of_epoch(netuid, 1); - assert_eq!(first_block, 1); // 1 * 2 - 1 = 1 + add_network(netuid, 1, 0); - // Cross-check - assert_eq!(SubtensorModule::get_epoch_index(netuid, 1), 1); - assert_eq!(SubtensorModule::get_epoch_index(netuid, 0), 0); + // 1 * 2 - 1 = 1. + assert_eq!(SubtensorModule::get_first_block_of_epoch(netuid, 1), 1); }); } @@ -6261,15 +6106,10 @@ fn test_get_first_block_of_epoch_small_epoch() { fn test_get_first_block_of_epoch_with_offset() { new_test_ext(1).execute_with(|| { let netuid: NetUid = NetUid::from(1); - let tempo: u16 = 10; - add_network(netuid, tempo, 0); - - let first_block = SubtensorModule::get_first_block_of_epoch(netuid, 1); - assert_eq!(first_block, 9); // 1 * 11 - 2 = 9 + add_network(netuid, 10, 0); - // Cross-check - assert_eq!(SubtensorModule::get_epoch_index(netuid, 9), 1); - assert_eq!(SubtensorModule::get_epoch_index(netuid, 8), 0); + // 1 * 11 - 2 = 9. + assert_eq!(SubtensorModule::get_first_block_of_epoch(netuid, 1), 9); }); } @@ -6277,61 +6117,14 @@ fn test_get_first_block_of_epoch_with_offset() { fn test_get_first_block_of_epoch_large_epoch() { new_test_ext(1).execute_with(|| { let netuid: NetUid = NetUid::from(0); - let tempo: u16 = 100; - add_network(netuid, tempo, 0); + add_network(netuid, 100, 0); let epoch: u64 = 1000; - let first_block = SubtensorModule::get_first_block_of_epoch(netuid, epoch); - assert_eq!(first_block, epoch * 101 - 1); // No overflow for this size - - // Cross-check (simulate, as large block not runnable, but math holds) - assert_eq!(first_block + 1, epoch * 101); - }); -} - -#[test] -fn test_get_first_block_of_epoch_step_blocks_and_assert_with_until_next() { - new_test_ext(1).execute_with(|| { - let netuid: NetUid = NetUid::from(1); - let tempo: u16 = 10; - add_network(netuid, tempo, 0); - - let mut current_block: u64 = 0; - for expected_epoch in 0..10u64 { - let expected_first = SubtensorModule::get_first_block_of_epoch(netuid, expected_epoch); - - // Step blocks until we reach the start of this epoch - while current_block < expected_first { - run_to_block(current_block + 1); - current_block += 1; - } - - // Assert we are at the first block of the epoch - assert_eq!(current_block, expected_first); - assert_eq!( - SubtensorModule::get_epoch_index(netuid, current_block), - expected_epoch - ); - - // From here, blocks_until_next_epoch should point to the start of next epoch - let until_next = SubtensorModule::blocks_until_next_epoch(netuid, tempo, current_block); - let next_first = SubtensorModule::get_first_block_of_epoch(netuid, expected_epoch + 1); - assert_eq!(current_block + until_next + 1, next_first); // +1 since until is blocks to end, +1 to start next - - // Advance to near end of this epoch - let last_block = next_first.saturating_sub(1); - run_to_block(last_block); - current_block = System::block_number(); - assert_eq!( - SubtensorModule::get_epoch_index(netuid, current_block), - expected_epoch - ); - - // Until next from near end - let until_next_end = - SubtensorModule::blocks_until_next_epoch(netuid, tempo, current_block); - assert_eq!(current_block + until_next_end + 1, next_first); - } + // 1000 * 101 - 1. + assert_eq!( + SubtensorModule::get_first_block_of_epoch(netuid, epoch), + epoch * 101 - 1 + ); }); } @@ -6671,11 +6464,14 @@ fn test_reveal_crv3_commits_retry_on_missing_pulse() { .map(|(e, _)| e) .expect("commit stored"); - // first block of reveal epoch (commit_epoch + RP) - let first_reveal_epoch = stored_epoch + SubtensorModule::get_reveal_period(netuid); - let first_reveal_block = - SubtensorModule::get_first_block_of_epoch(netuid, first_reveal_epoch); - run_to_block_no_epoch(netuid, first_reveal_block); + // Place the subnet's epoch counter at the commit's reveal epoch + // (`commit_epoch + reveal_period`). The counter is the canonical epoch + // index; pin `LastEpochBlock`/`PendingEpochAt` so `should_run_epoch` stays + // false and the look-ahead does not skip past the reveal epoch. + let reveal_epoch = stored_epoch + SubtensorModule::get_reveal_period(netuid); + SubnetEpochIndex::::insert(netuid, reveal_epoch); + LastEpochBlock::::insert(netuid, SubtensorModule::get_current_block_as_u64()); + PendingEpochAt::::insert(netuid, 0); // run *one* block inside reveal epoch without pulse → commit should stay queued step_block(1); diff --git a/pallets/subtensor/src/utils/misc.rs b/pallets/subtensor/src/utils/misc.rs index a1c0309b24..23844cc363 100644 --- a/pallets/subtensor/src/utils/misc.rs +++ b/pallets/subtensor/src/utils/misc.rs @@ -54,13 +54,18 @@ impl Pallet { /// Returns true if the current block is within the terminal freeze window of the tempo for the /// given subnet. During this window, admin ops are prohibited to avoid interference with - /// validator weight submissions. + /// validator weight submissions. Engages immediately on a pending manual trigger (so the trigger + /// arms the freeze for the entire countdown to `PendingEpochAt`). pub fn is_in_admin_freeze_window(netuid: NetUid, current_block: u64) -> bool { let tempo = Self::get_tempo(netuid); if tempo == 0 { return false; } - let remaining = Self::blocks_until_next_epoch(netuid, tempo, current_block); + let pending = PendingEpochAt::::get(netuid); + if pending > 0 && pending > current_block { + return true; + } + let remaining = Self::blocks_until_next_auto_epoch(netuid, tempo, current_block); let window = AdminFreezeWindow::::get() as u64; remaining < window } @@ -102,10 +107,23 @@ impl Pallet { // ======================== // ==== Global Setters ==== // ======================== - pub fn set_tempo(netuid: NetUid, tempo: u16) { + /// Unchecked tempo write used by tests, precompiles, and internal helpers. + /// Does NOT reset `LastEpochBlock` — that is the responsibility of the owner-side + /// `set_tempo` extrinsic and `sudo_set_tempo` (root), both of which perform the cycle + /// reset explicitly. + pub fn set_tempo_unchecked(netuid: NetUid, tempo: u16) { Tempo::::insert(netuid, tempo); Self::deposit_event(Event::TempoSet(netuid, tempo)); } + + /// Sets `Tempo` and resets the state-based scheduler anchor `LastEpochBlock` + /// to the current block + pub fn apply_tempo_with_cycle_reset(netuid: NetUid, tempo: u16) { + Self::set_tempo_unchecked(netuid, tempo); + let now = Self::get_current_block_as_u64(); + LastEpochBlock::::insert(netuid, now); + } + pub fn set_last_adjustment_block(netuid: NetUid, last_adjustment_block: u64) { LastAdjustmentBlock::::insert(netuid, last_adjustment_block); } @@ -582,6 +600,27 @@ impl Pallet { Self::deposit_event(Event::ActivityCutoffSet(netuid, activity_cutoff)); } + /// Effective activity cutoff in blocks, derived from `ActivityCutoffFactorMilli` and `Tempo`. + /// `cutoff_blocks = (factor × tempo) / 1000`, clamped to ≥ 1. + pub fn get_activity_cutoff_blocks(netuid: NetUid) -> u64 { + let factor_milli = ActivityCutoffFactorMilli::::get(netuid) as u64; + let tempo = Self::get_tempo(netuid) as u64; + factor_milli + .saturating_mul(tempo) + .checked_div(1000) + .unwrap_or(0) + .max(1) + } + + pub fn get_activity_cutoff_factor_milli(netuid: NetUid) -> u32 { + ActivityCutoffFactorMilli::::get(netuid) + } + + pub fn set_activity_cutoff_factor_milli(netuid: NetUid, factor_milli: u32) { + ActivityCutoffFactorMilli::::insert(netuid, factor_milli); + Self::deposit_event(Event::ActivityCutoffFactorMilliSet(netuid, factor_milli)); + } + // Registration Toggle utils pub fn get_network_registration_allowed(netuid: NetUid) -> bool { NetworkRegistrationAllowed::::get(netuid) diff --git a/pallets/subtensor/src/utils/rate_limiting.rs b/pallets/subtensor/src/utils/rate_limiting.rs index e9559f2c6d..7b93d620ac 100644 --- a/pallets/subtensor/src/utils/rate_limiting.rs +++ b/pallets/subtensor/src/utils/rate_limiting.rs @@ -17,6 +17,7 @@ pub enum TransactionType { MechanismEmission, MaxUidsTrimming, AddStakeBurn, + TempoUpdate, } impl TransactionType { @@ -46,6 +47,7 @@ impl TransactionType { } Self::SetSNOwnerHotkey => DefaultSetSNOwnerHotkeyRateLimit::::get(), Self::AddStakeBurn => Tempo::::get(netuid) as u64, + Self::TempoUpdate => MIN_TEMPO as u64, _ => self.rate_limit::(), } @@ -144,6 +146,7 @@ impl From for u16 { TransactionType::MechanismEmission => 8, TransactionType::MaxUidsTrimming => 9, TransactionType::AddStakeBurn => 10, + TransactionType::TempoUpdate => 11, } } } @@ -162,6 +165,7 @@ impl From for TransactionType { 8 => TransactionType::MechanismEmission, 9 => TransactionType::MaxUidsTrimming, 10 => TransactionType::AddStakeBurn, + 11 => TransactionType::TempoUpdate, _ => TransactionType::Unknown, } } @@ -206,6 +210,8 @@ pub enum Hyperparameter { BurnIncreaseMult = 27, SubnetEmissionEnabled = 28, MinChildkeyTake = 29, + ActivityCutoffFactorMilli = 30, + TriggerEpoch = 31, } impl Pallet { diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 03fee56829..0fab492de5 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -93,6 +93,9 @@ pub trait WeightInfo { fn lock_stake() -> Weight; fn unlock_stake() -> Weight; fn move_lock() -> Weight; + fn set_tempo() -> Weight; + fn set_activity_cutoff_factor() -> Weight; + fn trigger_epoch() -> Weight; } /// Weights for `pallet_subtensor` using the Substrate node and recommended hardware. @@ -2395,6 +2398,77 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(8_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::CommitRevealWeightsEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::CommitRevealWeightsEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Tempo` (r:1 w:1) + /// Proof: `SubtensorModule::Tempo` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::PendingEpochAt` (r:1 w:0) + /// Proof: `SubtensorModule::PendingEpochAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastEpochBlock` (r:1 w:1) + /// Proof: `SubtensorModule::LastEpochBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AdminFreezeWindow` (r:1 w:0) + /// Proof: `SubtensorModule::AdminFreezeWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TransactionKeyLastBlock` (r:1 w:1) + /// Proof: `SubtensorModule::TransactionKeyLastBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn set_tempo() -> Weight { + // Proof Size summary in bytes: + // Measured: `1015` + // Estimated: `4480` + // Minimum execution time: 34_000_000 picoseconds. + Weight::from_parts(35_000_000, 4480) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Tempo` (r:1 w:0) + /// Proof: `SubtensorModule::Tempo` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::PendingEpochAt` (r:1 w:0) + /// Proof: `SubtensorModule::PendingEpochAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastEpochBlock` (r:1 w:0) + /// Proof: `SubtensorModule::LastEpochBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AdminFreezeWindow` (r:1 w:0) + /// Proof: `SubtensorModule::AdminFreezeWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnerHyperparamRateLimit` (r:1 w:0) + /// Proof: `SubtensorModule::OwnerHyperparamRateLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:1 w:1) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::ActivityCutoffFactorMilli` (r:0 w:1) + /// Proof: `SubtensorModule::ActivityCutoffFactorMilli` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn set_activity_cutoff_factor() -> Weight { + // Proof Size summary in bytes: + // Measured: `889` + // Estimated: `4354` + // Minimum execution time: 29_000_000 picoseconds. + Weight::from_parts(31_000_000, 4354) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::CommitRevealWeightsEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::CommitRevealWeightsEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::PendingEpochAt` (r:1 w:1) + /// Proof: `SubtensorModule::PendingEpochAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnerHyperparamRateLimit` (r:1 w:0) + /// Proof: `SubtensorModule::OwnerHyperparamRateLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Tempo` (r:1 w:0) + /// Proof: `SubtensorModule::Tempo` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:1 w:1) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AdminFreezeWindow` (r:1 w:0) + /// Proof: `SubtensorModule::AdminFreezeWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn trigger_epoch() -> Weight { + // Proof Size summary in bytes: + // Measured: `853` + // Estimated: `4318` + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(28_000_000, 4318) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } } // For backwards compatibility and tests. @@ -4696,4 +4770,75 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(8_u64)) .saturating_add(RocksDbWeight::get().writes(4_u64)) } + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::CommitRevealWeightsEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::CommitRevealWeightsEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Tempo` (r:1 w:1) + /// Proof: `SubtensorModule::Tempo` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::PendingEpochAt` (r:1 w:0) + /// Proof: `SubtensorModule::PendingEpochAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastEpochBlock` (r:1 w:1) + /// Proof: `SubtensorModule::LastEpochBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AdminFreezeWindow` (r:1 w:0) + /// Proof: `SubtensorModule::AdminFreezeWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TransactionKeyLastBlock` (r:1 w:1) + /// Proof: `SubtensorModule::TransactionKeyLastBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn set_tempo() -> Weight { + // Proof Size summary in bytes: + // Measured: `1015` + // Estimated: `4480` + // Minimum execution time: 34_000_000 picoseconds. + Weight::from_parts(35_000_000, 4480) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Tempo` (r:1 w:0) + /// Proof: `SubtensorModule::Tempo` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::PendingEpochAt` (r:1 w:0) + /// Proof: `SubtensorModule::PendingEpochAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastEpochBlock` (r:1 w:0) + /// Proof: `SubtensorModule::LastEpochBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AdminFreezeWindow` (r:1 w:0) + /// Proof: `SubtensorModule::AdminFreezeWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnerHyperparamRateLimit` (r:1 w:0) + /// Proof: `SubtensorModule::OwnerHyperparamRateLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:1 w:1) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::ActivityCutoffFactorMilli` (r:0 w:1) + /// Proof: `SubtensorModule::ActivityCutoffFactorMilli` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn set_activity_cutoff_factor() -> Weight { + // Proof Size summary in bytes: + // Measured: `889` + // Estimated: `4354` + // Minimum execution time: 29_000_000 picoseconds. + Weight::from_parts(31_000_000, 4354) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::CommitRevealWeightsEnabled` (r:1 w:0) + /// Proof: `SubtensorModule::CommitRevealWeightsEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::PendingEpochAt` (r:1 w:1) + /// Proof: `SubtensorModule::PendingEpochAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnerHyperparamRateLimit` (r:1 w:0) + /// Proof: `SubtensorModule::OwnerHyperparamRateLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::Tempo` (r:1 w:0) + /// Proof: `SubtensorModule::Tempo` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:1 w:1) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AdminFreezeWindow` (r:1 w:0) + /// Proof: `SubtensorModule::AdminFreezeWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn trigger_epoch() -> Weight { + // Proof Size summary in bytes: + // Measured: `853` + // Estimated: `4318` + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(28_000_000, 4318) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } } diff --git a/precompiles/src/neuron.rs b/precompiles/src/neuron.rs index 1397baf272..f94940b3d6 100644 --- a/precompiles/src/neuron.rs +++ b/precompiles/src/neuron.rs @@ -303,7 +303,7 @@ mod tests { pallet_subtensor::Pallet::::set_burn(netuid, REGISTRATION_BURN.into()); pallet_subtensor::Pallet::::set_max_allowed_uids(netuid, 4096); pallet_subtensor::Pallet::::set_weights_set_rate_limit(netuid, 0); - pallet_subtensor::Pallet::::set_tempo(netuid, TEMPO); + pallet_subtensor::Pallet::::set_tempo_unchecked(netuid, TEMPO); pallet_subtensor::Pallet::::set_commit_reveal_weights_enabled(netuid, true); pallet_subtensor::Pallet::::set_reveal_period(netuid, REVEAL_PERIOD) .expect("reveal period setup should succeed"); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index bd0c55f9d4..40d51a6d12 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -2530,6 +2530,10 @@ impl_runtime_apis! { fn get_subnet_account_id(netuid: NetUid) -> Option { SubtensorModule::get_subnet_account_id(netuid) } + + fn get_next_epoch_start_block(netuid: NetUid) -> Option { + SubtensorModule::get_next_epoch_start_block(netuid) + } } impl subtensor_custom_rpc_runtime_api::StakeInfoRuntimeApi for Runtime { diff --git a/ts-tests/scripts/build-spec.sh b/ts-tests/scripts/build-spec.sh index 8ef4e40b96..9356b5fc5c 100755 --- a/ts-tests/scripts/build-spec.sh +++ b/ts-tests/scripts/build-spec.sh @@ -4,6 +4,8 @@ set -e cd $(dirname $0)/.. +# Clean vitest cache, so the tests order are the same on CI and locally +rm -rf node_modules/.vite/vitest mkdir -p specs ../target/release/node-subtensor build-spec --disable-default-bootnode --raw --chain local > specs/chain-spec.json \ No newline at end of file diff --git a/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts b/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts index 0124bae671..3578061b6f 100644 --- a/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts +++ b/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts @@ -68,7 +68,7 @@ async function setupTwoSubnetsWithClaimable( log(`Created netuid2: ${netuid2}`); for (const netuid of [netuid1, netuid2]) { - await sudoSetTempo(api, netuid, 1); + await sudoSetTempo(api, netuid, 5); await sudoSetEmaPriceHalvingPeriod(api, netuid, 1); await sudoSetRootClaimThreshold(api, netuid, 0n); } @@ -91,14 +91,15 @@ async function setupTwoSubnetsWithClaimable( await addStake(api, owner1Coldkey, owner1Hotkey.address, netuid1, tao(50)); await addStake(api, owner2Coldkey, owner2Hotkey.address, netuid2, tao(50)); - log("Waiting 30 blocks for RootClaimable to accumulate on both subnets..."); - await waitForBlocks(api, 30); + const waitBlocks = 90; + log(`Waiting ${waitBlocks} blocks for RootClaimable to accumulate on both subnets...`); + await waitForBlocks(api, waitBlocks); return { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 }; } describeSuite({ - id: "0203_swap_hotkey_root_claimable", + id: "0204_claim-root_hotkey_swap", title: "▶ swap_hotkey RootClaimable per-subnet transfer", foundationMethods: "zombie", testCases: ({ it, context, log }) => {