-
Notifications
You must be signed in to change notification settings - Fork 257
Description
Is your feature request related to a problem? Please describe.
The patch PR #2214 introduced to protect against EMA manipulation has an unintended side effect: when concentrated liquidity is enabled on a subnet, the EMA (Exponential Moving Average) of TAO flow can become excessively negative due to asymmetric flow tracking.
Affected subnets would receive 0% emissions and could be stuck in this state for an extremely long period, as the EMA takes months to recover naturally. We need a mechanism for subnet owners to reset their EMA by burning TAO.
Problem
When concentrated liquidity is enabled:
- The EMA can become permanently negative due to asymmetric flow tracking
- Subnets with negative EMA receive reduced or zero emissions
- Natural recovery takes months (EMA half-life ≈ 1 month)
- No way to recover except waiting for natural EMA decay
Describe the solution you'd like
Allow subnet owners to reset a negative EMA by burning TAO proportional to the recovery effort:
pub fn reset_subnet_ema(origin, netuid) -> DispatchResult {
// Only subnet owner can reset
let coldkey = ensure_signed(origin)?;
ensure!(Self::is_subnet_owner(&coldkey, netuid), Error::NotSubnetOwner);
// Only negative EMA can be reset
let (_, ema) = SubnetEmaTaoFlow::<T>::get(netuid)
.ok_or(Error::EmaNotInitialized)?;
ensure!(ema < 0, Error::EmaNotNegative);
// Cost = |EMA| × (1 / smoothing_factor)
// This represents the TAO needed to offset the negative EMA in one block
let smoothing_factor = FlowEmaSmoothingFactor::<T>::get();
let alpha = smoothing_factor as f64 / i64::MAX as f64;
let ema_abs = ema.abs().saturating_to_num::<u64>();
let base_cost = (ema_abs as f64) * (1.0 / alpha);
// Cap to prevent astronomical costs
let max_cost = MaxResetEmaCost::<T>::get(); // Default: 100 TAO
let final_cost = (base_cost as u64).min(max_cost);
// Burn the TAO
T::Currency::withdraw(&coldkey, final_cost.into())?;
// Reset EMA to zero
SubnetEmaTaoFlow::<T>::remove(netuid);
SubnetTaoFlow::<T>::remove(netuid);
Ok(())
}Cost Calculation Explanation
The cost formula |EMA| × (1/α) represents the TAO (in RAO) that would need to be staked in a single block to offset the negative EMA, where:
- EMA is stored as
I64F64representing RAO (not TAO) - α (alpha) =
FlowEmaSmoothingFactor / i64::MAX≈ 0.000003 (1-month half-life) - 1/α ≈ 333,333 (the multiplier needed to offset EMA in one block)
Example:
- EMA = -310,937 RAO (≈ -0.0003 TAO)
- Base cost in RAO = 310,937 × 333,333 ≈ 103,645,666,221 RAO ≈ 103.6 TAO
- With cap of 100 TAO: Final cost = min(103.6, 100) = 100 TAO
Why This Cost is Fair
- Proportional to damage: More negative EMA = higher cost
- Represents real effort: The cost equals what would be needed to fix it naturally in one block
- Prevents exploitation: Cost is always much higher than potential gain from reset
- Cap prevents griefing: Maximum cost prevents astronomical fees from attacks
Security Analysis
| Attack Vector | Mitigation |
|---|---|
| Reset for profit | Cost >> potential gain from emissions |
| Reduce cost artificially | Counter-productive (would need to stake anyway) |
| Grief competitor's EMA | Attacker must stake first (contributes inflow) |
| Force astronomical reset cost | Cap on maximum cost prevents this |
Implementation Details
1. Add Storage Item for Max Reset Cost
In pallets/subtensor/src/lib.rs:
#[pallet::type_value]
pub fn DefaultMaxResetEmaCost<T: Config>() -> TaoCurrency {
// Default: 100 TAO cap
TaoCurrency::from(100_000_000_000u64) // 100 TAO in RAO
}
#[pallet::storage]
/// Maximum cost allowed for resetting subnet EMA (prevents griefing)
pub type MaxResetEmaCost<T: Config> =
StorageValue<_, TaoCurrency, ValueQuery, DefaultMaxResetEmaCost<T>>;2. Add Error Variants
In pallets/subtensor/src/lib.rs:
#[pallet::error]
pub enum Error<T> {
// ... existing errors ...
/// EMA has not been initialized for this subnet
EmaNotInitialized,
/// EMA is not negative, cannot reset
EmaNotNegative,
}3. Implement the Reset Function
In pallets/subtensor/src/coinbase/subnet_emissions.rs:
impl<T: Config> Pallet<T> {
/// Reset subnet EMA to zero by burning TAO proportional to recovery effort
///
/// Only the subnet owner can call this function.
/// Only works if EMA is negative.
/// Cost = |EMA| × (1 / smoothing_factor), capped at MaxResetEmaCost
#[pallet::call_index(X)] // Replace X with next available call index
#[pallet::weight(T::WeightInfo::reset_subnet_ema())]
pub fn reset_subnet_ema(
origin: OriginFor<T>,
netuid: NetUid,
) -> DispatchResult {
// Ensure subnet exists
ensure!(
Self::if_subnet_exist(netuid),
Error::<T>::SubnetNotExists
);
// Ensure caller is subnet owner (uses existing helper function)
let coldkey = Self::ensure_subnet_owner(origin, netuid)
.map_err(|_| Error::<T>::NotSubnetOwner)?;
// Get current EMA
let (_, ema_flow) = SubnetEmaTaoFlow::<T>::get(netuid)
.ok_or(Error::<T>::EmaNotInitialized)?;
// Ensure EMA is negative
ensure!(
ema_flow < I64F64::saturating_from_num(0),
Error::<T>::EmaNotNegative
);
// Calculate cost: |EMA| × (1 / alpha)
// where alpha = FlowEmaSmoothingFactor / i64::MAX
// Note: EMA is stored in RAO (I64F64), not TAO
let smoothing_factor = FlowEmaSmoothingFactor::<T>::get();
// Convert I64F64 to i64 (absolute value in RAO)
let ema_abs_i64 = ema_flow.abs().saturating_to_num::<i64>();
let ema_abs_rao = ema_abs_i64 as u64;
// Calculate alpha (smoothing factor normalized)
// smoothing_factor is stored as u64, representing alpha * i64::MAX
let alpha = smoothing_factor as f64 / i64::MAX as f64;
// Calculate base cost in RAO: |EMA_RAO| × (1 / alpha)
// This represents RAO needed to offset EMA in one block
let base_cost_rao_f64 = (ema_abs_rao as f64) * (1.0 / alpha);
// Convert to u64 (saturating) and then to TaoCurrency
let base_cost_rao_u64 = base_cost_rao_f64.min(u64::MAX as f64) as u64;
let base_cost = TaoCurrency::from(base_cost_rao_u64);
// Apply cap to prevent astronomical costs (default: 100 TAO)
let max_cost = MaxResetEmaCost::<T>::get();
let final_cost = base_cost.min(max_cost);
// Ensure cost is not zero (safety check)
ensure!(!final_cost.is_zero(), Error::<T>::AmountTooLow);
// Withdraw TAO from owner (this burns it)
T::BalanceOps::decrease_balance(&coldkey, final_cost.into())?;
// Reset EMA to zero by removing storage
SubnetEmaTaoFlow::<T>::remove(netuid);
SubnetTaoFlow::<T>::remove(netuid);
// Emit event
Self::deposit_event(Event::SubnetEmaReset {
netuid,
coldkey,
cost: final_cost,
previous_ema: ema_flow,
});
log::info!(
"Subnet {} EMA reset by owner {:?}. Cost: {} TAO, Previous EMA: {:?}",
netuid,
coldkey,
final_cost,
ema_flow
);
Ok(())
}
}4. Add Event Variant
In pallets/subtensor/src/lib.rs:
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
// ... existing events ...
/// Subnet EMA was reset by owner
SubnetEmaReset {
netuid: NetUid,
coldkey: T::AccountId,
cost: TaoCurrency,
previous_ema: I64F64,
},
}5. Update Weight Calculation
In pallets/subtensor/src/weights.rs or weight file:
impl<T: frame_system::Config> WeightInfo for SubstrateWeight<T> {
// ... existing weights ...
fn reset_subnet_ema() -> Weight {
// Storage reads: SubnetEmaTaoFlow (1), MaxResetEmaCost (1), SubnetOwner (1)
// Storage writes: SubnetEmaTaoFlow (1), SubnetTaoFlow (1), Balance (1)
// Event: 1
Weight::from_ref_time(50_000_000)
.saturating_add(T::DbWeight::get().reads(3))
.saturating_add(T::DbWeight::get().writes(3))
}
}Describe alternatives you've considered
I thought about the fact that the EMA only needs to be above 0, but that would open the door to subnets generating emissions without doing anything.
Additional context
This is an example implementation, a starting idea. It could help unlock the situation of SN100, which won’t be able to receive emissions for a few months, possibly even years.