Skip to content

Subnets become permanently non-functional with concentrated liquidity – add EMA reset mechanism #2228

@EchoBT

Description

@EchoBT

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 I64F64 representing 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

  1. Proportional to damage: More negative EMA = higher cost
  2. Represents real effort: The cost equals what would be needed to fix it naturally in one block
  3. Prevents exploitation: Cost is always much higher than potential gain from reset
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions