From 5acc0cf794b817e556341a2e954fad88231cd868 Mon Sep 17 00:00:00 2001 From: calm329 Date: Fri, 19 Dec 2025 14:35:16 -0800 Subject: [PATCH] feat: implement subnet EMA reset mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements a mechanism for subnet owners to reset negative EMA values that prevent their subnet from receiving emissions. Changes: - Add MaxEmaResetCost storage item (default: 100 TAO) - Add reset_subnet_ema extrinsic (call_index: 125) - Add get_ema_reset_cost helper function - Add EmaNotInitialized, SubnetEmaNotNegative, NotEnoughBalanceToPayEmaResetCost errors - Add SubnetEmaReset event with netuid, who, cost, previous_ema - Add comprehensive tests for the new functionality The reset cost is calculated as |EMA| × (1/α), capped at MaxEmaResetCost. The burned TAO is recycled to reduce total issuance. Closes #2228 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/coinbase/subnet_emissions.rs | 102 +++++++ pallets/subtensor/src/lib.rs | 10 + pallets/subtensor/src/macros/dispatches.rs | 30 ++ pallets/subtensor/src/macros/errors.rs | 6 + pallets/subtensor/src/macros/events.rs | 12 + .../subtensor/src/tests/subnet_emissions.rs | 279 ++++++++++++++++++ 6 files changed, 439 insertions(+) diff --git a/pallets/subtensor/src/coinbase/subnet_emissions.rs b/pallets/subtensor/src/coinbase/subnet_emissions.rs index 477a678864..a568c1d626 100644 --- a/pallets/subtensor/src/coinbase/subnet_emissions.rs +++ b/pallets/subtensor/src/coinbase/subnet_emissions.rs @@ -1,8 +1,10 @@ use super::*; use alloc::collections::BTreeMap; +use frame_support::dispatch::DispatchResult; use safe_math::FixedExt; use substrate_fixed::transcendental::{exp, ln}; use substrate_fixed::types::{I32F32, I64F64, U64F64, U96F32}; +use subtensor_runtime_common::TaoCurrency; impl Pallet { pub fn get_subnets_to_emit_to(subnets: &[NetUid]) -> Vec { @@ -246,4 +248,104 @@ impl Pallet { }) .collect::>() } + + /// Calculates the cost to reset a subnet's EMA to zero. + /// + /// The cost formula is: |EMA| × (1/α), capped at MaxEmaResetCost. + /// Where α is the FlowEmaSmoothingFactor normalized by i64::MAX. + /// + /// Returns the cost in RAO (TaoCurrency), or None if EMA is not negative. + pub fn get_ema_reset_cost(netuid: NetUid) -> Option { + // Get the current EMA value + let (_, ema) = SubnetEmaTaoFlow::::get(netuid)?; + + // Only allow reset if EMA is negative + if ema >= I64F64::saturating_from_num(0) { + return None; + } + + // Get the absolute value of EMA + let abs_ema = ema.saturating_abs(); + + // Get the smoothing factor (alpha) and normalize it + // FlowEmaSmoothingFactor is stored as u64 normalized by i64::MAX (2^63) + let alpha_normalized = FlowEmaSmoothingFactor::::get(); + + // Cost = |EMA| × (1/α) = |EMA| × (i64::MAX / alpha_normalized) + // This can overflow, so we need to be careful with the calculation + let i64_max = I64F64::saturating_from_num(i64::MAX); + let alpha = I64F64::saturating_from_num(alpha_normalized).safe_div(i64_max); + + // Calculate cost = |EMA| / alpha + let cost_raw = abs_ema.safe_div(alpha); + + // Convert to u64 (RAO) + let cost_rao = cost_raw + .checked_to_num::() + .unwrap_or(u64::MAX); + + // Cap at MaxEmaResetCost + let max_cost = MaxEmaResetCost::::get(); + let cost = TaoCurrency::from(cost_rao).min(max_cost); + + Some(cost) + } + + /// Resets the subnet EMA to zero by burning TAO. + /// + /// This function allows subnet owners to reset negative EMA values that + /// prevent their subnet from receiving emissions. + pub fn do_reset_subnet_ema(origin: T::RuntimeOrigin, netuid: NetUid) -> DispatchResult { + // Ensure the subnet exists + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + + // Ensure the caller is the subnet owner + let who = Self::ensure_subnet_owner(origin, netuid)?; + + // Get the current EMA value - check if initialized + let (_, previous_ema) = SubnetEmaTaoFlow::::get(netuid) + .ok_or(Error::::EmaNotInitialized)?; + + // Ensure EMA is negative + ensure!( + previous_ema < I64F64::saturating_from_num(0), + Error::::SubnetEmaNotNegative + ); + + // Get the reset cost + let cost = Self::get_ema_reset_cost(netuid) + .ok_or(Error::::SubnetEmaNotNegative)?; + + // Ensure the owner has enough balance + ensure!( + Self::can_remove_balance_from_coldkey_account(&who, cost.into()), + Error::::NotEnoughBalanceToPayEmaResetCost + ); + + // Remove the balance from the owner's account + let actual_cost = Self::remove_balance_from_coldkey_account(&who, cost.into())?; + + // Burn the TAO (reduce total issuance) + Self::recycle_tao(actual_cost); + + // Reset the EMA to zero + let current_block = Self::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, I64F64::saturating_from_num(0))); + + // Also reset the accumulated flow + Self::reset_tao_outflow(netuid); + + // Convert previous_ema to i128 for the event (I64F64 is 128 bits total) + let previous_ema_bits = previous_ema.to_bits(); + + // Emit the event + Self::deposit_event(Event::SubnetEmaReset { + netuid, + who, + cost: actual_cost, + previous_ema: previous_ema_bits, + }); + + Ok(()) + } } diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index ef2d44e68b..2d3a9e0fe4 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1493,6 +1493,16 @@ pub mod pallet { pub type FlowEmaSmoothingFactor = StorageValue<_, u64, ValueQuery, DefaultFlowEmaSmoothingFactor>; + #[pallet::type_value] + /// Default maximum cost for resetting subnet EMA (100 TAO in RAO). + pub fn DefaultMaxEmaResetCost() -> TaoCurrency { + TaoCurrency::from(100_000_000_000u64) // 100 TAO + } + #[pallet::storage] + /// --- ITEM --> Maximum cost for resetting subnet EMA + pub type MaxEmaResetCost = + StorageValue<_, TaoCurrency, ValueQuery, DefaultMaxEmaResetCost>; + /// ============================ /// ==== Global Parameters ===== /// ============================ diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 8c0b2210ec..71a1e0b00d 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2432,5 +2432,35 @@ mod dispatches { Ok(()) } + + /// --- Resets the subnet EMA to zero by burning TAO. + /// + /// This extrinsic allows subnet owners to reset negative EMA values that + /// prevent their subnet from receiving emissions. The cost is proportional + /// to |EMA| × (1/α), capped at MaxEmaResetCost. + /// + /// # Arguments + /// * `origin` - The origin of the call, must be the subnet owner. + /// * `netuid` - The network identifier of the subnet to reset. + /// + /// # Errors + /// * `SubnetNotExists` - The subnet does not exist. + /// * `EmaNotInitialized` - The subnet EMA has not been initialized. + /// * `SubnetEmaNotNegative` - The subnet EMA is not negative, reset not needed. + /// * `NotEnoughBalanceToPayEmaResetCost` - Insufficient balance to pay reset cost. + /// + /// # Events + /// Emits a `SubnetEmaReset` event on success. + #[pallet::call_index(125)] + #[pallet::weight(( + Weight::from_parts(35_000_000, 0) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)), + DispatchClass::Normal, + Pays::Yes + ))] + pub fn reset_subnet_ema(origin: OriginFor, netuid: NetUid) -> DispatchResult { + Self::do_reset_subnet_ema(origin, netuid) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 6c3d7a35df..1defbc2a2d 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -268,5 +268,11 @@ mod errors { InvalidSubnetNumber, /// Unintended precision loss when unstaking alpha PrecisionLoss, + /// EMA has not been initialized for this subnet. + EmaNotInitialized, + /// Subnet EMA is not negative, reset not needed. + SubnetEmaNotNegative, + /// Not enough balance to pay the EMA reset cost. + NotEnoughBalanceToPayEmaResetCost, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index a06e035d86..2b7f0cdadf 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -479,5 +479,17 @@ mod events { /// The amount of alpha distributed alpha: AlphaCurrency, }, + + /// Subnet EMA has been reset by the owner. + SubnetEmaReset { + /// The subnet ID + netuid: NetUid, + /// The account that executed the reset + who: T::AccountId, + /// The cost paid for the reset in TAO + cost: TaoCurrency, + /// The previous EMA value before reset (as i128 bits representation of I64F64) + previous_ema: i128, + }, } } diff --git a/pallets/subtensor/src/tests/subnet_emissions.rs b/pallets/subtensor/src/tests/subnet_emissions.rs index 311a930647..db29a91d17 100644 --- a/pallets/subtensor/src/tests/subnet_emissions.rs +++ b/pallets/subtensor/src/tests/subnet_emissions.rs @@ -3,7 +3,9 @@ use super::mock::*; use crate::*; use alloc::collections::BTreeMap; use approx::assert_abs_diff_eq; +use frame_support::{assert_noop, assert_ok}; use sp_core::U256; +use sp_runtime::DispatchError; use substrate_fixed::types::{I64F64, I96F32, U64F64, U96F32}; use subtensor_runtime_common::NetUid; @@ -488,3 +490,280 @@ fn seed_price_and_flow(n1: NetUid, n2: NetUid, price1: f64, price2: f64, flow1: // assert_abs_diff_eq!(s1 + s2 + s3, 1.0, epsilon = 1e-9); // }); // } + +// ========================== +// EMA Reset Tests +// ========================== + +#[test] +fn test_reset_subnet_ema_success() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Give the owner some balance + let initial_balance = 1_000_000_000_000u64; // 1000 TAO + SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, initial_balance); + + // Set a negative EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(-1_000_000_000.0))); // -1 TAO worth + + // Verify EMA is negative before reset + let (_, ema_before) = SubnetEmaTaoFlow::::get(netuid).unwrap(); + assert!(ema_before < i64f64(0.0)); + + // Reset the EMA + assert_ok!(SubtensorModule::reset_subnet_ema( + RuntimeOrigin::signed(owner_coldkey), + netuid + )); + + // Verify EMA is now zero + let (_, ema_after) = SubnetEmaTaoFlow::::get(netuid).unwrap(); + assert_eq!(ema_after, i64f64(0.0)); + + // Verify balance was reduced (some TAO was burned) + let balance_after = SubtensorModule::get_coldkey_balance(&owner_coldkey); + assert!(balance_after < initial_balance); + }); +} + +#[test] +fn test_reset_subnet_ema_fails_for_non_owner() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + let not_owner = U256::from(999); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Give the non-owner some balance + SubtensorModule::add_balance_to_coldkey_account(¬_owner, 1_000_000_000_000u64); + + // Set a negative EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(-1_000_000_000.0))); + + // Attempt reset as non-owner should fail + assert_noop!( + SubtensorModule::reset_subnet_ema(RuntimeOrigin::signed(not_owner), netuid), + DispatchError::BadOrigin + ); + }); +} + +#[test] +fn test_reset_subnet_ema_fails_for_positive_ema() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Give the owner some balance + SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, 1_000_000_000_000u64); + + // Set a positive EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(1_000_000_000.0))); + + // Attempt reset should fail because EMA is not negative + assert_noop!( + SubtensorModule::reset_subnet_ema(RuntimeOrigin::signed(owner_coldkey), netuid), + Error::::SubnetEmaNotNegative + ); + }); +} + +#[test] +fn test_reset_subnet_ema_fails_for_zero_ema() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Give the owner some balance + SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, 1_000_000_000_000u64); + + // Set a zero EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(0.0))); + + // Attempt reset should fail because EMA is not negative + assert_noop!( + SubtensorModule::reset_subnet_ema(RuntimeOrigin::signed(owner_coldkey), netuid), + Error::::SubnetEmaNotNegative + ); + }); +} + +#[test] +fn test_reset_subnet_ema_fails_for_nonexistent_subnet() { + new_test_ext(1).execute_with(|| { + let not_owner = U256::from(999); + let nonexistent_netuid = NetUid::from(99); + + // Give some balance + SubtensorModule::add_balance_to_coldkey_account(¬_owner, 1_000_000_000_000u64); + + // Attempt reset on non-existent subnet + assert_noop!( + SubtensorModule::reset_subnet_ema(RuntimeOrigin::signed(not_owner), nonexistent_netuid), + Error::::SubnetNotExists + ); + }); +} + +#[test] +fn test_reset_subnet_ema_fails_for_uninitialized_ema() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Give the owner some balance + SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, 1_000_000_000_000u64); + + // Do NOT set any EMA flow - leave it uninitialized + // SubnetEmaTaoFlow should be None for this netuid + + // Attempt reset should fail because EMA is not initialized + assert_noop!( + SubtensorModule::reset_subnet_ema(RuntimeOrigin::signed(owner_coldkey), netuid), + Error::::EmaNotInitialized + ); + }); +} + +#[test] +fn test_reset_subnet_ema_fails_for_insufficient_balance() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Don't give the owner any balance (or very little) + SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, 100u64); + + // Set a negative EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(-1_000_000_000.0))); + + // Attempt reset should fail because not enough balance + assert_noop!( + SubtensorModule::reset_subnet_ema(RuntimeOrigin::signed(owner_coldkey), netuid), + Error::::NotEnoughBalanceToPayEmaResetCost + ); + }); +} + +#[test] +fn test_get_ema_reset_cost_returns_none_for_positive_ema() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Set a positive EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(1_000_000_000.0))); + + // get_ema_reset_cost should return None + assert!(SubtensorModule::get_ema_reset_cost(netuid).is_none()); + }); +} + +#[test] +fn test_get_ema_reset_cost_returns_some_for_negative_ema() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Set a negative EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(-1_000_000_000.0))); + + // get_ema_reset_cost should return Some + let cost = SubtensorModule::get_ema_reset_cost(netuid); + assert!(cost.is_some()); + assert!(cost.unwrap() > subtensor_runtime_common::TaoCurrency::ZERO); + }); +} + +#[test] +fn test_reset_subnet_ema_cost_capped_at_max() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Set an extremely negative EMA flow (should be capped) + let current_block = SubtensorModule::get_current_block_as_u64(); + SubnetEmaTaoFlow::::insert(netuid, (current_block, i64f64(-1_000_000_000_000_000.0))); + + // get_ema_reset_cost should be capped at MaxEmaResetCost + let cost = SubtensorModule::get_ema_reset_cost(netuid); + assert!(cost.is_some()); + let max_cost = MaxEmaResetCost::::get(); + assert!(cost.unwrap() <= max_cost); + }); +} + +#[test] +fn test_reset_subnet_ema_emits_event() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // Create subnet + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Give the owner enough balance + let initial_balance = 1_000_000_000_000u64; // 1000 TAO + SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, initial_balance); + + // Set a negative EMA flow + let current_block = SubtensorModule::get_current_block_as_u64(); + let negative_ema = i64f64(-1_000_000_000.0); + SubnetEmaTaoFlow::::insert(netuid, (current_block, negative_ema)); + + // Get the expected cost before reset + let expected_cost = SubtensorModule::get_ema_reset_cost(netuid).unwrap(); + + // Reset the EMA + assert_ok!(SubtensorModule::reset_subnet_ema( + RuntimeOrigin::signed(owner_coldkey), + netuid + )); + + // Check that the event was emitted with correct values + System::assert_has_event( + Event::SubnetEmaReset { + netuid, + who: owner_coldkey, + cost: expected_cost, + previous_ema: negative_ema.to_bits(), + } + .into(), + ); + }); +}