diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index b64043a4f5..35069ef6b5 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -322,6 +322,7 @@ impl Pallet { PendingServerEmission::::remove(netuid); PendingRootAlphaDivs::::remove(netuid); PendingOwnerCut::::remove(netuid); + MinerBurned::::remove(netuid); BlocksSinceLastStep::::remove(netuid); LastMechansimStepBlock::::remove(netuid); LastAdjustmentBlock::::remove(netuid); diff --git a/pallets/subtensor/src/coinbase/run_coinbase.rs b/pallets/subtensor/src/coinbase/run_coinbase.rs index a6c988feea..60f6253643 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -187,11 +187,6 @@ impl Pallet { let mut alpha_in: BTreeMap = BTreeMap::new(); let mut alpha_out: BTreeMap = BTreeMap::new(); let mut excess_tao: BTreeMap = BTreeMap::new(); - let tao_block_emission: U96F32 = U96F32::saturating_from_num( - Self::calculate_block_emission() - .unwrap_or(TaoBalance::ZERO) - .to_u64(), - ); // Only calculate for subnets that we are emitting to. for (&netuid_i, &tao_emission_i) in subnet_emissions.iter() { @@ -210,7 +205,14 @@ impl Pallet { let alpha_out_i: U96F32 = alpha_emission_i; let mut alpha_in_i: U96F32 = tao_emission_i.safe_div_or(price_i, U96F32::from_num(0.0)); - let alpha_injection_cap: U96F32 = alpha_emission_i.min(tao_block_emission); + // Cap alpha injection by the subnet's root proportion of its alpha emission. + // root_proportion = tao_weight / (tao_weight + alpha_issuance), so as a subnet + // ages its alpha issuance grows, root_proportion shrinks, and the injection cap + // falls. The TAO emission that can no longer be injected as liquidity becomes + // excess TAO and is routed into chain buys instead. This is what transitions + // older subnets from liquidity injection to chain buys over time. + let root_proportion_i: U96F32 = Self::root_proportion(netuid_i); + let alpha_injection_cap: U96F32 = root_proportion_i.saturating_mul(alpha_emission_i); if alpha_in_i > alpha_injection_cap { alpha_in_i = alpha_injection_cap; tao_in_i = alpha_in_i.saturating_mul(price_i); @@ -611,14 +613,24 @@ impl Pallet { let subnet_owner_coldkey = SubnetOwner::::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::::try_get(netuid) { Ok(RecycleOrBurnEnum::Recycle) => { @@ -658,6 +670,13 @@ impl Pallet { ); } + // 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::::insert(netuid, withheld_proportion); + // Distribute alpha divs. let _ = AlphaDividendsPerSubnet::::clear_prefix(netuid, u32::MAX, None); for (hotkey, mut alpha_divs) in alpha_dividends { diff --git a/pallets/subtensor/src/coinbase/subnet_emissions.rs b/pallets/subtensor/src/coinbase/subnet_emissions.rs index 63dbf36e5a..d7b35166d2 100644 --- a/pallets/subtensor/src/coinbase/subnet_emissions.rs +++ b/pallets/subtensor/src/coinbase/subnet_emissions.rs @@ -329,14 +329,51 @@ impl Pallet { offset_flows } - // Combines ema price method and tao flow method linearly over FlowHalfLife blocks + // Price-based emission shares: each subnet's share is its EMA price normalized + // by the sum of EMA prices. Emit-disabled subnets are zeroed and their share + // 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 { - Self::get_shares_flow(subnets_to_emit_to) - // 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 = price_shares + .iter() + .map(|(netuid, share)| { + let burned = U64F64::saturating_from_num(MinerBurned::::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 + } } - // DEPRECATED: Implementation of shares that uses EMA prices will be gradually deprecated - #[allow(dead_code)] + // Implementation of shares that uses subnet EMA prices (SubnetMovingPrice), + // not the active/spot alpha price. fn get_shares_price_ema(subnets_to_emit_to: &[NetUid]) -> BTreeMap { // Get sum of alpha moving prices let total_moving_prices = subnets_to_emit_to diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 97ba77a92a..828b8e6fb8 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -69,6 +69,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)] @@ -1186,6 +1189,11 @@ pub mod pallet { pub type Owner = StorageMap<_, Blake2_128Concat, T::AccountId, T::AccountId, ValueQuery, DefaultAccount>; + /// MAP ( coldkey ) --> flags | Account-level flags. Defaults to zero. + #[pallet::storage] + pub type AccountFlags = + 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 = @@ -1875,6 +1883,20 @@ pub mod pallet { pub type PendingOwnerCut = StorageMap<_, Identity, NetUid, AlphaBalance, ValueQuery, DefaultZeroAlpha>; + /// Default miner-burned proportion. + #[pallet::type_value] + pub fn DefaultMinerBurned() -> 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 = + StorageMap<_, Identity, NetUid, U96F32, ValueQuery, DefaultMinerBurned>; + /// --- MAP ( netuid ) --> blocks_since_last_step #[pallet::storage] pub type BlocksSinceLastStep = diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index b471328aec..0e5a386529 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2593,5 +2593,30 @@ mod dispatches { let coldkey = ensure_signed(origin)?; Self::do_set_perpetual_lock(&coldkey, netuid, enabled) } + /// 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(139)] + #[pallet::weight(( + ::DbWeight::get().reads_writes(1, 1), + DispatchClass::Normal, + Pays::Yes + ))] + pub fn set_reject_locked_alpha(origin: OriginFor, enabled: bool) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + AccountFlags::::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(()) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index cb120b56b5..8b454d609c 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -305,5 +305,7 @@ mod errors { CannotUseSystemAccount, /// Trying to unlock more than locked UnlockAmountTooHigh, + /// The destination coldkey rejects incoming locked alpha. + AccountRejectsLockedAlpha, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 918baf1107..787b5b5503 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -631,5 +631,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, + }, } } diff --git a/pallets/subtensor/src/staking/lock.rs b/pallets/subtensor/src/staking/lock.rs index aa4a6508ab..3e2f3c54e3 100644 --- a/pallets/subtensor/src/staking/lock.rs +++ b/pallets/subtensor/src/staking/lock.rs @@ -444,6 +444,29 @@ impl ConvictionModel { } impl Pallet { + pub fn account_rejects_locked_alpha(coldkey: &T::AccountId) -> bool { + AccountFlags::::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::::AccountRejectsLockedAlpha); + Ok(()) + } + pub fn insert_lock_state( coldkey: &T::AccountId, netuid: NetUid, @@ -1331,40 +1354,67 @@ impl Pallet { 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::::get(); + let maturity_rate = MaturityRate::::get(); + let new_coldkey_rejects_locked_alpha = Self::account_rejects_locked_alpha(new_coldkey); + let decaying_locks_to_transfer: Vec<(NetUid, bool)> = + DecayingLock::::iter_prefix(old_coldkey).collect(); // Gather locks for old coldkey for ((netuid, hotkey), lock) in Lock::::iter_prefix((old_coldkey,)) { locks_to_transfer.push((netuid, hotkey, lock)); } - // 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::::get(); - let maturity_rate = MaturityRate::::get(); + let perpetual_lock = decaying_locks_to_transfer + .iter() + .any(|(decaying_netuid, decaying)| *decaying_netuid == netuid && !*decaying); let old_lock = ConvictionModel::roll_forward_lock( lock, now, unlock_rate, maturity_rate, Self::is_subnet_owner_hotkey(netuid, &hotkey), - Self::is_perpetual_lock(old_coldkey, netuid), + 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::::remove((old_coldkey.clone(), *netuid, hotkey.clone())); + 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::::take(old_coldkey, netuid) { + DecayingLock::::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.clone(), now, unlock_rate, maturity_rate, Self::is_subnet_owner_hotkey(netuid, &hotkey), - Self::is_perpetual_lock(new_coldkey, netuid), - ); - Lock::::remove((old_coldkey.clone(), netuid, hotkey.clone())); - Self::reduce_aggregate_lock( - old_coldkey, - &hotkey, - netuid, - old_lock.locked_mass, - old_lock.conviction, + perpetual_lock, ); Self::insert_lock_state(new_coldkey, netuid, &hotkey, new_lock.clone()); Self::add_aggregate_lock(new_coldkey, &hotkey, netuid, new_lock); @@ -1780,6 +1830,7 @@ impl Pallet { .conviction .saturating_add(conviction_transfer); } + Self::ensure_can_receive_locked_alpha(destination_coldkey, locked_transfer)?; source_lock = ConvictionModel::roll_forward_lock( source_lock, diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 1b6b9d8c6b..4b75d164f4 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -1139,11 +1139,15 @@ fn test_claim_root_coinbase_distribution() { run_to_block(2); let alpha_issuance = SubtensorModule::get_alpha_issuance(netuid); - // We went two blocks so we should have 2x the alpha emissions - assert_eq!( - initial_alpha_issuance + alpha_emissions.saturating_mul(2.into()), - alpha_issuance - ); + // Net issuance grows by the block alpha emission (alpha_out) plus the + // root-proportion-capped alpha injection. Chain buys move alpha between the + // pool reserve and outstanding supply without changing net issuance, and with + // this subnet's small root proportion the injection is well under a second + // full emission. + let issuance_growth = + u64::from(alpha_issuance).saturating_sub(u64::from(initial_alpha_issuance)); + assert!(issuance_growth >= u64::from(alpha_emissions)); + assert!(issuance_growth < u64::from(alpha_emissions.saturating_mul(2.into()))); let root_prop = initial_tao as f64 / (u64::from(alpha_issuance) + initial_tao) as f64; let root_validators_share = 0.5f64; diff --git a/pallets/subtensor/src/tests/coinbase.rs b/pallets/subtensor/src/tests/coinbase.rs index 45260ef8fc..6b5857afcc 100644 --- a/pallets/subtensor/src/tests/coinbase.rs +++ b/pallets/subtensor/src/tests/coinbase.rs @@ -30,6 +30,19 @@ fn close(value: u64, target: u64, eps: u64) { ) } +/// Seed a large root stake with full TAO weight so that +/// `root_proportion = tao_weight / (tao_weight + alpha_issuance)` is ~1. +/// This keeps the alpha-injection cap (`root_proportion * alpha_emission`) from +/// spuriously binding for small per-subnet emissions, preserving the liquidity +/// injection behavior these tests were written for. +fn set_full_injection_root_stake() { + SubnetTAO::::insert( + NetUid::ROOT, + TaoBalance::from(1_000_000_000_000_000_000_u64), + ); + SubtensorModule::set_tao_weight(u64::MAX); +} + // SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::coinbase::test_hotkey_take --exact --show-output --nocapture #[test] fn test_hotkey_take() { @@ -70,9 +83,11 @@ fn test_coinbase_tao_issuance_base() { let subnet_owner_ck = U256::from(1001); let subnet_owner_hk = U256::from(1002); let netuid = add_dynamic_network(&subnet_owner_hk, &subnet_owner_ck); + // Price-based emission shares require a non-zero moving price. + SubnetMovingPrice::::insert(netuid, I96F32::from_num(1)); + // Keep root_proportion ~1 so the injection cap does not bind. + set_full_injection_root_stake(); let total_issuance_before = TotalIssuance::::get(); - // Set subnet TAO flow to non-zero - SubnetTaoFlow::::insert(netuid, 1234567_i64); let tao_in_before = SubnetTAO::::get(netuid); let total_stake_before = TotalStake::::get(); let emission_credit = SubtensorModule::mint_tao(emission); @@ -248,43 +263,6 @@ fn test_coinbase_disabled_subnet_emission_redistributes_tao_to_enabled_subnets() }); } -#[test] -fn test_net_tao_flow_disabled_still_drains_protocol_flow_into_ema() { - new_test_ext(1).execute_with(|| { - let netuid1 = NetUid::from(1); - let netuid2 = NetUid::from(2); - - add_network(netuid1, 1, 0); - add_network(netuid2, 1, 0); - - NetTaoFlowEnabled::::set(false); - FlowEmaSmoothingFactor::::set(i64::MAX as u64); - - SubnetTaoFlow::::insert(netuid1, 1_000_i64); - SubnetTaoFlow::::insert(netuid2, 1_000_i64); - SubtensorModule::record_protocol_inflow(netuid1, 700.into()); - SubtensorModule::record_protocol_outflow(netuid2, 300.into()); - - System::set_block_number(1); - - SubtensorModule::get_subnet_block_emissions( - &[netuid1, netuid2], - U96F32::saturating_from_num(1_000_000u64), - ); - - assert_eq!(SubnetProtocolFlow::::get(netuid1), 0); - assert_eq!(SubnetProtocolFlow::::get(netuid2), 0); - assert_eq!( - SubnetEmaProtocolFlow::::get(netuid1), - Some((1, I64F64::from_num(700))) - ); - assert_eq!( - SubnetEmaProtocolFlow::::get(netuid2), - Some((1, I64F64::from_num(-300))) - ); - }); -} - #[test] fn test_sudo_set_subnet_emission_enabled_multiple_subnets_multiple_toggles() { new_test_ext(1).execute_with(|| { @@ -297,9 +275,9 @@ fn test_sudo_set_subnet_emission_enabled_multiple_subnets_multiple_toggles() { add_network(netuid2, 1, 0); add_network(netuid3, 1, 0); - SubnetTaoFlow::::insert(netuid1, 100_000_000_i64); - SubnetTaoFlow::::insert(netuid2, 100_000_000_i64); - SubnetTaoFlow::::insert(netuid3, 100_000_000_i64); + // Keep root_proportion ~1 so TAO-side emission is injected (populating + // SubnetTaoInEmission) rather than routed entirely to chain buys. + set_full_injection_root_stake(); let assert_emission_storage = |expected1: u64, expected2: u64, expected3: u64| { assert_abs_diff_eq!( @@ -417,10 +395,12 @@ fn test_coinbase_tao_issuance_different_prices() { SubnetMechanism::::insert(netuid1, 1); SubnetMechanism::::insert(netuid2, 1); - // Set subnet flows - // Subnet 2 has twice the flow of subnet 1. - SubnetTaoFlow::::insert(netuid1, 100_000_000_i64); - SubnetTaoFlow::::insert(netuid2, 200_000_000_i64); + // Price-based shares: subnet 2 has twice the moving price of subnet 1, + // so it should receive twice the TAO emission. + SubnetMovingPrice::::insert(netuid1, I96F32::from_num(0.1)); + SubnetMovingPrice::::insert(netuid2, I96F32::from_num(0.2)); + // Keep root_proportion ~1 so the injection cap does not bind. + set_full_injection_root_stake(); // Assert initial TAO reserves. assert_eq!(SubnetTAO::::get(netuid1), initial_tao.into()); @@ -668,9 +648,8 @@ fn test_coinbase_alpha_issuance_base() { SubnetAlphaIn::::insert(netuid1, AlphaBalance::from(initial)); SubnetTAO::::insert(netuid2, TaoBalance::from(initial)); SubnetAlphaIn::::insert(netuid2, AlphaBalance::from(initial)); - // Equal flow - SubnetTaoFlow::::insert(netuid1, 100_000_000_i64); - SubnetTaoFlow::::insert(netuid2, 100_000_000_i64); + // Keep root_proportion ~1 so the injection cap does not bind. + set_full_injection_root_stake(); // Check initial SubtensorModule::run_coinbase(emission_credit); // tao_in = 500_000 @@ -710,10 +689,11 @@ fn test_coinbase_alpha_issuance_different() { SubnetAlphaIn::::insert(netuid1, AlphaBalance::from(initial)); SubnetTAO::::insert(netuid2, TaoBalance::from(2 * initial)); SubnetAlphaIn::::insert(netuid2, AlphaBalance::from(initial)); - // Set subnet TAO flows to non-zero and 1:2 ratio - SubnetTaoFlow::::insert(netuid1, 100_000_000_i64); - SubnetTaoFlow::::insert(netuid2, 200_000_000_i64); - // Do NOT Set tao flow, let it initialize + // Price-based shares with prices 1 and 2 (1:2 ratio). + SubnetMovingPrice::::insert(netuid1, I96F32::from_num(1)); + SubnetMovingPrice::::insert(netuid2, I96F32::from_num(2)); + // Keep root_proportion ~1 so the injection cap does not bind. + set_full_injection_root_stake(); // Run coinbase SubtensorModule::run_coinbase(emission_credit); // tao_in = 333_333 @@ -754,16 +734,23 @@ fn test_coinbase_alpha_issuance_with_cap_trigger() { // Set subnet prices. SubnetMovingPrice::::insert(netuid1, I96F32::from_num(1)); SubnetMovingPrice::::insert(netuid2, I96F32::from_num(2)); + // Keep root_proportion ~1 so the injection cap binds at alpha_emission. + set_full_injection_root_stake(); // Run coinbase SubtensorModule::run_coinbase(emission_credit); - // tao_in = 333_333 - // alpha_in = 333_333/price > 1_000_000_000 --> 1_000_000_000 + initial_alpha + // alpha_in is capped at the injection cap, so injected alpha stays below + // a full block emission on top of the initial reserve. assert!(SubnetAlphaIn::::get(netuid1) < (initial_alpha + 1_000_000_000).into()); - assert_eq!(SubnetAlphaOut::::get(netuid2), 1_000_000_000.into()); - // tao_in = 666_666 - // alpha_in = 666_666/price > 1_000_000_000 --> 1_000_000_000 + initial_alpha + // Per-block alpha emission is the full block emission regardless of the cap. + assert_eq!( + SubnetAlphaOutEmission::::get(netuid1), + 1_000_000_000.into() + ); assert!(SubnetAlphaIn::::get(netuid2) < (initial_alpha + 1_000_000_000).into()); - assert_eq!(SubnetAlphaOut::::get(netuid2), 1_000_000_000.into()); // Gets full block emission. + assert_eq!( + SubnetAlphaOutEmission::::get(netuid2), + 1_000_000_000.into() + ); // Gets full block emission. }); } @@ -791,9 +778,10 @@ fn test_coinbase_alpha_issuance_with_cap_trigger_and_block_emission() { // Enable emission FirstEmissionBlockNumber::::insert(netuid1, 0); FirstEmissionBlockNumber::::insert(netuid2, 0); - // Set subnet TAO flows to non-zero and 1:2 ratio - SubnetTaoFlow::::insert(netuid1, 100_000_000_i64); - SubnetTaoFlow::::insert(netuid2, 200_000_000_i64); + // Price-based shares (1:2 ratio). Low pool prices mean alpha_in exceeds the + // injection cap, so the surplus TAO is spent on chain buys. + SubnetMovingPrice::::insert(netuid1, I96F32::from_num(1)); + SubnetMovingPrice::::insert(netuid2, I96F32::from_num(2)); // Force the swap to initialize SubtensorModule::swap_tao_for_alpha( @@ -2706,6 +2694,11 @@ fn test_distribute_emission_zero_emission() { Incentive::::remove(NetUidStorageIndex::from(netuid)); Dividends::::remove(netuid); + // Capture stake right before the zero-emission distribution so the assertion + // isolates that call (the subnet legitimately accrues emission during the + // preceding block runs under price-based shares). + let stake_before_distribute = SubtensorModule::get_total_stake_for_hotkey(&hotkey); + // Set the emission to be ZERO. SubtensorModule::distribute_emission( netuid, @@ -2717,8 +2710,8 @@ fn test_distribute_emission_zero_emission() { // Get the new stake of the hotkey. let new_stake = SubtensorModule::get_total_stake_for_hotkey(&hotkey); - // We expect the stake to remain unchanged. - assert_eq!(new_stake, init_stake.into()); + // We expect the stake to remain unchanged by the zero-emission distribution. + assert_eq!(new_stake, stake_before_distribute); // Check that the incentive and dividends are set by epoch. assert!( @@ -2962,8 +2955,10 @@ fn test_coinbase_v3_liquidity_update() { // Enable emissions and run coinbase (which will increase position liquidity) let emission: u64 = 1_234_567; let emission_credit = SubtensorModule::mint_tao(emission.into()); - // Set the TAO flow to non-zero - SubnetTaoFlow::::insert(netuid, 8348383_i64); + // Price-based emission shares require a non-zero moving price. + SubnetMovingPrice::::insert(netuid, I96F32::from_num(1)); + // Keep root_proportion ~1 so the injection cap does not bind. + set_full_injection_root_stake(); FirstEmissionBlockNumber::::insert(netuid, 0); SubtensorModule::run_coinbase(emission_credit); @@ -3625,11 +3620,17 @@ fn test_coinbase_subnet_terms_with_alpha_in_gt_alpha_emission() { let subnet_emissions = BTreeMap::from([(netuid0, tao_emission)]); + // The injection cap is root_proportion * alpha_emission. Seed root stake so + // root_proportion is well-defined and the cap is positive. + set_full_injection_root_stake(); + let root_prop: U96F32 = SubtensorModule::root_proportion(netuid0); + let injection_cap: U96F32 = root_prop.saturating_mul(alpha_emission); + let (tao_in, alpha_in, alpha_out, excess_tao) = SubtensorModule::get_subnet_terms(&subnet_emissions); - // Check our condition is met - assert!(tao_emission / price_to_set_fixed > alpha_emission); + // Check our condition is met: the raw alpha_in exceeds the cap, so it binds. + assert!(tao_emission / price_to_set_fixed > injection_cap); // alpha_out should be the alpha_emission, always assert_abs_diff_eq!( @@ -3638,11 +3639,11 @@ fn test_coinbase_subnet_terms_with_alpha_in_gt_alpha_emission() { epsilon = 0.01 ); - // alpha_in should equal the alpha_emission + // alpha_in should be capped at root_proportion * alpha_emission assert_abs_diff_eq!( alpha_in[&netuid0].to_num::(), - alpha_emission.to_num::(), - epsilon = 0.01 + injection_cap.to_num::(), + epsilon = injection_cap.to_num::() / 1_000.0 ); // tao_in should be the alpha_in at the ratio of the price assert_abs_diff_eq!( @@ -3687,11 +3688,17 @@ fn test_coinbase_subnet_terms_with_alpha_in_lte_alpha_emission() { let subnet_emissions = BTreeMap::from([(netuid0, tao_emission)]); + // The injection cap is root_proportion * alpha_emission. Seed root stake so + // the cap is large enough that raw alpha_in stays under it (no excess). + set_full_injection_root_stake(); + let root_prop: U96F32 = SubtensorModule::root_proportion(netuid0); + let injection_cap: U96F32 = root_prop.saturating_mul(alpha_emission); + let (tao_in, alpha_in, alpha_out, excess_tao) = SubtensorModule::get_subnet_terms(&subnet_emissions); - // Check our condition is met - assert!(tao_emission / price <= alpha_emission); + // Check our condition is met: raw alpha_in stays under the cap. + assert!(tao_emission / price <= injection_cap); // alpha_out should be the alpha_emission, always assert_abs_diff_eq!( @@ -4310,32 +4317,35 @@ fn test_get_subnet_terms_alpha_emissions_cap() { let owner_hotkey = U256::from(10); let owner_coldkey = U256::from(11); let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); - let tao_block_emission: U96F32 = U96F32::saturating_from_num( - SubtensorModule::calculate_block_emission() - .unwrap_or(TaoBalance::ZERO) - .to_u64(), + + // The injection cap is now root_proportion * alpha_emission. Seed root stake + // so root_proportion is well-defined, and derive the cap from the live values. + set_full_injection_root_stake(); + let alpha_emission_i: U96F32 = U96F32::saturating_from_num( + SubtensorModule::get_block_emission_for_issuance( + SubtensorModule::get_alpha_issuance(netuid).into(), + ) + .unwrap_or(0), ); + let injection_cap: U96F32 = + SubtensorModule::root_proportion(netuid).saturating_mul(alpha_emission_i); - // price = 1.0 - // tao_block_emission = 1000000000 - // tao_block_emission == alpha_emission_i - // alpha_in_i <= alpha_injection_cap + // price = 1.0, alpha_in_i (== emissions1) <= alpha_injection_cap (not capped) let emissions1 = U96F32::from_num(100_000_000); + assert!(emissions1 < injection_cap); let subnet_emissions1 = BTreeMap::from([(netuid, emissions1)]); let (_, alpha_in, _, _) = SubtensorModule::get_subnet_terms(&subnet_emissions1); assert_eq!(alpha_in.get(&netuid).copied().unwrap(), emissions1); - // price = 1.0 - // tao_block_emission = 1000000000 - // tao_block_emission == alpha_emission_i - // alpha_in_i > alpha_injection_cap + // price = 1.0, alpha_in_i (== emissions2) > alpha_injection_cap (capped) let emissions2 = U96F32::from_num(10_000_000_000u64); + assert!(emissions2 > injection_cap); let subnet_emissions2 = BTreeMap::from([(netuid, emissions2)]); let (_, alpha_in, _, _) = SubtensorModule::get_subnet_terms(&subnet_emissions2); - assert_eq!(alpha_in.get(&netuid).copied().unwrap(), tao_block_emission); + assert_eq!(alpha_in.get(&netuid).copied().unwrap(), injection_cap); }); } diff --git a/pallets/subtensor/src/tests/locks.rs b/pallets/subtensor/src/tests/locks.rs index 91b87a634f..fc3e50f020 100644 --- a/pallets/subtensor/src/tests/locks.rs +++ b/pallets/subtensor/src/tests/locks.rs @@ -6,6 +6,7 @@ )] use approx::assert_abs_diff_eq; +use frame_support::dispatch::{GetDispatchInfo, Pays}; use frame_support::weights::Weight; use frame_support::{assert_noop, assert_ok}; use safe_math::FixedExt; @@ -96,6 +97,40 @@ fn roll_forward_individual_lock( ) } +#[test] +fn test_account_flags_default_to_zero_and_reject_locked_alpha_setter_pays_fee() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(1); + + assert_eq!(AccountFlags::::get(coldkey), 0); + assert!(!AccountFlags::::contains_key(coldkey)); + assert!(SubtensorModule::account_rejects_locked_alpha(&coldkey)); + + let call = + RuntimeCall::SubtensorModule(crate::Call::set_reject_locked_alpha { enabled: true }); + assert_eq!(call.get_dispatch_info().pays_fee, Pays::Yes); + + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(coldkey), + false, + )); + assert_eq!( + AccountFlags::::get(coldkey), + ACCOUNT_FLAGS_ACCEPT_LOCKED_ALPHA + ); + assert!(AccountFlags::::contains_key(coldkey)); + assert!(!SubtensorModule::account_rejects_locked_alpha(&coldkey)); + + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(coldkey), + true, + )); + assert_eq!(AccountFlags::::get(coldkey), 0); + assert!(!AccountFlags::::contains_key(coldkey)); + assert!(SubtensorModule::account_rejects_locked_alpha(&coldkey)); + }); +} + fn roll_forward_hotkey_lock(lock: LockState, now: u64) -> LockState { roll_forward_lock(lock, now, false, true) } @@ -1599,6 +1634,10 @@ fn test_do_transfer_stake_same_subnet_transfers_lock_to_destination_coldkey() { let hotkey = U256::from(2); let netuid = setup_subnet_with_stake(coldkey_sender, hotkey, 100_000_000_000); DecayingLock::::insert(coldkey_receiver, netuid, false); + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(coldkey_receiver), + false, + )); let total = SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_sender, netuid); let lock_half = total / 2.into(); @@ -1694,6 +1733,101 @@ fn test_move_stake_cross_subnet_blocked_by_lock() { }); } +#[test] +fn test_do_transfer_stake_rejects_locked_alpha_to_flagged_destination() { + new_test_ext(1).execute_with(|| { + let coldkey_sender = U256::from(1); + let coldkey_receiver = U256::from(5); + let hotkey = U256::from(2); + let netuid = setup_subnet_with_stake(coldkey_sender, hotkey, 100_000_000_000); + + let total = SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_sender, netuid); + let lock_half = total / 2.into(); + assert_ok!(SubtensorModule::do_lock_stake( + &coldkey_sender, + netuid, + &hotkey, + lock_half, + )); + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(coldkey_receiver), + true, + )); + + let sender_lock_before = + Lock::::get((coldkey_sender, netuid, hotkey)).expect("sender lock should exist"); + let sender_alpha_before = + SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_sender, netuid); + let receiver_alpha_before = + SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_receiver, netuid); + + assert_noop!( + SubtensorModule::do_transfer_stake( + RuntimeOrigin::signed(coldkey_sender), + coldkey_receiver, + hotkey, + netuid, + netuid, + total, + ), + Error::::AccountRejectsLockedAlpha + ); + + assert_eq!( + Lock::::get((coldkey_sender, netuid, hotkey)), + Some(sender_lock_before) + ); + assert!(Lock::::get((coldkey_receiver, netuid, hotkey)).is_none()); + assert_eq!( + SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_sender, netuid), + sender_alpha_before + ); + assert_eq!( + SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_receiver, netuid), + receiver_alpha_before + ); + }); +} + +#[test] +fn test_do_transfer_stake_allows_unlocked_alpha_to_flagged_destination() { + new_test_ext(1).execute_with(|| { + let coldkey_sender = U256::from(1); + let coldkey_receiver = U256::from(5); + let hotkey = U256::from(2); + let netuid = setup_subnet_with_stake(coldkey_sender, hotkey, 100_000_000_000); + + let total = SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_sender, netuid); + let lock_half = total / 2.into(); + assert_ok!(SubtensorModule::do_lock_stake( + &coldkey_sender, + netuid, + &hotkey, + lock_half, + )); + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(coldkey_receiver), + true, + )); + + let unlocked_transfer = lock_half / 2.into(); + assert_ok!(SubtensorModule::do_transfer_stake( + RuntimeOrigin::signed(coldkey_sender), + coldkey_receiver, + hotkey, + netuid, + netuid, + unlocked_transfer, + )); + + assert!(Lock::::get((coldkey_receiver, netuid, hotkey)).is_none()); + assert_eq!( + SubtensorModule::total_coldkey_alpha_on_subnet(&coldkey_receiver, netuid), + unlocked_transfer + ); + }); +} + #[test] fn test_transfer_stake_cross_coldkey_allowed_partial() { new_test_ext(1).execute_with(|| { @@ -2724,6 +2858,10 @@ fn test_coldkey_swap_swaps_lock() { &hotkey, 5000u64.into(), )); + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(new_coldkey), + false, + )); // Perform coldkey swap assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); @@ -2754,6 +2892,10 @@ fn test_coldkey_swap_lock_blocks_unstake() { &hotkey, total, )); + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(new_coldkey), + false, + )); // Swap coldkey assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); @@ -2798,6 +2940,7 @@ fn test_coldkey_swap_allows_destination_conviction_only_lock() { last_update: SubtensorModule::get_current_block_as_u64(), }, ); + DecayingLock::::insert(old_coldkey, netuid, false); SubtensorModule::insert_lock_state( &new_coldkey, netuid, @@ -2826,6 +2969,8 @@ fn test_coldkey_swap_allows_destination_conviction_only_lock() { assert_eq!(swapped_lock.locked_mass, AlphaBalance::ZERO); assert_eq!(swapped_lock.conviction, old_conviction); assert_eq!(Lock::::iter_prefix((new_coldkey, netuid)).count(), 2); + assert!(DecayingLock::::get(old_coldkey, netuid).is_none()); + assert_eq!(DecayingLock::::get(new_coldkey, netuid), Some(false)); }); } @@ -2888,6 +3033,52 @@ fn test_coldkey_swap_rejects_destination_lock() { }); } +#[test] +fn test_coldkey_swap_rejects_locked_alpha_to_flagged_destination() { + new_test_ext(1).execute_with(|| { + let old_coldkey = U256::from(1); + let new_coldkey = U256::from(10); + let old_hotkey = U256::from(2); + let netuid = subtensor_runtime_common::NetUid::from(1); + + let old_locked = AlphaBalance::from(7_000u64); + let old_conviction = U64F64::from_num(77); + + SubtensorModule::insert_lock_state( + &old_coldkey, + netuid, + &old_hotkey, + LockState { + locked_mass: old_locked, + conviction: old_conviction, + last_update: SubtensorModule::get_current_block_as_u64(), + }, + ); + DecayingLock::::insert(old_coldkey, netuid, false); + assert_ok!(SubtensorModule::set_reject_locked_alpha( + RuntimeOrigin::signed(new_coldkey), + true, + )); + + assert_noop!( + SubtensorModule::swap_coldkey_locks(&old_coldkey, &new_coldkey), + Error::::AccountRejectsLockedAlpha + ); + + let source_lock = Lock::::get((old_coldkey, netuid, old_hotkey)) + .expect("source lock should remain after failed transfer"); + assert_eq!(source_lock.locked_mass, old_locked); + assert_eq!(source_lock.conviction, old_conviction); + assert!( + Lock::::iter_prefix((new_coldkey, netuid)) + .next() + .is_none() + ); + assert_eq!(DecayingLock::::get(old_coldkey, netuid), Some(false)); + assert!(DecayingLock::::get(new_coldkey, netuid).is_none()); + }); +} + #[test] // The public coldkey swap extrinsic runs inside a storage layer, so a late failure rolls back the earlier writes. fn test_failed_coldkey_swap_extrinsic_rolls_back_state_changes() { diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index 4bd1a9cb19..d1be5f13f4 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -405,6 +405,7 @@ fn dissolve_clears_all_per_subnet_storages() { PendingValidatorEmission::::insert(net, AlphaBalance::from(1)); PendingRootAlphaDivs::::insert(net, AlphaBalance::from(1)); PendingOwnerCut::::insert(net, AlphaBalance::from(1)); + MinerBurned::::insert(net, substrate_fixed::types::U96F32::from_num(1)); BlocksSinceLastStep::::insert(net, 1u64); LastMechansimStepBlock::::insert(net, 1u64); ServingRateLimit::::insert(net, 1u64); @@ -564,6 +565,7 @@ fn dissolve_clears_all_per_subnet_storages() { assert!(!PendingValidatorEmission::::contains_key(net)); assert!(!PendingRootAlphaDivs::::contains_key(net)); assert!(!PendingOwnerCut::::contains_key(net)); + assert!(!MinerBurned::::contains_key(net)); assert!(!BlocksSinceLastStep::::contains_key(net)); assert!(!LastMechansimStepBlock::::contains_key(net)); assert!(!ServingRateLimit::::contains_key(net)); diff --git a/pallets/subtensor/src/tests/subnet_emissions.rs b/pallets/subtensor/src/tests/subnet_emissions.rs index 060171d5c7..61af8b0cc7 100644 --- a/pallets/subtensor/src/tests/subnet_emissions.rs +++ b/pallets/subtensor/src/tests/subnet_emissions.rs @@ -5,7 +5,7 @@ use alloc::{collections::BTreeMap, vec::Vec}; use approx::assert_abs_diff_eq; use sp_core::U256; use substrate_fixed::types::{I64F64, I96F32, U64F64, U96F32}; -use subtensor_runtime_common::{NetUid, TaoBalance}; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; fn u64f64(x: f64) -> U64F64 { U64F64::from_num(x) @@ -151,122 +151,143 @@ fn inplace_pow_normalize_fractional_exponent() { }) } -#[allow(clippy::expect_used)] +/// Configure a dynamic subnet with a given EMA price and miner-burned proportion so +/// `get_shares` can be exercised. Also seeds a large root stake with full TAO weight so +/// that, with zero alpha issuance on the test subnets, `root_proportion` is 1 and the +/// root-proportion factor in `get_shares` is neutral (isolating the price/burn weighting). +fn set_price_and_burn(netuid: NetUid, price: f64, burned: f64) { + SubnetTAO::::insert( + NetUid::ROOT, + TaoBalance::from(1_000_000_000_000_000_000_u64), + ); + SubtensorModule::set_tao_weight(u64::MAX); + SubnetMechanism::::insert(netuid, 1); + SubnetMovingPrice::::insert(netuid, i96f32(price)); + MinerBurned::::insert(netuid, U96F32::from_num(burned)); +} + +/// With no miner emission burned anywhere, `get_shares` is exactly the price-based +/// share: e_i = p_i / sum(p_j). #[test] -fn protocol_normalization_keeps_eligible_subnet_count_from_collapsing() { +fn get_shares_no_burn_matches_price_shares() { new_test_ext(1).execute_with(|| { - let subnet_count = 70usize; - let user_flow = 100u64; - let protocol_flow_start = 40u64; - let protocol_flow_step = 4u64; - - NetTaoFlowEnabled::::set(true); - FlowNormExponent::::set(u64f64(1.0)); - TaoFlowCutoff::::set(i64f64(0.0)); - FlowEmaSmoothingFactor::::set(i64::MAX as u64); - - let subnets = (0..subnet_count) - .map(|i| { - let netuid = NetUid::from((i + 1) as u16); - add_network(netuid, 360, 0); - SubnetEmissionEnabled::::insert(netuid, true); - - let protocol_flow = protocol_flow_start + protocol_flow_step.saturating_mul(i as u64); - SubtensorModule::record_tao_inflow(netuid, TaoBalance::from(user_flow)); - SubtensorModule::record_protocol_inflow(netuid, TaoBalance::from(protocol_flow)); - - netuid - }) - .collect::>(); - - let subnets_to_emit_to = SubtensorModule::get_subnets_to_emit_to(&subnets); - assert_eq!( - subnets_to_emit_to.len(), - subnets.len(), - "test setup should make every subnet structurally eligible before flow scoring" - ); - - let emissions = SubtensorModule::get_subnet_block_emissions( - &subnets_to_emit_to, - U96F32::saturating_from_num(1_000_000_000u64), - ); + let n1 = NetUid::from(1); + let n2 = NetUid::from(2); + let n3 = NetUid::from(3); + set_price_and_burn(n1, 1.0, 0.0); + set_price_and_burn(n2, 2.0, 0.0); + set_price_and_burn(n3, 3.0, 0.0); + + let shares = SubtensorModule::get_shares(&[n1, n2, n3]); + let s1 = shares.get(&n1).unwrap().to_num::(); + let s2 = shares.get(&n2).unwrap().to_num::(); + let s3 = shares.get(&n3).unwrap().to_num::(); + + assert_abs_diff_eq!(s1, 1.0 / 6.0, epsilon = 1e-9); + assert_abs_diff_eq!(s2, 2.0 / 6.0, epsilon = 1e-9); + assert_abs_diff_eq!(s3, 3.0 / 6.0, epsilon = 1e-9); + assert_abs_diff_eq!(s1 + s2 + s3, 1.0, epsilon = 1e-9); + }); +} - let ema_rows = subnets_to_emit_to - .iter() - .map(|netuid| { - let (_, user_ema) = SubnetEmaTaoFlow::::get(*netuid) - .expect("user EMA should be initialized by get_subnet_block_emissions"); - let (_, protocol_ema) = SubnetEmaProtocolFlow::::get(*netuid) - .expect("protocol EMA should be initialized by get_subnet_block_emissions"); - - (*netuid, user_ema.to_num::(), protocol_ema.to_num::()) - }) - .collect::>(); - - let positive_user_ema_count = ema_rows - .iter() - .filter(|(_, user_ema, _)| *user_ema > 0.0) - .count(); - let dynamic_eligibility_floor = positive_user_ema_count / 2; - - let sum_positive_user_ema: f64 = ema_rows - .iter() - .map(|(_, user_ema, _)| (*user_ema).max(0.0)) - .sum(); - let sum_positive_protocol_ema: f64 = ema_rows - .iter() - .map(|(_, _, protocol_ema)| (*protocol_ema).max(0.0)) - .sum(); - let protocol_norm_factor = if sum_positive_protocol_ema > 0.0 { - (sum_positive_user_ema / sum_positive_protocol_ema).min(1.0) - } else { - 0.0 - }; - - let unnormalized_eligible = ema_rows - .iter() - .filter(|(_, user_ema, protocol_ema)| *user_ema > *protocol_ema) - .count(); - let expected_normalized_eligible = ema_rows - .iter() - .filter(|(_, user_ema, protocol_ema)| { - let scaled_protocol_ema = if *protocol_ema > 0.0 { - protocol_norm_factor * *protocol_ema - } else { - *protocol_ema - }; - *user_ema > scaled_protocol_ema - }) - .count(); - let actual_eligible = emissions - .values() - .filter(|emission| emission.to_num::() > 0.0) - .count(); - let total_emission: f64 = emissions - .values() - .map(|emission| emission.to_num::()) - .sum(); - - assert_abs_diff_eq!(total_emission, 1_000_000_000.0_f64, epsilon = 1.0); - assert!( - unnormalized_eligible < dynamic_eligibility_floor, - "test setup should reproduce the old unnormalized collapse: unnormalized_eligible={unnormalized_eligible}, dynamic_eligibility_floor={dynamic_eligibility_floor}" - ); - assert!( - expected_normalized_eligible >= dynamic_eligibility_floor, - "test setup should keep enough subnets eligible after protocol normalization: expected_normalized_eligible={expected_normalized_eligible}, dynamic_eligibility_floor={dynamic_eligibility_floor}" - ); - assert_eq!( - actual_eligible, expected_normalized_eligible, - "eligible subnet count should be derived from the normalized protocol-cost calculation" - ); +/// A partial burn reallocates emission away from the burning subnet and toward the +/// non-burning one, while shares still sum to 1. +#[test] +fn get_shares_partial_burn_reallocates_away_from_burner() { + new_test_ext(1).execute_with(|| { + let n1 = NetUid::from(1); + let n2 = NetUid::from(2); + // Equal prices so the price side is neutral; n1 burns 50% of its miner emission. + set_price_and_burn(n1, 1.0, 0.5); + set_price_and_burn(n2, 1.0, 0.0); + + // weighted: n1 = 0.5 * (1 - 0.5) = 0.25, n2 = 0.5 * 1 = 0.5; total = 0.75 + let shares = SubtensorModule::get_shares(&[n1, n2]); + let s1 = shares.get(&n1).unwrap().to_num::(); + let s2 = shares.get(&n2).unwrap().to_num::(); + + assert_abs_diff_eq!(s1, 1.0 / 3.0, epsilon = 1e-9); + assert_abs_diff_eq!(s2, 2.0 / 3.0, epsilon = 1e-9); + assert_abs_diff_eq!(s1 + s2, 1.0, epsilon = 1e-9); assert!( - actual_eligible >= dynamic_eligibility_floor, - "eligible subnet count collapsed below the dynamic floor: actual_eligible={actual_eligible}, dynamic_eligibility_floor={dynamic_eligibility_floor}, unnormalized_eligible={unnormalized_eligible}" + s2 > s1, + "non-burning subnet should receive more: s1={s1}, s2={s2}" ); + }); +} + +/// A subnet burning 100% of its miner emission receives zero chain emission; the rest +/// goes entirely to the non-burning subnet. +#[test] +fn get_shares_full_burn_gets_zero_emission() { + new_test_ext(1).execute_with(|| { + let n1 = NetUid::from(1); + let n2 = NetUid::from(2); + set_price_and_burn(n1, 1.0, 1.0); + set_price_and_burn(n2, 1.0, 0.0); + + let shares = SubtensorModule::get_shares(&[n1, n2]); + let s1 = shares.get(&n1).unwrap().to_num::(); + let s2 = shares.get(&n2).unwrap().to_num::(); + + assert_abs_diff_eq!(s1, 0.0, epsilon = 1e-9); + assert_abs_diff_eq!(s2, 1.0, epsilon = 1e-9); + }); +} + +/// When every subnet burns all of its miner emission, the reweighting would zero the +/// total, so `get_shares` falls back to unweighted price shares (emission is not +/// stranded). +#[test] +fn get_shares_all_full_burn_falls_back_to_price_shares() { + new_test_ext(1).execute_with(|| { + let n1 = NetUid::from(1); + let n2 = NetUid::from(2); + set_price_and_burn(n1, 1.0, 1.0); + set_price_and_burn(n2, 3.0, 1.0); + + let shares = SubtensorModule::get_shares(&[n1, n2]); + let s1 = shares.get(&n1).unwrap().to_num::(); + let s2 = shares.get(&n2).unwrap().to_num::(); + + // Fallback: price-proportional (1:3), not zeroed. + assert_abs_diff_eq!(s1, 1.0 / 4.0, epsilon = 1e-9); + assert_abs_diff_eq!(s2, 3.0 / 4.0, epsilon = 1e-9); + assert_abs_diff_eq!(s1 + s2, 1.0, epsilon = 1e-9); + }); +} + +/// With equal price and no burn, the root_proportion factor reallocates emission toward +/// the newer subnet (lower alpha issuance => higher root_proportion) and away from the +/// older one (higher alpha issuance => lower root_proportion). +#[test] +fn get_shares_root_proportion_favors_newer_subnets() { + new_test_ext(1).execute_with(|| { + let n1 = NetUid::from(1); + let n2 = NetUid::from(2); + // Equal price, no burn; root proportion factor is the only differentiator. + set_price_and_burn(n1, 1.0, 0.0); + set_price_and_burn(n2, 1.0, 0.0); + + // tao_weight = 1.0 (u64::MAX), so tao_weight term = root_tao. Set root_tao = 1000 + // and per-subnet alpha issuance to make root_proportion deterministic: + // n1: issuance 1000 => root_prop = 1000 / (1000 + 1000) = 0.5 + // n2: issuance 3000 => root_prop = 1000 / (1000 + 3000) = 0.25 + SubnetTAO::::insert(NetUid::ROOT, TaoBalance::from(1_000_u64)); + SubnetAlphaOut::::insert(n1, AlphaBalance::from(1_000_u64)); + SubnetAlphaOut::::insert(n2, AlphaBalance::from(3_000_u64)); + + // weighted: n1 = 0.5(price) * 0.5(root) = 0.25, n2 = 0.5 * 0.25 = 0.125; total 0.375 + let shares = SubtensorModule::get_shares(&[n1, n2]); + let s1 = shares.get(&n1).unwrap().to_num::(); + let s2 = shares.get(&n2).unwrap().to_num::(); + + assert_abs_diff_eq!(s1, 2.0 / 3.0, epsilon = 1e-6); + assert_abs_diff_eq!(s2, 1.0 / 3.0, epsilon = 1e-6); + assert_abs_diff_eq!(s1 + s2, 1.0, epsilon = 1e-9); assert!( - actual_eligible > unnormalized_eligible, - "normalization should preserve more eligible subnets than the old unnormalized path: actual_eligible={actual_eligible}, unnormalized_eligible={unnormalized_eligible}" + s1 > s2, + "newer subnet (higher root_prop) should get more: s1={s1}, s2={s2}" ); }); } diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 6d536dadaa..4876a830a0 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -1374,6 +1374,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::StakingHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Lock` (r:1 w:0) /// Proof: `SubtensorModule::Lock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AccountFlags` (r:1 w:0) + /// Proof: `SubtensorModule::AccountFlags` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:0) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `Swap::SwapV3Initialized` (r:1 w:0) @@ -1388,7 +1390,7 @@ impl WeightInfo for SubstrateWeight { // Estimated: `7994` // Minimum execution time: 254_636_000 picoseconds. Weight::from_parts(258_541_000, 7994) - .saturating_add(T::DbWeight::get().reads(18_u64)) + .saturating_add(T::DbWeight::get().reads(19_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } /// Storage: `SubtensorModule::Alpha` (r:2 w:0) @@ -3754,6 +3756,8 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::StakingHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Lock` (r:1 w:0) /// Proof: `SubtensorModule::Lock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::AccountFlags` (r:1 w:0) + /// Proof: `SubtensorModule::AccountFlags` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:0) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `Swap::SwapV3Initialized` (r:1 w:0) @@ -3768,7 +3772,7 @@ impl WeightInfo for () { // Estimated: `7994` // Minimum execution time: 254_636_000 picoseconds. Weight::from_parts(258_541_000, 7994) - .saturating_add(RocksDbWeight::get().reads(18_u64)) + .saturating_add(RocksDbWeight::get().reads(19_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } /// Storage: `SubtensorModule::Alpha` (r:2 w:0) diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 32d629a761..935a364ad3 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -277,7 +277,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 419, + spec_version: 421, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1,