Skip to content
1 change: 1 addition & 0 deletions pallets/subtensor/src/coinbase/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ impl<T: Config> Pallet<T> {
PendingServerEmission::<T>::remove(netuid);
PendingRootAlphaDivs::<T>::remove(netuid);
PendingOwnerCut::<T>::remove(netuid);
MinerBurned::<T>::remove(netuid);
BlocksSinceLastStep::<T>::remove(netuid);
LastMechansimStepBlock::<T>::remove(netuid);
LastAdjustmentBlock::<T>::remove(netuid);
Expand Down
17 changes: 17 additions & 0 deletions pallets/subtensor/src/coinbase/run_coinbase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,14 +669,24 @@ impl<T: Config> Pallet<T> {
let subnet_owner_coldkey = SubnetOwner::<T>::get(netuid);
let owner_hotkeys = Self::get_owner_hotkeys(netuid, &subnet_owner_coldkey);
log::debug!("incentives: owner hotkeys: {owner_hotkeys:?}");
// Track total miner emission vs the portion withheld from miners this tempo
// (directed to an owner/immune hotkey) to record the withheld proportion.
let mut total_incentive: AlphaBalance = AlphaBalance::ZERO;
let mut withheld_incentive: AlphaBalance = AlphaBalance::ZERO;
for (hotkey, incentive) in incentives {
log::debug!("incentives: hotkey: {incentive:?}");
total_incentive = total_incentive.saturating_add(incentive);

// Skip/burn miner-emission for immune keys
if owner_hotkeys.contains(&hotkey) {
log::debug!(
"incentives: hotkey: {hotkey:?} is SN owner hotkey or associated hotkey, skipping {incentive:?}"
);
// Miner emission directed to an owner (immune) hotkey is withheld from
// miners whether it is recycled or burned. Count both toward the withheld
// proportion so the emission penalty cannot be dodged by choosing Recycle
// and an unset RecycleOrBurn config is not uniquely penalized.
withheld_incentive = withheld_incentive.saturating_add(incentive);
// Check if we should recycle or burn the incentive
match RecycleOrBurn::<T>::try_get(netuid) {
Ok(RecycleOrBurnEnum::Recycle) => {
Expand Down Expand Up @@ -716,6 +726,13 @@ impl<T: Config> Pallet<T> {
);
}

// Record the proportion of this tempo's miner emission that was withheld from
// miners (directed to owner/immune hotkeys, whether recycled or burned).
let withheld_proportion: U96F32 = U96F32::saturating_from_num(withheld_incentive.to_u64())
.checked_div(U96F32::saturating_from_num(total_incentive.to_u64()))
.unwrap_or_else(|| U96F32::saturating_from_num(0));
MinerBurned::<T>::insert(netuid, withheld_proportion);
Comment on lines 726 to +734

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Emission penalty path lacks active tests

This adds a new economic signal (MinerBurned) and feeds it into chain emission allocation, but the final diff has no active test proving the recorded ratio or the resulting share math. The only change in tests/subnet_emissions.rs is an import, even though the commits and overlapping branches contain tests for no-burn, partial-burn, full-burn, all-full-burn fallback, and root-proportion weighting. Please restore/add active coverage for both sides of the behavior: distribute_emissions records withheld_incentive / total_incentive correctly, including recycle/burn/default and zero-total cases, and get_shares reallocates as root_proportion * price * (1 - miner_burned) with the all-zero fallback. This is runtime emission economics, so it should not merge untested.


// Distribute alpha divs.
let _ = AlphaDividendsPerSubnet::<T>::clear_prefix(netuid, u32::MAX, None);
for (hotkey, mut alpha_divs) in alpha_dividends {
Expand Down
37 changes: 36 additions & 1 deletion pallets/subtensor/src/coinbase/subnet_emissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,42 @@ impl<T: Config> Pallet<T> {
// redistributed to enabled subnets in `get_subnet_block_emissions`, so the
// effective emission is e_i = p_i / sum(p_j) over emit-enabled subnets.
pub(crate) fn get_shares(subnets_to_emit_to: &[NetUid]) -> BTreeMap<NetUid, U64F64> {
Self::get_shares_price_ema(subnets_to_emit_to)
let price_shares = Self::get_shares_price_ema(subnets_to_emit_to);

// Weight each subnet's price share by root_proportion * (1 - miner_burned), then
// renormalize. The effective emission is therefore proportional to
// root_proportion_i * price_i * (1 - miner_burned_i).
// - root_proportion shrinks as a subnet's alpha issuance grows, so emission is
// reallocated away from older subnets toward newer ones (easier entrance).
// - (1 - miner_burned) reallocates away from subnets that withhold miner emission.
let zero = U64F64::saturating_from_num(0);
let one = U64F64::saturating_from_num(1);
let weighted: BTreeMap<NetUid, U64F64> = price_shares
.iter()
.map(|(netuid, share)| {
let burned = U64F64::saturating_from_num(MinerBurned::<T>::get(netuid)).min(one);
let root_prop = U64F64::saturating_from_num(Self::root_proportion(*netuid));
let factor = root_prop.saturating_mul(one.saturating_sub(burned));
(*netuid, share.saturating_mul(factor))
})
.collect();

let total_weight = weighted
.values()
.copied()
.fold(zero, |acc, w| acc.saturating_add(w));

if total_weight > zero {
weighted
.into_iter()
.map(|(netuid, w)| (netuid, w.safe_div(total_weight)))
.collect()
} else {
// The combined weight zeroes out for every subnet (e.g. no root stake, or
// every subnet burning all of its miner emission); fall back to the
// unweighted price shares so the block's emission is not stranded.
price_shares
}
}

// Implementation of shares that uses subnet EMA prices (SubnetMovingPrice),
Expand Down
22 changes: 22 additions & 0 deletions pallets/subtensor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ pub const MAX_SUBNET_CLAIMS: usize = 5;

pub const MAX_ROOT_CLAIM_THRESHOLD: u64 = 10_000_000;

/// Account flag bit that opts into receiving locked alpha transfers.
pub const ACCOUNT_FLAGS_ACCEPT_LOCKED_ALPHA: u128 = 1u128 << 0;

#[allow(deprecated)]
#[deny(missing_docs)]
#[import_section(errors::errors)]
Expand Down Expand Up @@ -1190,6 +1193,11 @@ pub mod pallet {
pub type Owner<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, T::AccountId, ValueQuery, DefaultAccount<T>>;

/// MAP ( coldkey ) --> flags | Account-level flags. Defaults to zero.
#[pallet::storage]
pub type AccountFlags<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, u128, ValueQuery>;

/// MAP ( hot ) --> take | Returns the hotkey delegation take. And signals that this key is open for delegation
#[pallet::storage]
pub type Delegates<T: Config> =
Expand Down Expand Up @@ -1926,6 +1934,20 @@ pub mod pallet {
pub type PendingOwnerCut<T> =
StorageMap<_, Identity, NetUid, AlphaBalance, ValueQuery, DefaultZeroAlpha<T>>;

/// Default miner-burned proportion.
#[pallet::type_value]
pub fn DefaultMinerBurned<T: Config>() -> U96F32 {
U96F32::saturating_from_num(0.0)
}
/// --- MAP ( netuid ) --> miner_burned | Proportion (0..1) of this tempo's miner
/// (incentive) emission that was withheld from miners during emission distribution
/// because the recipient hotkey is owned by the subnet owner (immune key). Counts
/// emission that is either recycled or burned, so the value is independent of the
/// subnet's RecycleOrBurn configuration.
#[pallet::storage]
pub type MinerBurned<T> =
StorageMap<_, Identity, NetUid, U96F32, ValueQuery, DefaultMinerBurned<T>>;

/// --- MAP ( netuid ) --> blocks_since_last_step
#[pallet::storage]
pub type BlocksSinceLastStep<T> =
Expand Down
26 changes: 26 additions & 0 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2625,5 +2625,31 @@ mod dispatches {
pub fn trigger_epoch(origin: OriginFor<T>, netuid: NetUid) -> DispatchResult {
Self::do_trigger_epoch(origin, netuid)
}

/// Sets or clears whether the caller rejects incoming locked alpha.
///
/// Coldkeys reject locked alpha by default. Passing `false` opts the
/// caller into receiving locked alpha from stake transfers or coldkey
/// swaps.
#[pallet::call_index(142)]
#[pallet::weight((
<T as frame_system::Config>::DbWeight::get().reads_writes(1, 1),
DispatchClass::Normal,
Pays::Yes
))]
pub fn set_reject_locked_alpha(origin: OriginFor<T>, enabled: bool) -> DispatchResult {
let coldkey = ensure_signed(origin)?;
AccountFlags::<T>::mutate_exists(&coldkey, |maybe_flags| {
let mut flags = maybe_flags.unwrap_or_default();
if enabled {
flags &= !crate::ACCOUNT_FLAGS_ACCEPT_LOCKED_ALPHA;
} else {
flags |= crate::ACCOUNT_FLAGS_ACCEPT_LOCKED_ALPHA;
}
*maybe_flags = if flags == 0 { None } else { Some(flags) };
});
Self::deposit_event(Event::RejectLockedAlphaUpdated { coldkey, enabled });
Ok(())
}
}
}
2 changes: 2 additions & 0 deletions pallets/subtensor/src/macros/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,5 +317,7 @@ mod errors {
/// an out-of-band epoch would desync the CRv3 reveal window from the wall-clock
/// Drand schedule and silently drop committed weights.
DynamicTempoBlockedByCommitReveal,
/// The destination coldkey rejects incoming locked alpha.
AccountRejectsLockedAlpha,
}
}
8 changes: 8 additions & 0 deletions pallets/subtensor/src/macros/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,5 +669,13 @@ mod events {
/// Whether this coldkey's locks are now perpetual.
enabled: bool,
},

/// A coldkey's reject locked alpha account flag was updated.
RejectLockedAlphaUpdated {
/// The coldkey whose flag changed.
coldkey: T::AccountId,
/// Whether this coldkey rejects incoming locked alpha.
enabled: bool,
},
}
}
84 changes: 61 additions & 23 deletions pallets/subtensor/src/staking/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,29 @@ impl<T: Config> Pallet<T> {
LockingColdkeys::<T>::remove((netuid, hotkey, coldkey));
}

pub fn account_rejects_locked_alpha(coldkey: &T::AccountId) -> bool {
AccountFlags::<T>::get(coldkey) & crate::ACCOUNT_FLAGS_ACCEPT_LOCKED_ALPHA != 1
}

pub fn ensure_can_receive_locked_alpha(
coldkey: &T::AccountId,
amount: AlphaBalance,
) -> DispatchResult {
let rejects_locked_alpha = Self::account_rejects_locked_alpha(coldkey);
Self::ensure_can_receive_locked_alpha_with_flag(rejects_locked_alpha, amount)
}

fn ensure_can_receive_locked_alpha_with_flag(
rejects_locked_alpha: bool,
amount: AlphaBalance,
) -> DispatchResult {
if amount.is_zero() {
return Ok(());
}
ensure!(!rejects_locked_alpha, Error::<T>::AccountRejectsLockedAlpha);
Ok(())
}

pub fn insert_lock_state(
coldkey: &T::AccountId,
netuid: NetUid,
Expand Down Expand Up @@ -1359,6 +1382,10 @@ impl<T: Config> Pallet<T> {
Self::ensure_no_active_locks(new_coldkey)?;

let mut locks_to_transfer: Vec<(NetUid, T::AccountId, LockState)> = Vec::new();
let now = Self::get_current_block_as_u64();
let unlock_rate = UnlockRate::<T>::get();
let maturity_rate = MaturityRate::<T>::get();
let new_coldkey_rejects_locked_alpha = Self::account_rejects_locked_alpha(new_coldkey);
let decaying_locks_to_transfer: Vec<(NetUid, bool)> =
DecayingLock::<T>::iter_prefix(old_coldkey).collect();

Expand All @@ -1367,52 +1394,62 @@ impl<T: Config> Pallet<T> {
locks_to_transfer.push((netuid, hotkey, lock));
}

for (netuid, decaying) in decaying_locks_to_transfer.iter() {
DecayingLock::<T>::insert(new_coldkey, *netuid, *decaying);
}

// Remove locks for old coldkey and insert for new
let mut rolled_locks_to_transfer: Vec<(NetUid, T::AccountId, LockState, bool)> = Vec::new();
for (netuid, hotkey, lock) in locks_to_transfer {
let now = Self::get_current_block_as_u64();
let unlock_rate = UnlockRate::<T>::get();
let maturity_rate = MaturityRate::<T>::get();
let perpetual_lock = decaying_locks_to_transfer
.iter()
.any(|(decaying_netuid, decaying)| *decaying_netuid == netuid && !*decaying);
let old_lock = ConvictionModel::roll_forward_lock(
let (old_lock, _) = ConvictionModel::roll_forward_lock(
lock,
now,
unlock_rate,
maturity_rate,
Self::is_subnet_owner_hotkey(netuid, &hotkey),
perpetual_lock,
);
Self::ensure_can_receive_locked_alpha_with_flag(
new_coldkey_rejects_locked_alpha,
old_lock.locked_mass,
)?;
rolled_locks_to_transfer.push((netuid, hotkey, old_lock, perpetual_lock));
}

// Remove old locks and reduce old aggregate buckets before moving the
// perpetual-lock flags; aggregate selection depends on the old flag.
for (netuid, hotkey, old_lock, _) in rolled_locks_to_transfer.iter() {
Lock::<T>::remove((old_coldkey.clone(), *netuid, hotkey.clone()));
Self::maybe_remove_locking_coldkey(hotkey, *netuid, old_coldkey);
Self::reduce_aggregate_lock(
old_coldkey,
hotkey,
*netuid,
old_lock.locked_mass,
old_lock.conviction,
);
}

for (netuid, _) in decaying_locks_to_transfer {
if let Some(decaying) = DecayingLock::<T>::take(old_coldkey, netuid) {
DecayingLock::<T>::insert(new_coldkey, netuid, decaying);
}
}

// Insert locks for the new coldkey and add to the destination aggregate
// buckets after the flags have moved.
for (netuid, hotkey, old_lock, perpetual_lock) in rolled_locks_to_transfer {
let new_lock = ConvictionModel::roll_forward_lock(
old_lock.0.clone(),
old_lock.clone(),
now,
unlock_rate,
maturity_rate,
Self::is_subnet_owner_hotkey(netuid, &hotkey),
perpetual_lock,
)
.0;
Lock::<T>::remove((old_coldkey.clone(), netuid, hotkey.clone()));
Self::maybe_remove_locking_coldkey(&hotkey, netuid, old_coldkey);
Self::reduce_aggregate_lock(
old_coldkey,
&hotkey,
netuid,
old_lock.0.locked_mass,
old_lock.0.conviction,
);
Self::insert_lock_state(new_coldkey, netuid, &hotkey, new_lock.clone());
Self::add_aggregate_lock(new_coldkey, &hotkey, netuid, new_lock);
}

for (netuid, _) in decaying_locks_to_transfer {
DecayingLock::<T>::remove(old_coldkey, netuid);
}

Ok(())
}

Expand Down Expand Up @@ -1838,6 +1875,7 @@ impl<T: Config> Pallet<T> {
.conviction
.saturating_add(conviction_transfer);
}
Self::ensure_can_receive_locked_alpha(destination_coldkey, locked_transfer)?;

source_lock = ConvictionModel::roll_forward_lock(
source_lock,
Expand Down
Loading
Loading