From 061559c23cd391e3db1be0cf19e4c441c04692f6 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Mon, 22 Jun 2026 22:02:35 +0300 Subject: [PATCH 1/6] Trim account flags PR to main changes --- .../subtensor/src/coinbase/run_coinbase.rs | 14 +- .../src/coinbase/subnet_emissions.rs | 12 +- pallets/subtensor/src/lib.rs | 8 + pallets/subtensor/src/macros/dispatches.rs | 25 +++ pallets/subtensor/src/macros/errors.rs | 2 + pallets/subtensor/src/macros/events.rs | 8 + pallets/subtensor/src/staking/lock.rs | 55 +++++- pallets/subtensor/src/tests/claim_root.rs | 14 +- pallets/subtensor/src/tests/coinbase.rs | 182 +++++++++-------- pallets/subtensor/src/tests/locks.rs | 185 ++++++++++++++++++ .../subtensor/src/tests/subnet_emissions.rs | 120 ------------ pallets/subtensor/src/weights.rs | 8 +- 12 files changed, 403 insertions(+), 230 deletions(-) diff --git a/pallets/subtensor/src/coinbase/run_coinbase.rs b/pallets/subtensor/src/coinbase/run_coinbase.rs index a6c988feea..e372d70407 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); diff --git a/pallets/subtensor/src/coinbase/subnet_emissions.rs b/pallets/subtensor/src/coinbase/subnet_emissions.rs index 63dbf36e5a..7599d27a62 100644 --- a/pallets/subtensor/src/coinbase/subnet_emissions.rs +++ b/pallets/subtensor/src/coinbase/subnet_emissions.rs @@ -329,14 +329,16 @@ 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) + Self::get_shares_price_ema(subnets_to_emit_to) } - // 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..4058ff983f 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 = 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..ae27614f14 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,32 +1354,51 @@ 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 + for (netuid, decaying) in decaying_locks_to_transfer.iter() { + DecayingLock::::insert(new_coldkey, *netuid, *decaying); + } + + 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 locks for old coldkey and insert for new + 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), + perpetual_lock, ); Lock::::remove((old_coldkey.clone(), netuid, hotkey.clone())); Self::reduce_aggregate_lock( @@ -1780,6 +1822,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..78573eac3b 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)); @@ -2888,6 +3030,49 @@ 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(), + }, + ); + 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() + ); + }); +} + #[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/subnet_emissions.rs b/pallets/subtensor/src/tests/subnet_emissions.rs index 060171d5c7..4bb1aa4c75 100644 --- a/pallets/subtensor/src/tests/subnet_emissions.rs +++ b/pallets/subtensor/src/tests/subnet_emissions.rs @@ -151,126 +151,6 @@ fn inplace_pow_normalize_fractional_exponent() { }) } -#[allow(clippy::expect_used)] -#[test] -fn protocol_normalization_keeps_eligible_subnet_count_from_collapsing() { - 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 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" - ); - 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}" - ); - assert!( - actual_eligible > unnormalized_eligible, - "normalization should preserve more eligible subnets than the old unnormalized path: actual_eligible={actual_eligible}, unnormalized_eligible={unnormalized_eligible}" - ); - }); -} - // /// 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 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) From 5c1b38081b5fba15adb43da283a54aef30ba2a00 Mon Sep 17 00:00:00 2001 From: unconst Date: Mon, 22 Jun 2026 14:02:16 -0600 Subject: [PATCH 2/6] Scale chain emission away from subnets burning miner emission - Add a per-subnet MinerBurned storage holding the proportion (0..1) of each tempo's miner (incentive) emission that was burned during emission distribution because the recipient hotkey is owned by the subnet owner. - Weight price-based emission shares by (1 - miner_burned) and renormalize, so subnets that burn more of their miner emission receive proportionally less chain emission (reallocated toward non-burning subnets). Co-authored-by: Cursor --- .../subtensor/src/coinbase/run_coinbase.rs | 11 +++++++ .../src/coinbase/subnet_emissions.rs | 32 ++++++++++++++++++- pallets/subtensor/src/lib.rs | 12 +++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/pallets/subtensor/src/coinbase/run_coinbase.rs b/pallets/subtensor/src/coinbase/run_coinbase.rs index e372d70407..1d075a313c 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -613,8 +613,12 @@ 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 vs burned miner emission this tempo to record the burned proportion. + let mut total_incentive: AlphaBalance = AlphaBalance::ZERO; + let mut burned_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) { @@ -630,6 +634,7 @@ impl Pallet { Ok(RecycleOrBurnEnum::Burn) | Err(_) => { log::debug!("burning {incentive:?}"); Self::burn_subnet_alpha(netuid, incentive); + burned_incentive = burned_incentive.saturating_add(incentive); } } continue; @@ -660,6 +665,12 @@ impl Pallet { ); } + // Record the proportion of this tempo's miner emission that was burned. + let burned_proportion: U96F32 = U96F32::saturating_from_num(burned_incentive.to_u64()) + .checked_div(U96F32::saturating_from_num(total_incentive.to_u64())) + .unwrap_or_else(|| U96F32::saturating_from_num(0)); + MinerBurned::::insert(netuid, burned_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 7599d27a62..d59a8d1a9d 100644 --- a/pallets/subtensor/src/coinbase/subnet_emissions.rs +++ b/pallets/subtensor/src/coinbase/subnet_emissions.rs @@ -334,7 +334,37 @@ 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); + + // Reallocate emission away from subnets that burn miner emission: weight each + // subnet's price share by (1 - miner_burned_proportion), then renormalize so the + // block's total emission is preserved and redistributed toward subnets that are + // not burning their 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); + (*netuid, share.saturating_mul(one.saturating_sub(burned))) + }) + .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 { + // Every eligible subnet is 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 4058ff983f..dddfa8fd09 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1883,6 +1883,18 @@ 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 burned during emission distribution because the + /// recipient hotkey is owned by the subnet owner (immune key). + #[pallet::storage] + pub type MinerBurned = + StorageMap<_, Identity, NetUid, U96F32, ValueQuery, DefaultMinerBurned>; + /// --- MAP ( netuid ) --> blocks_since_last_step #[pallet::storage] pub type BlocksSinceLastStep = From a068db561fdd1293b19b0a78308a3ce551557bff Mon Sep 17 00:00:00 2001 From: unconst Date: Mon, 22 Jun 2026 14:13:55 -0600 Subject: [PATCH 3/6] Address review of miner-burn emission scaling - Count miner emission withheld via an owner/immune hotkey toward the burned proportion whether it is recycled or burned, so the emission penalty is independent of a subnet's RecycleOrBurn config (no Recycle bypass, no unique penalty for the unset default). - Clear MinerBurned on subnet removal so a deregistered subnet leaves no stale proportion; extend dissolve cleanup test to cover it. - Add active tests for the price-share reweight by (1 - miner_burned): no-burn, partial burn, full burn, and all-full-burn fallback to price shares. Co-authored-by: Cursor --- pallets/subtensor/src/coinbase/root.rs | 1 + .../subtensor/src/coinbase/run_coinbase.rs | 18 ++-- pallets/subtensor/src/lib.rs | 6 +- pallets/subtensor/src/tests/networks.rs | 2 + .../subtensor/src/tests/subnet_emissions.rs | 99 +++++++++++++++++++ 5 files changed, 118 insertions(+), 8 deletions(-) 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 1d075a313c..60f6253643 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -613,9 +613,10 @@ 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 vs burned miner emission this tempo to record the burned proportion. + // 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 burned_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); @@ -625,6 +626,11 @@ impl Pallet { 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) => { @@ -634,7 +640,6 @@ impl Pallet { Ok(RecycleOrBurnEnum::Burn) | Err(_) => { log::debug!("burning {incentive:?}"); Self::burn_subnet_alpha(netuid, incentive); - burned_incentive = burned_incentive.saturating_add(incentive); } } continue; @@ -665,11 +670,12 @@ impl Pallet { ); } - // Record the proportion of this tempo's miner emission that was burned. - let burned_proportion: U96F32 = U96F32::saturating_from_num(burned_incentive.to_u64()) + // 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, burned_proportion); + MinerBurned::::insert(netuid, withheld_proportion); // Distribute alpha divs. let _ = AlphaDividendsPerSubnet::::clear_prefix(netuid, u32::MAX, None); diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index dddfa8fd09..828b8e6fb8 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1889,8 +1889,10 @@ pub mod pallet { U96F32::saturating_from_num(0.0) } /// --- MAP ( netuid ) --> miner_burned | Proportion (0..1) of this tempo's miner - /// (incentive) emission that was burned during emission distribution because the - /// recipient hotkey is owned by the subnet owner (immune key). + /// (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>; 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 4bb1aa4c75..b18a1163c1 100644 --- a/pallets/subtensor/src/tests/subnet_emissions.rs +++ b/pallets/subtensor/src/tests/subnet_emissions.rs @@ -151,6 +151,105 @@ fn inplace_pow_normalize_fractional_exponent() { }) } +/// Configure a dynamic subnet with a given EMA price and miner-burned proportion so +/// `get_shares` (price-based, reweighted by `1 - miner_burned`) can be exercised. +fn set_price_and_burn(netuid: NetUid, price: f64, burned: f64) { + 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); + }); +} + // /// Normal (moderate, non-zero) EMA flows across 3 subnets. // /// Expect: shares sum to ~1 and are monotonic with flows. // #[test] From eaadb745a0c007b8fc1db534d2318dca0452da53 Mon Sep 17 00:00:00 2001 From: unconst Date: Mon, 22 Jun 2026 14:46:03 -0600 Subject: [PATCH 4/6] Weight emission shares by root_proportion (favor newer subnets) Emission share is now proportional to root_proportion * price * (1 - miner_burned), renormalized. Multiplying by root_proportion (which shrinks as a subnet's alpha issuance grows) reallocates chain emission away from older subnets toward newer ones, easing entrance for new subnets. Falls back to unweighted price shares when the combined weight is zero (e.g. no root stake). Adds get_shares tests: no-burn, partial/full burn, all-full-burn fallback, and root_proportion favoring newer subnets. Co-authored-by: Cursor --- .../src/coinbase/subnet_emissions.rs | 19 +++++--- .../subtensor/src/tests/subnet_emissions.rs | 46 ++++++++++++++++++- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/pallets/subtensor/src/coinbase/subnet_emissions.rs b/pallets/subtensor/src/coinbase/subnet_emissions.rs index d59a8d1a9d..d7b35166d2 100644 --- a/pallets/subtensor/src/coinbase/subnet_emissions.rs +++ b/pallets/subtensor/src/coinbase/subnet_emissions.rs @@ -336,17 +336,21 @@ impl Pallet { pub(crate) fn get_shares(subnets_to_emit_to: &[NetUid]) -> BTreeMap { let price_shares = Self::get_shares_price_ema(subnets_to_emit_to); - // Reallocate emission away from subnets that burn miner emission: weight each - // subnet's price share by (1 - miner_burned_proportion), then renormalize so the - // block's total emission is preserved and redistributed toward subnets that are - // not burning their miner emission. + // 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); - (*netuid, share.saturating_mul(one.saturating_sub(burned))) + 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(); @@ -361,8 +365,9 @@ impl Pallet { .map(|(netuid, w)| (netuid, w.safe_div(total_weight))) .collect() } else { - // Every eligible subnet is burning all of its miner emission; fall back to - // the unweighted price shares so the block's emission is not stranded. + // 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 } } diff --git a/pallets/subtensor/src/tests/subnet_emissions.rs b/pallets/subtensor/src/tests/subnet_emissions.rs index b18a1163c1..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) @@ -152,8 +152,15 @@ fn inplace_pow_normalize_fractional_exponent() { } /// Configure a dynamic subnet with a given EMA price and miner-burned proportion so -/// `get_shares` (price-based, reweighted by `1 - miner_burned`) can be exercised. +/// `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)); @@ -250,6 +257,41 @@ fn get_shares_all_full_burn_falls_back_to_price_shares() { }); } +/// 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] From 38c9e763f7c50dfbdc645995e783c9b744e75289 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Mon, 22 Jun 2026 23:55:36 +0300 Subject: [PATCH 5/6] address ai review --- pallets/subtensor/src/staking/lock.rs | 34 +++++++++++++++++---------- pallets/subtensor/src/tests/locks.rs | 6 +++++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/pallets/subtensor/src/staking/lock.rs b/pallets/subtensor/src/staking/lock.rs index ae27614f14..3e2f3c54e3 100644 --- a/pallets/subtensor/src/staking/lock.rs +++ b/pallets/subtensor/src/staking/lock.rs @@ -1366,10 +1366,6 @@ impl Pallet { locks_to_transfer.push((netuid, hotkey, lock)); } - for (netuid, decaying) in decaying_locks_to_transfer.iter() { - DecayingLock::::insert(new_coldkey, *netuid, *decaying); - } - let mut rolled_locks_to_transfer: Vec<(NetUid, T::AccountId, LockState, bool)> = Vec::new(); for (netuid, hotkey, lock) in locks_to_transfer { let perpetual_lock = decaying_locks_to_transfer @@ -1390,7 +1386,27 @@ impl Pallet { rolled_locks_to_transfer.push((netuid, hotkey, old_lock, perpetual_lock)); } - // Remove locks for old coldkey and insert for new + // 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(), @@ -1400,14 +1416,6 @@ impl Pallet { Self::is_subnet_owner_hotkey(netuid, &hotkey), perpetual_lock, ); - Lock::::remove((old_coldkey.clone(), netuid, hotkey.clone())); - Self::reduce_aggregate_lock( - old_coldkey, - &hotkey, - netuid, - old_lock.locked_mass, - old_lock.conviction, - ); Self::insert_lock_state(new_coldkey, netuid, &hotkey, new_lock.clone()); Self::add_aggregate_lock(new_coldkey, &hotkey, netuid, new_lock); } diff --git a/pallets/subtensor/src/tests/locks.rs b/pallets/subtensor/src/tests/locks.rs index 78573eac3b..fc3e50f020 100644 --- a/pallets/subtensor/src/tests/locks.rs +++ b/pallets/subtensor/src/tests/locks.rs @@ -2940,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, @@ -2968,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)); }); } @@ -3051,6 +3054,7 @@ fn test_coldkey_swap_rejects_locked_alpha_to_flagged_destination() { 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, @@ -3070,6 +3074,8 @@ fn test_coldkey_swap_rejects_locked_alpha_to_flagged_destination() { .next() .is_none() ); + assert_eq!(DecayingLock::::get(old_coldkey, netuid), Some(false)); + assert!(DecayingLock::::get(new_coldkey, netuid).is_none()); }); } From dc2d06c5054e1d3ef107bf8b5731d9f8e517f71b Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Tue, 23 Jun 2026 00:10:30 +0300 Subject: [PATCH 6/6] spec bump --- runtime/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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,