diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index d339e0ec83..b4db388621 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -323,6 +323,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 6b2ea6de0b..d7c75964b2 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -669,14 +669,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) => { @@ -716,6 +726,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 caab671ed4..f378e3e930 100644 --- a/pallets/subtensor/src/coinbase/subnet_emissions.rs +++ b/pallets/subtensor/src/coinbase/subnet_emissions.rs @@ -352,7 +352,42 @@ impl Pallet { // 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_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 + } } // Implementation of shares that uses subnet EMA prices (SubnetMovingPrice), diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 39fe375e6b..b1afef3401 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -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)] @@ -1190,6 +1193,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 = @@ -1926,6 +1934,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 2a22915ee9..08e5bb8fdf 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2625,5 +2625,31 @@ mod dispatches { pub fn trigger_epoch(origin: OriginFor, 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(( + ::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 32e0fbcbd6..6401b5846d 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -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, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index adcd97e0cf..7bc9bf450a 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -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, + }, } } diff --git a/pallets/subtensor/src/staking/lock.rs b/pallets/subtensor/src/staking/lock.rs index 78e2734c5b..05746c753d 100644 --- a/pallets/subtensor/src/staking/lock.rs +++ b/pallets/subtensor/src/staking/lock.rs @@ -467,6 +467,29 @@ impl Pallet { LockingColdkeys::::remove((netuid, hotkey, coldkey)); } + 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, @@ -1359,6 +1382,10 @@ 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(); @@ -1367,15 +1394,8 @@ impl Pallet { locks_to_transfer.push((netuid, hotkey, lock)); } - for (netuid, decaying) in decaying_locks_to_transfer.iter() { - DecayingLock::::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::::get(); - let maturity_rate = MaturityRate::::get(); let perpetual_lock = decaying_locks_to_transfer .iter() .any(|(decaying_netuid, decaying)| *decaying_netuid == netuid && !*decaying); @@ -1387,8 +1407,38 @@ impl Pallet { Self::is_subnet_owner_hotkey(netuid, &hotkey), perpetual_lock, ); + Self::ensure_can_receive_locked_alpha_with_flag( + new_coldkey_rejects_locked_alpha, + old_lock.0.locked_mass, + )?; + rolled_locks_to_transfer.push((netuid, hotkey, old_lock.0, 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::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::::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.0.clone(), + old_lock.clone(), now, unlock_rate, maturity_rate, @@ -1396,23 +1446,10 @@ impl Pallet { perpetual_lock, ) .0; - Lock::::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::::remove(old_coldkey, netuid); - } - Ok(()) } @@ -1838,6 +1875,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/coinbase.rs b/pallets/subtensor/src/tests/coinbase.rs index 52a9981dfd..3ac421ac50 100644 --- a/pallets/subtensor/src/tests/coinbase.rs +++ b/pallets/subtensor/src/tests/coinbase.rs @@ -2894,6 +2894,46 @@ fn test_run_coinbase_not_started_start_after() { }); } +/// Test that coinbase updates protocol liquidity accounting. +/// cargo test --package pallet-subtensor --lib -- tests::coinbase::test_coinbase_v3_liquidity_update --exact --show-output +#[test] +fn test_coinbase_v3_liquidity_update() { + new_test_ext(1).execute_with(|| { + let owner_hotkey = U256::from(1); + let owner_coldkey = U256::from(2); + + // add network + let netuid = add_dynamic_network(&owner_hotkey, &owner_coldkey); + + // Force the swap to initialize + SubtensorModule::swap_tao_for_alpha( + netuid, + TaoBalance::ZERO, + 1_000_000_000_000_u64.into(), + false, + ) + .unwrap(); + + let tao_before = SubnetTAO::::get(netuid); + let alpha_in_before = SubnetAlphaIn::::get(netuid); + + // Enable emissions and run coinbase (which will adjust protocol liquidity) + let emission: u64 = 1_234_567; + let emission_credit = SubtensorModule::mint_tao(emission.into()); + // 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); + + assert!(!SubnetTaoInEmission::::get(netuid).is_zero()); + assert!(!SubnetAlphaInEmission::::get(netuid).is_zero()); + assert!(SubnetTAO::::get(netuid) > tao_before); + assert!(SubnetAlphaIn::::get(netuid) > alpha_in_before); + }); +} + // SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::coinbase::test_drain_alpha_childkey_parentkey_with_burn --exact --show-output --nocapture #[test] fn test_drain_alpha_childkey_parentkey_with_burn() { diff --git a/pallets/subtensor/src/tests/locks.rs b/pallets/subtensor/src/tests/locks.rs index 4eaf01668c..91b48b7881 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) } @@ -2152,6 +2187,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(); @@ -2247,6 +2286,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(|| { @@ -3284,6 +3418,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)); @@ -3318,6 +3456,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)); @@ -3362,6 +3504,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, @@ -3390,6 +3533,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)); }); } @@ -3452,6 +3597,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 4696507e2e..a967761ef5 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -406,6 +406,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); @@ -563,6 +564,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 4bb1aa4c75..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,6 +151,147 @@ fn inplace_pow_normalize_fractional_exponent() { }) } +/// 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 get_shares_no_burn_matches_price_shares() { + new_test_ext(1).execute_with(|| { + 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); + }); +} + +/// 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!( + 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!( + s1 > s2, + "newer subnet (higher root_prop) should get more: s1={s1}, s2={s2}" + ); + }); +} + // /// Normal (moderate, non-zero) EMA flows across 3 subnets. // /// Expect: shares sum to ~1 and are monotonic with flows. // #[test] diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 84cb42ae8c..4b5806aaf5 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -1314,6 +1314,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: `SubtensorModule::SubnetAlphaIn` (r:1 w:0) @@ -1326,11 +1328,11 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) fn transfer_stake() -> Weight { // Proof Size summary in bytes: - // Measured: `1988` - // Estimated: `7928` - // Minimum execution time: 108_000_000 picoseconds. - Weight::from_parts(109_000_000, 7928) - .saturating_add(T::DbWeight::get().reads(18_u64)) + // Measured: `2054` + // Estimated: `7994` + // Minimum execution time: 254_636_000 picoseconds. + Weight::from_parts(258_541_000, 7994) + .saturating_add(T::DbWeight::get().reads(19_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } /// Storage: `SubtensorModule::Alpha` (r:2 w:0) @@ -3800,6 +3802,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: `SubtensorModule::SubnetAlphaIn` (r:1 w:0) @@ -3812,11 +3816,11 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) fn transfer_stake() -> Weight { // Proof Size summary in bytes: - // Measured: `1988` - // Estimated: `7928` - // Minimum execution time: 108_000_000 picoseconds. - Weight::from_parts(109_000_000, 7928) - .saturating_add(RocksDbWeight::get().reads(18_u64)) + // Measured: `2054` + // Estimated: `7994` + // Minimum execution time: 254_636_000 picoseconds. + Weight::from_parts(258_541_000, 7994) + .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 8c532c306b..652b83475c 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -234,7 +234,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: 421, + spec_version: 422, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1,