diff --git a/node/src/cli.rs b/node/src/cli.rs index 834d03bd9a..dcf7eccdfa 100644 --- a/node/src/cli.rs +++ b/node/src/cli.rs @@ -136,6 +136,10 @@ pub struct CloneStateCmd { #[arg(long, value_name = "BOOTNODE")] pub bootnodes: Vec, + /// Optional reserved nodes for the sync step. Repeatable. + #[arg(long, value_name = "RESERVED_NODE")] + pub reserved_nodes: Vec, + /// Include Alice in patched validator authorities (default if no validator flags are passed; /// Sudo is assigned to the first selected validator in Alice->Bob->Charlie order). #[arg(long, default_value_t = false)] diff --git a/node/src/clone_spec.rs b/node/src/clone_spec.rs index dba70bf117..0059235fe0 100644 --- a/node/src/clone_spec.rs +++ b/node/src/clone_spec.rs @@ -110,6 +110,11 @@ async fn async_run(cmd: &CloneStateCmd, skip_history_backfill: bool) -> CloneRes sync_args.push(bootnode.clone()); } + for reserved_node in &cmd.reserved_nodes { + sync_args.push("--reserved-nodes".to_string()); + sync_args.push(reserved_node.clone()); + } + log::info!("build-patched-spec: starting sync node"); let mut sync_child = Command::new(¤t_exe) @@ -545,6 +550,7 @@ mod tests { sync_timeout_sec: 10, sync_lag_blocks: 8, bootnodes: Vec::new(), + reserved_nodes: Vec::new(), alice: false, bob: false, charlie: false, diff --git a/pallets/admin-utils/src/tests/mod.rs b/pallets/admin-utils/src/tests/mod.rs index 216bf6bb81..bfcb106eea 100644 --- a/pallets/admin-utils/src/tests/mod.rs +++ b/pallets/admin-utils/src/tests/mod.rs @@ -9,13 +9,13 @@ use frame_support::{ use frame_system::Config; use pallet_subtensor::{ Error as SubtensorError, Event, MaxRegistrationsPerBlock, SubnetOwner, - TargetRegistrationsPerInterval, Tempo, WeightsVersionKeyRateLimit, + TargetRegistrationsPerInterval, Tempo, WeightsVersionKeyRateLimit, staking::lock::LockState, subnets::mechanism::MAX_MECHANISM_COUNT_PER_SUBNET, utils::rate_limiting::TransactionType, *, }; use sp_consensus_grandpa::AuthorityId as GrandpaId; use sp_core::{Get, Pair, U256, ed25519}; use sp_runtime::PerU16; -use substrate_fixed::types::I96F32; +use substrate_fixed::types::{I96F32, U64F64}; use subtensor_runtime_common::{MechId, NetUid, TaoBalance, Token}; pub mod mock; use mock::*; @@ -2214,11 +2214,58 @@ fn test_set_sn_owner_hotkey_owner() { fn test_set_sn_owner_hotkey_root() { new_test_ext().execute_with(|| { let netuid = NetUid::from(1); + let old_hotkey = U256::from(2); let hotkey: U256 = U256::from(3); add_network(netuid, 10); let owner = U256::from(10); + let old_perpetual_coldkey = U256::from(11); + let old_decaying_coldkey = U256::from(12); + let new_perpetual_coldkey = U256::from(13); + let new_decaying_coldkey = U256::from(14); pallet_subtensor::SubnetOwner::::insert(netuid, owner); + pallet_subtensor::SubnetOwnerHotkey::::insert(netuid, old_hotkey); + let now = SubtensorModule::get_current_block_as_u64(); + let old_owner_lock = LockState { + locked_mass: 1_000u64.into(), + conviction: U64F64::from_num(1_000), + last_update: now, + }; + let new_owner_lock = LockState { + locked_mass: 2_000u64.into(), + conviction: U64F64::from_num(2_000), + last_update: now, + }; + pallet_subtensor::DecayingLock::::insert(old_perpetual_coldkey, netuid, false); + pallet_subtensor::DecayingLock::::insert(new_perpetual_coldkey, netuid, false); + SubtensorModule::insert_lock_state( + &old_perpetual_coldkey, + netuid, + &old_hotkey, + old_owner_lock.clone(), + ); + SubtensorModule::insert_lock_state( + &old_decaying_coldkey, + netuid, + &old_hotkey, + old_owner_lock.clone(), + ); + SubtensorModule::insert_lock_state( + &new_perpetual_coldkey, + netuid, + &hotkey, + new_owner_lock.clone(), + ); + SubtensorModule::insert_lock_state( + &new_decaying_coldkey, + netuid, + &hotkey, + new_owner_lock.clone(), + ); + SubtensorModule::insert_owner_lock_state(netuid, old_owner_lock.clone()); + SubtensorModule::insert_decaying_owner_lock_state(netuid, old_owner_lock); + SubtensorModule::insert_hotkey_lock_state(netuid, &hotkey, new_owner_lock.clone()); + SubtensorModule::insert_decaying_hotkey_lock_state(netuid, &hotkey, new_owner_lock); // Root can set the hotkey assert_ok!(AdminUtils::sudo_set_sn_owner_hotkey( @@ -2230,6 +2277,24 @@ fn test_set_sn_owner_hotkey_root() { // Check the value let actual_hotkey = pallet_subtensor::SubnetOwnerHotkey::::get(netuid); assert_eq!(actual_hotkey, hotkey); + assert_eq!( + pallet_subtensor::HotkeyLock::::get(netuid, old_hotkey) + .map(|lock| lock.locked_mass), + Some(1_000u64.into()) + ); + assert_eq!( + pallet_subtensor::DecayingHotkeyLock::::get(netuid, old_hotkey) + .map(|lock| lock.locked_mass), + Some(1_000u64.into()) + ); + assert_eq!( + pallet_subtensor::OwnerLock::::get(netuid).map(|lock| lock.locked_mass), + Some(2_000u64.into()) + ); + assert_eq!( + pallet_subtensor::DecayingOwnerLock::::get(netuid).map(|lock| lock.locked_mass), + Some(2_000u64.into()) + ); }); } diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 21083cee07..3259c71c03 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -227,7 +227,9 @@ mod hooks { .saturating_add(migrations::migrate_storage_bloat_v2::kickoff_storage_bloat_cleanup::()) // Schedule stale StakingHotkeys relationship cleanup. It runs after storage GC // and uses only otherwise-unused on_idle weight; normal operations stay enabled. - .saturating_add(migrations::migrate_cleanup_staking_hotkeys::kickoff_staking_hotkeys_cleanup::()); + .saturating_add(migrations::migrate_cleanup_staking_hotkeys::kickoff_staking_hotkeys_cleanup::()) + // Rebuild the corrupted conviction lock aggregates + .saturating_add(migrations::migrate_rebuild_conviction_aggregates::migrate_rebuild_conviction_aggregates::()); // The beta-baseline seed (`migrate_stamp_beta_baselines`) runs from the // runtime `Migrations` tuple instead of this hook, so try-runtime validates // its pre/post-upgrade invariants against real network state. diff --git a/pallets/subtensor/src/migrations/migrate_fix_subnet_hotkey_lock_swaps.rs b/pallets/subtensor/src/migrations/migrate_fix_subnet_hotkey_lock_swaps.rs index 93af663092..5c2abbe799 100644 --- a/pallets/subtensor/src/migrations/migrate_fix_subnet_hotkey_lock_swaps.rs +++ b/pallets/subtensor/src/migrations/migrate_fix_subnet_hotkey_lock_swaps.rs @@ -140,13 +140,6 @@ fn is_non_zero_lock(lock: &LockState) -> bool { !lock.locked_mass.is_zero() || lock.conviction > U64F64::saturating_from_num(0) } -fn add_lock_state(mut lhs: LockState, rhs: &LockState) -> LockState { - lhs.locked_mass = lhs.locked_mass.saturating_add(rhs.locked_mass); - lhs.conviction = lhs.conviction.saturating_add(rhs.conviction); - lhs.last_update = lhs.last_update.max(rhs.last_update); - lhs -} - fn subtract_lock_state(mut lhs: LockState, rhs: &LockState) -> LockState { lhs.locked_mass = lhs.locked_mass.saturating_sub(rhs.locked_mass); lhs.conviction = lhs.conviction.saturating_sub(rhs.conviction); @@ -212,25 +205,25 @@ fn add_to_aggregate( match (owner, perpetual) { (true, true) => OwnerLock::::mutate(netuid, |maybe_lock| { *maybe_lock = Some(match maybe_lock.take() { - Some(lock) => add_lock_state(lock, added), + Some(lock) => lock.add(added), None => added.clone(), }); }), (true, false) => DecayingOwnerLock::::mutate(netuid, |maybe_lock| { *maybe_lock = Some(match maybe_lock.take() { - Some(lock) => add_lock_state(lock, added), + Some(lock) => lock.add(added), None => added.clone(), }); }), (false, true) => HotkeyLock::::mutate(netuid, hotkey, |maybe_lock| { *maybe_lock = Some(match maybe_lock.take() { - Some(lock) => add_lock_state(lock, added), + Some(lock) => lock.add(added), None => added.clone(), }); }), (false, false) => DecayingHotkeyLock::::mutate(netuid, hotkey, |maybe_lock| { *maybe_lock = Some(match maybe_lock.take() { - Some(lock) => add_lock_state(lock, added), + Some(lock) => lock.add(added), None => added.clone(), }); }), diff --git a/pallets/subtensor/src/migrations/migrate_populate_locking_coldkeys.rs b/pallets/subtensor/src/migrations/migrate_populate_locking_coldkeys.rs index c1220c2077..d544fe0c3c 100644 --- a/pallets/subtensor/src/migrations/migrate_populate_locking_coldkeys.rs +++ b/pallets/subtensor/src/migrations/migrate_populate_locking_coldkeys.rs @@ -38,7 +38,7 @@ pub fn migrate_populate_locking_coldkeys() -> Weight { Subtensor::::read_conviction_model_for_hotkey(&coldkey, netuid, &hotkey, now); model.roll_forward(now, unlock_rate, maturity_rate); - if model.individual_lock().is_zero() { + if model.individual_lock().is_dust() { removed_count = removed_count.saturating_add(1); } else { indexed_count = indexed_count.saturating_add(1); diff --git a/pallets/subtensor/src/migrations/migrate_rebuild_conviction_aggregates.rs b/pallets/subtensor/src/migrations/migrate_rebuild_conviction_aggregates.rs new file mode 100644 index 0000000000..9bbd3dfc09 --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_rebuild_conviction_aggregates.rs @@ -0,0 +1,186 @@ +use alloc::{collections::BTreeMap, string::String, vec::Vec}; +use frame_support::{traits::Get, weights::Weight}; + +use crate::{ + Config, DecayingHotkeyLock, DecayingLock, DecayingOwnerLock, HasMigrationRun, HotkeyLock, Lock, + LockingColdkeys, MaturityRate, OwnerLock, Pallet as Subtensor, SubnetOwnerHotkey, UnlockRate, + staking::lock::{LockState, roll_lock_state}, +}; +use subtensor_runtime_common::NetUid; + +const MIGRATION_NAME: &[u8] = b"migrate_rebuild_conviction_aggregates"; + +// Mainnet archive scan at block 8_793_919 on 2026-08-07: +// - 352 Lock rows and 352 matching LockingColdkeys rows +// - 193 aggregate rows across the four aggregate maps +// - 125 subnets with locks, with at most 44 Lock rows on any one subnet +// +// This is intentionally a one-shot runtime-upgrade scan of small existing +// state, not an operation placed on a recurring block path. Keep these +// measurements next to the migration so its practical bound and review +// rationale are not lost. +const OBSERVED_MAINNET_LOCK_ROWS: u64 = 352; +const OBSERVED_MAINNET_AGGREGATE_ROWS: u64 = 193; +const OBSERVED_MAINNET_MAX_LOCKS_PER_SUBNET: u64 = 44; +const OBSERVED_MAINNET_BLOCK: u64 = 8_793_919; + +fn merge_into(aggregates: &mut BTreeMap, key: K, lock: &LockState) { + if let Some(aggregate) = aggregates.get_mut(&key) { + *aggregate = aggregate.add(lock); + } else { + aggregates.insert(key, lock.clone()); + } +} + +/// Rebuilds conviction aggregates from canonical individual lock rows. +/// +/// Runtime v443 could advance an aggregate timestamp after applying only one +/// member's roll delta. Once that happened, the aggregate was no longer the +/// sum of its members at its advertised timestamp. There is no safe way to +/// repair such a bucket incrementally, so this migration ignores every stored +/// aggregate and reconstructs all four maps from `Lock`. +/// +/// Each individual is first rolled to the runtime-upgrade block using its +/// current lock mode and owner role. The rolled row is persisted (or removed +/// if it has become dust), then merged into its appropriate new aggregate. +/// This preserves earned conviction while establishing one common timestamp +/// for every individual and aggregate contribution. +pub fn migrate_rebuild_conviction_aggregates() -> Weight { + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(MIGRATION_NAME) { + log::info!( + "Migration '{}' already executed - skipping", + String::from_utf8_lossy(MIGRATION_NAME) + ); + return weight; + } + + log::info!( + "Running migration '{}'. Mainnet scan at block {} observed {} individual locks, \ + {} aggregate rows, and at most {} locks on one subnet", + String::from_utf8_lossy(MIGRATION_NAME), + OBSERVED_MAINNET_BLOCK, + OBSERVED_MAINNET_LOCK_ROWS, + OBSERVED_MAINNET_AGGREGATE_ROWS, + OBSERVED_MAINNET_MAX_LOCKS_PER_SUBNET, + ); + + let now = Subtensor::::get_current_block_as_u64(); + let unlock_rate = UnlockRate::::get(); + let maturity_rate = MaturityRate::::get(); + weight = weight.saturating_add(T::DbWeight::get().reads(3)); + + // Collect before rewriting Lock so mutation cannot disturb the iterator. + let locks: Vec<_> = Lock::::iter().collect(); + let scanned_count = locks.len() as u64; + weight = weight.saturating_add(T::DbWeight::get().reads(scanned_count)); + + let locking_coldkeys_removal = LockingColdkeys::::clear(u32::MAX, None); + weight = weight.saturating_add(T::DbWeight::get().reads_writes( + locking_coldkeys_removal.loops as u64, + locking_coldkeys_removal.backend as u64, + )); + + let hotkey_removal = HotkeyLock::::clear(u32::MAX, None); + weight = weight.saturating_add( + T::DbWeight::get().reads_writes(hotkey_removal.loops as u64, hotkey_removal.backend as u64), + ); + + let decaying_hotkey_removal = DecayingHotkeyLock::::clear(u32::MAX, None); + weight = weight.saturating_add(T::DbWeight::get().reads_writes( + decaying_hotkey_removal.loops as u64, + decaying_hotkey_removal.backend as u64, + )); + + let owner_removal = OwnerLock::::clear(u32::MAX, None); + weight = weight.saturating_add( + T::DbWeight::get().reads_writes(owner_removal.loops as u64, owner_removal.backend as u64), + ); + + let decaying_owner_removal = DecayingOwnerLock::::clear(u32::MAX, None); + weight = weight.saturating_add(T::DbWeight::get().reads_writes( + decaying_owner_removal.loops as u64, + decaying_owner_removal.backend as u64, + )); + + let mut perpetual_general = BTreeMap::<(NetUid, T::AccountId), LockState>::new(); + let mut decaying_general = BTreeMap::<(NetUid, T::AccountId), LockState>::new(); + let mut perpetual_owner = BTreeMap::::new(); + let mut decaying_owner = BTreeMap::::new(); + let mut retained_count = 0u64; + let mut removed_dust_count = 0u64; + + for ((coldkey, netuid, hotkey), lock) in locks { + let owner_lock = SubnetOwnerHotkey::::get(netuid) == hotkey; + let perpetual_lock = DecayingLock::::get(&coldkey, netuid) == Some(false); + weight = weight.saturating_add(T::DbWeight::get().reads(2)); + + let rolled = roll_lock_state( + lock, + now, + unlock_rate, + maturity_rate, + owner_lock, + perpetual_lock, + ); + + if rolled.is_dust() { + Lock::::remove((&coldkey, netuid, &hotkey)); + removed_dust_count = removed_dust_count.saturating_add(1); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + continue; + } + + Lock::::insert((&coldkey, netuid, &hotkey), rolled.clone()); + LockingColdkeys::::insert((netuid, &hotkey, &coldkey), ()); + retained_count = retained_count.saturating_add(1); + weight = weight.saturating_add(T::DbWeight::get().writes(2)); + + match (owner_lock, perpetual_lock) { + (true, true) => merge_into(&mut perpetual_owner, netuid, &rolled), + (true, false) => merge_into(&mut decaying_owner, netuid, &rolled), + (false, true) => { + merge_into(&mut perpetual_general, (netuid, hotkey), &rolled); + } + (false, false) => { + merge_into(&mut decaying_general, (netuid, hotkey), &rolled); + } + } + } + + let aggregate_count = perpetual_general + .len() + .saturating_add(decaying_general.len()) + .saturating_add(perpetual_owner.len()) + .saturating_add(decaying_owner.len()) as u64; + + for ((netuid, hotkey), lock) in perpetual_general { + HotkeyLock::::insert(netuid, hotkey, lock); + } + for ((netuid, hotkey), lock) in decaying_general { + DecayingHotkeyLock::::insert(netuid, hotkey, lock); + } + for (netuid, lock) in perpetual_owner { + OwnerLock::::insert(netuid, lock); + } + for (netuid, lock) in decaying_owner { + DecayingOwnerLock::::insert(netuid, lock); + } + weight = weight.saturating_add(T::DbWeight::get().writes(aggregate_count)); + + HasMigrationRun::::insert(MIGRATION_NAME, true); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!( + "Migration '{}' completed. scanned_entries={}, retained_entries={}, \ + removed_dust_entries={}, rebuilt_aggregate_entries={}", + String::from_utf8_lossy(MIGRATION_NAME), + scanned_count, + retained_count, + removed_dust_count, + aggregate_count, + ); + + weight +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index 78bd65c34d..d26a11a064 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -50,6 +50,7 @@ pub mod migrate_rao; pub mod migrate_rate_limit_keys; pub mod migrate_rate_limiting_last_blocks; pub mod migrate_rebase_recycled_alpha_asset_counters; +pub mod migrate_rebuild_conviction_aggregates; pub mod migrate_remove_add_stake_burn_rate_limit; pub mod migrate_remove_commitments_rate_limit; pub mod migrate_remove_deprecated_conviction_maps; diff --git a/pallets/subtensor/src/staking/lock.rs b/pallets/subtensor/src/staking/lock.rs index ac25d846cd..95d6b589b1 100644 --- a/pallets/subtensor/src/staking/lock.rs +++ b/pallets/subtensor/src/staking/lock.rs @@ -14,7 +14,9 @@ pub const ONE_YEAR: u64 = 7200 * 365 + 1800; pub const LOCK_STATE_ZERO_THRESHOLD: u64 = 100; /// Exponential lock state for a coldkey on a subnet. -#[crate::freeze_struct("1f6be20a66128b8d")] +/// This struct is stored in state maps. The additional logic is implemented in +// higher level LockState[class] structs. +#[crate::freeze_struct("eedde2cfd95ddcb1")] #[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, Debug, TypeInfo)] pub struct LockState { /// Exponentially decaying locked amount. @@ -26,327 +28,17 @@ pub struct LockState { } impl LockState { - pub fn is_zero(&self) -> bool { + pub fn is_dust(&self) -> bool { self.locked_mass < AlphaBalance::from(LOCK_STATE_ZERO_THRESHOLD) && self.conviction < U64F64::saturating_from_num(LOCK_STATE_ZERO_THRESHOLD) } -} - -/// Change produced by rolling a lock forward. Locked mass only ever -/// decreases, but conviction can move either way (it matures upward from -/// locked mass and decays downward once the mass is gone), so its change is -/// carried as separate unsigned growth/decay components. -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct RollDelta { - pub locked_mass_delta: AlphaBalance, - pub conviction_decay: U64F64, - pub conviction_growth: U64F64, -} - -impl RollDelta { - pub fn zero() -> Self { - Self { - locked_mass_delta: AlphaBalance::ZERO, - conviction_decay: U64F64::saturating_from_num(0), - conviction_growth: U64F64::saturating_from_num(0), - } - } - - pub fn is_zero(&self) -> bool { - self.locked_mass_delta.is_zero() - && self.conviction_decay == U64F64::saturating_from_num(0) - && self.conviction_growth == U64F64::saturating_from_num(0) - } -} - -/// A struct that incapsulates Lock primitives such as adding, removing, -/// rolling, and updating aggregates. -/// -/// This model has one individual lock state, which relates to the stake owner -/// (locking coldkey) lock and 4 aggregates that are maintained in operations. -pub struct ConvictionModel { - /// Whether this model's individual lock targets the subnet owner hotkey. - owner_lock: bool, - /// Whether this model's individual lock uses the non-decaying lock mode. - perpetual_lock: bool, - /// Individual stake owner coldkey lock - individual_lock: LockState, - individual_lock_dirty: bool, - /// Perpetual non-owner aggregate - agg_perpetual_general: LockState, - agg_perpetual_general_dirty: bool, - /// Decaying non-owner aggregate - agg_decaying_general: LockState, - agg_decaying_general_dirty: bool, - /// Perpetual owner aggregate - agg_perpetual_owner: LockState, - agg_perpetual_owner_dirty: bool, - /// Decaying owner aggregate - agg_decaying_owner: LockState, - agg_decaying_owner_dirty: bool, -} - -impl ConvictionModel { - pub fn new( - owner_lock: bool, - perpetual_lock: bool, - individual_lock: LockState, - agg_perpetual_general: LockState, - agg_decaying_general: LockState, - agg_perpetual_owner: LockState, - agg_decaying_owner: LockState, - ) -> Self { - Self { - owner_lock, - perpetual_lock, - individual_lock, - individual_lock_dirty: false, - agg_perpetual_general, - agg_perpetual_general_dirty: false, - agg_decaying_general, - agg_decaying_general_dirty: false, - agg_perpetual_owner, - agg_perpetual_owner_dirty: false, - agg_decaying_owner, - agg_decaying_owner_dirty: false, - } - } - - pub fn individual_lock(&self) -> &LockState { - &self.individual_lock - } - - pub fn agg_perpetual_general(&self) -> &LockState { - &self.agg_perpetual_general - } - - pub fn agg_decaying_general(&self) -> &LockState { - &self.agg_decaying_general - } - - pub fn agg_perpetual_owner(&self) -> &LockState { - &self.agg_perpetual_owner - } - - pub fn agg_decaying_owner(&self) -> &LockState { - &self.agg_decaying_owner - } - - pub fn aggregate_lock(&self) -> &LockState { - if self.owner_lock && self.perpetual_lock { - &self.agg_perpetual_owner - } else if self.owner_lock { - &self.agg_decaying_owner - } else if self.perpetual_lock { - &self.agg_perpetual_general - } else { - &self.agg_decaying_general - } - } - - pub fn individual_lock_dirty(&self) -> bool { - self.individual_lock_dirty - } - - pub fn agg_perpetual_general_dirty(&self) -> bool { - self.agg_perpetual_general_dirty - } - - pub fn agg_decaying_general_dirty(&self) -> bool { - self.agg_decaying_general_dirty - } - - pub fn agg_perpetual_owner_dirty(&self) -> bool { - self.agg_perpetual_owner_dirty - } - pub fn agg_decaying_owner_dirty(&self) -> bool { - self.agg_decaying_owner_dirty - } - - pub fn merge(&mut self, conv: &ConvictionModel) { - self.individual_lock = Self::merge_lock(&self.individual_lock, &conv.individual_lock); - self.individual_lock_dirty = true; - self.agg_perpetual_general = - Self::merge_lock(&self.agg_perpetual_general, &conv.agg_perpetual_general); - self.agg_perpetual_general_dirty = true; - self.agg_decaying_general = - Self::merge_lock(&self.agg_decaying_general, &conv.agg_decaying_general); - self.agg_decaying_general_dirty = true; - self.agg_perpetual_owner = - Self::merge_lock(&self.agg_perpetual_owner, &conv.agg_perpetual_owner); - self.agg_perpetual_owner_dirty = true; - self.agg_decaying_owner = - Self::merge_lock(&self.agg_decaying_owner, &conv.agg_decaying_owner); - self.agg_decaying_owner_dirty = true; - } - - pub fn set_individual_lock(&mut self, lock: LockState) { - self.individual_lock = lock; - self.individual_lock_dirty = true; - } - - pub fn set_rolled_individual_lock( - &mut self, - lock: LockState, - now: u64, - unlock_rate: u64, - maturity_rate: u64, - ) { - self.individual_lock = Self::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - self.owner_lock, - self.perpetual_lock, - ) - .0; - self.individual_lock_dirty = true; - } - - pub fn roll_forward(&mut self, now: u64, unlock_rate: u64, maturity_rate: u64) { - let (rolled_individual_lock, roll_delta) = Self::roll_forward_lock( - self.individual_lock.clone(), - now, - unlock_rate, - maturity_rate, - self.owner_lock, - self.perpetual_lock, - ); - self.individual_lock = rolled_individual_lock; - self.individual_lock_dirty = true; - if !roll_delta.is_zero() { - self.apply_roll_delta_to_aggregate(roll_delta, now); - } else { - self.roll_forward_aggregate(now, unlock_rate, maturity_rate); - } - } - - pub fn roll_forward_aggregate(&mut self, now: u64, unlock_rate: u64, maturity_rate: u64) { - let owner_lock = self.owner_lock; - let perpetual_lock = self.perpetual_lock; - let (aggregate, aggregate_dirty) = self.aggregate_mut(); - *aggregate = Self::roll_forward_lock( - aggregate.clone(), - now, - unlock_rate, - maturity_rate, - owner_lock, - perpetual_lock, - ) - .0; - *aggregate_dirty = true; - } - - pub fn add_to_aggregate(&mut self, added: &LockState) { - let (aggregate, aggregate_dirty) = self.aggregate_mut(); - *aggregate = Self::merge_lock(aggregate, added); - *aggregate_dirty = true; - } - - pub fn reduce_aggregate(&mut self, locked_mass: AlphaBalance, conviction: U64F64) { - let (aggregate, aggregate_dirty) = self.aggregate_mut(); - *aggregate = Self::reduce_lock(aggregate, locked_mass, conviction); - *aggregate_dirty = true; - } - - fn apply_roll_delta_to_aggregate(&mut self, roll_delta: RollDelta, now: u64) { - let (aggregate, aggregate_dirty) = self.aggregate_mut(); - *aggregate = Self::reduce_lock( - aggregate, - roll_delta.locked_mass_delta, - roll_delta.conviction_decay, - ); - // Conviction matured by the individual lock must be credited to the - // aggregate here: bumping last_update below means the aggregate's own - // roll-forward will never cover this window, so dropping the growth - // (as a saturating decrease-only delta used to) permanently - // understates aggregate conviction. - aggregate.conviction = aggregate - .conviction - .saturating_add(roll_delta.conviction_growth); - aggregate.last_update = now; - *aggregate_dirty = true; - } - - pub fn reduce(&mut self, locked_mass: AlphaBalance, conviction: U64F64) { - self.individual_lock = Self::reduce_lock(&self.individual_lock, locked_mass, conviction); - self.individual_lock_dirty = true; - - let (aggregate, aggregate_dirty) = self.aggregate_mut(); - *aggregate = Self::reduce_lock(aggregate, locked_mass, conviction); - *aggregate_dirty = true; - } - - pub fn force_reduce_individual(&mut self, amount: AlphaBalance, now: u64) { - let rolled = self.individual_lock.clone(); - let new_locked_mass = rolled.locked_mass.saturating_sub(amount); - let locked_mass_diff = rolled.locked_mass.saturating_sub(new_locked_mass); - - let conviction_diff = if new_locked_mass.is_zero() { - self.individual_lock = LockState { - locked_mass: AlphaBalance::ZERO, - conviction: U64F64::saturating_from_num(0), - last_update: now, - }; - rolled.conviction - } else { - let removed_proportion = U64F64::saturating_from_num(u64::from(amount)) - .safe_div(U64F64::saturating_from_num(u64::from(rolled.locked_mass))); - let new_conviction = rolled - .conviction - .saturating_mul(U64F64::saturating_from_num(1).saturating_sub(removed_proportion)); - self.individual_lock = LockState { - locked_mass: new_locked_mass, - conviction: new_conviction, - last_update: now, - }; - rolled.conviction.saturating_sub(new_conviction) - }; - self.individual_lock_dirty = true; - - self.reduce_aggregate(locked_mass_diff, conviction_diff); - } - - fn aggregate_mut(&mut self) -> (&mut LockState, &mut bool) { - if self.owner_lock && self.perpetual_lock { - ( - &mut self.agg_perpetual_owner, - &mut self.agg_perpetual_owner_dirty, - ) - } else if self.owner_lock { - ( - &mut self.agg_decaying_owner, - &mut self.agg_decaying_owner_dirty, - ) - } else if self.perpetual_lock { - ( - &mut self.agg_perpetual_general, - &mut self.agg_perpetual_general_dirty, - ) - } else { - ( - &mut self.agg_decaying_general, - &mut self.agg_decaying_general_dirty, - ) - } - } - - fn merge_lock(lhs: &LockState, rhs: &LockState) -> LockState { - LockState { - locked_mass: lhs.locked_mass.saturating_add(rhs.locked_mass), - conviction: lhs.conviction.saturating_add(rhs.conviction), - last_update: lhs.last_update.max(rhs.last_update), - } - } - - fn reduce_lock(lock: &LockState, locked_mass: AlphaBalance, conviction: U64F64) -> LockState { - LockState { - locked_mass: lock.locked_mass.saturating_sub(locked_mass), - conviction: lock.conviction.saturating_sub(conviction), - last_update: lock.last_update, + fn normalize_dust(mut self) -> Self { + if self.is_dust() { + self.locked_mass = AlphaBalance::ZERO; + self.conviction = U64F64::saturating_from_num(0); } + self } pub fn exp_decay(dt: u64, tau: u64) -> U64F64 { @@ -370,8 +62,7 @@ impl ConvictionModel { } fn calculate_decayed_mass_and_conviction( - locked_mass: AlphaBalance, - conviction: U64F64, + &self, dt: u64, unlock_rate: u64, maturity_rate: u64, @@ -379,9 +70,9 @@ impl ConvictionModel { ) -> (AlphaBalance, U64F64) { let unlock_decay = Self::exp_decay(dt, unlock_rate); let maturity_decay = Self::exp_decay(dt, maturity_rate); - let mass_fixed = U64F64::saturating_from_num(locked_mass); + let mass_fixed = U64F64::saturating_from_num(self.locked_mass); let new_locked_mass = if perpetual_lock { - locked_mass + self.locked_mass } else { unlock_decay .saturating_mul(mass_fixed) @@ -389,7 +80,7 @@ impl ConvictionModel { .into() }; - let conviction_from_existing = maturity_decay.saturating_mul(conviction); + let conviction_from_existing = maturity_decay.saturating_mul(self.conviction); let conviction_from_mass = if perpetual_lock { mass_fixed.saturating_mul(U64F64::saturating_from_num(1).saturating_sub(maturity_decay)) } else if unlock_rate == maturity_rate { @@ -423,57 +114,490 @@ impl ConvictionModel { (new_locked_mass, new_conviction) } - pub fn roll_forward_lock( - lock: LockState, - now: u64, - unlock_rate: u64, - maturity_rate: u64, - owner_lock: bool, - perpetual_lock: bool, - ) -> (LockState, RollDelta) { - let previous_locked_mass = lock.locked_mass; - let previous_conviction = lock.conviction; - let mut rolled = if now > lock.last_update { - let dt = now.saturating_sub(lock.last_update); - let (new_locked_mass, new_conviction) = Self::calculate_decayed_mass_and_conviction( - lock.locked_mass, - lock.conviction, + pub(crate) fn add(&self, other: &Self) -> Self { + Self { + locked_mass: self.locked_mass.saturating_add(other.locked_mass), + conviction: self.conviction.saturating_add(other.conviction), + last_update: self.last_update, + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +struct LockStatePerpetualGeneral { + lock: LockState, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +struct LockStateDecayinglGeneral { + lock: LockState, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +struct LockStatePerpetualOwner { + lock: LockState, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +struct LockStateDecayinglOwner { + lock: LockState, +} + +impl LockStatePerpetualGeneral { + pub fn roll_forward(&self, now: u64, unlock_rate: u64, maturity_rate: u64) -> Self { + Self { + lock: if now > self.lock.last_update { + let dt = now.saturating_sub(self.lock.last_update); + let (locked_mass, conviction) = self.lock.calculate_decayed_mass_and_conviction( + dt, + unlock_rate, + maturity_rate, + true, + ); + + LockState { + locked_mass, + conviction, + last_update: now, + } + } else { + self.lock.clone() + }, + } + } +} + +impl LockStateDecayinglGeneral { + pub fn roll_forward(&self, now: u64, unlock_rate: u64, maturity_rate: u64) -> Self { + Self { + lock: if now > self.lock.last_update { + let dt = now.saturating_sub(self.lock.last_update); + let (locked_mass, conviction) = self.lock.calculate_decayed_mass_and_conviction( + dt, + unlock_rate, + maturity_rate, + false, + ); + + LockState { + locked_mass, + conviction, + last_update: now, + } + } else { + self.lock.clone() + }, + } + } +} + +impl LockStatePerpetualOwner { + pub fn roll_forward(&self, now: u64, unlock_rate: u64, maturity_rate: u64) -> Self { + let mut lock = if now > self.lock.last_update { + let dt = now.saturating_sub(self.lock.last_update); + let (locked_mass, conviction) = self.lock.calculate_decayed_mass_and_conviction( dt, unlock_rate, maturity_rate, - perpetual_lock, + true, + ); + + LockState { + locked_mass, + conviction, + last_update: now, + } + } else { + self.lock.clone() + }; + lock.conviction = U64F64::saturating_from_num(u64::from(lock.locked_mass)); + Self { lock } + } +} + +impl LockStateDecayinglOwner { + pub fn roll_forward(&self, now: u64, unlock_rate: u64, maturity_rate: u64) -> Self { + let mut lock = if now > self.lock.last_update { + let dt = now.saturating_sub(self.lock.last_update); + let (locked_mass, conviction) = self.lock.calculate_decayed_mass_and_conviction( + dt, + unlock_rate, + maturity_rate, + false, ); LockState { - locked_mass: new_locked_mass, - conviction: new_conviction, + locked_mass, + conviction, last_update: now, } } else { - lock + self.lock.clone() + }; + lock.conviction = U64F64::saturating_from_num(u64::from(lock.locked_mass)); + Self { lock } + } +} + +/// Class of lock that is determined by owner and perpetual flags. +#[derive(Clone, PartialEq, Eq, Debug)] +enum LockClass { + PerpetualGeneral(LockStatePerpetualGeneral), + DecayingGeneral(LockStateDecayinglGeneral), + PerpetualOwner(LockStatePerpetualOwner), + DecayingOwner(LockStateDecayinglOwner), +} + +impl LockClass { + pub(crate) fn new(lock: LockState, owner: bool, perpetual: bool) -> Self { + match (owner, perpetual) { + (false, true) => Self::PerpetualGeneral(LockStatePerpetualGeneral { lock }), + (false, false) => Self::DecayingGeneral(LockStateDecayinglGeneral { lock }), + (true, true) => Self::PerpetualOwner(LockStatePerpetualOwner { lock }), + (true, false) => Self::DecayingOwner(LockStateDecayinglOwner { lock }), + } + } + + fn lock(&self) -> &LockState { + match self { + Self::PerpetualGeneral(state) => &state.lock, + Self::DecayingGeneral(state) => &state.lock, + Self::PerpetualOwner(state) => &state.lock, + Self::DecayingOwner(state) => &state.lock, + } + } + + fn lock_mut(&mut self) -> &mut LockState { + match self { + Self::PerpetualGeneral(state) => &mut state.lock, + Self::DecayingGeneral(state) => &mut state.lock, + Self::PerpetualOwner(state) => &mut state.lock, + Self::DecayingOwner(state) => &mut state.lock, + } + } + + pub(crate) fn into_lock(self) -> LockState { + match self { + Self::PerpetualGeneral(state) => state.lock, + Self::DecayingGeneral(state) => state.lock, + Self::PerpetualOwner(state) => state.lock, + Self::DecayingOwner(state) => state.lock, + } + } + + pub(crate) fn roll_forward(&self, now: u64, unlock_rate: u64, maturity_rate: u64) -> Self { + match self { + Self::PerpetualGeneral(state) => { + Self::PerpetualGeneral(state.roll_forward(now, unlock_rate, maturity_rate)) + } + Self::DecayingGeneral(state) => { + Self::DecayingGeneral(state.roll_forward(now, unlock_rate, maturity_rate)) + } + Self::PerpetualOwner(state) => { + Self::PerpetualOwner(state.roll_forward(now, unlock_rate, maturity_rate)) + } + Self::DecayingOwner(state) => { + Self::DecayingOwner(state.roll_forward(now, unlock_rate, maturity_rate)) + } + } + } + + fn flags(&self) -> (bool, bool) { + match self { + Self::PerpetualGeneral(_) => (false, true), + Self::DecayingGeneral(_) => (false, false), + Self::PerpetualOwner(_) => (true, true), + Self::DecayingOwner(_) => (true, false), + } + } +} + +pub fn roll_lock_state( + lock: LockState, + now: u64, + unlock_rate: u64, + maturity_rate: u64, + owner: bool, + perpetual: bool, +) -> LockState { + LockClass::new(lock, owner, perpetual) + .roll_forward(now, unlock_rate, maturity_rate) + .into_lock() + .normalize_dust() +} + +/// A struct that incapsulates Lock primitives such as adding, removing, +/// rolling, and updating aggregates. +pub struct ConvictionModel { + individual_lock: LockClass, + aggregate_lock: LockClass, +} + +impl ConvictionModel { + pub fn new( + owner_lock: bool, + perpetual_lock: bool, + individual_lock_state: LockState, + aggregate_lock_state: LockState, + ) -> Self { + Self { + individual_lock: LockClass::new(individual_lock_state, owner_lock, perpetual_lock), + aggregate_lock: LockClass::new(aggregate_lock_state, owner_lock, perpetual_lock), + } + } + + pub fn individual_lock(&self) -> &LockState { + self.individual_lock.lock() + } + + pub fn aggregate_lock(&self) -> &LockState { + self.aggregate_lock.lock() + } + + pub fn rolled_individual(&self, now: u64, unlock_rate: u64, maturity_rate: u64) -> LockState { + self.individual_lock + .roll_forward(now, unlock_rate, maturity_rate) + .into_lock() + .normalize_dust() + } + + pub fn merge(&mut self, conv: &ConvictionModel) { + match ( + &self.individual_lock, + &self.aggregate_lock, + &conv.individual_lock, + &conv.aggregate_lock, + ) { + ( + LockClass::PerpetualGeneral(_), + LockClass::PerpetualGeneral(_), + LockClass::PerpetualGeneral(_), + LockClass::PerpetualGeneral(_), + ) + | ( + LockClass::DecayingGeneral(_), + LockClass::DecayingGeneral(_), + LockClass::DecayingGeneral(_), + LockClass::DecayingGeneral(_), + ) + | ( + LockClass::PerpetualOwner(_), + LockClass::PerpetualOwner(_), + LockClass::PerpetualOwner(_), + LockClass::PerpetualOwner(_), + ) + | ( + LockClass::DecayingOwner(_), + LockClass::DecayingOwner(_), + LockClass::DecayingOwner(_), + LockClass::DecayingOwner(_), + ) => {} + _ => { + log::error!("Cannot merge conviction models with different lock classes"); + return; + } + } + + let individual = self.individual_lock.lock().add(conv.individual_lock.lock()); + let aggregate = self.aggregate_lock.lock().add(conv.aggregate_lock.lock()); + *self.individual_lock.lock_mut() = individual; + *self.aggregate_lock.lock_mut() = aggregate; + } + + /// Rolls the individual lock and its aggregate bucket forward together. + /// If individual lock becomes dust, makes it zero and removes it from the aggregate. + pub fn roll_forward(&mut self, now: u64, unlock_rate: u64, maturity_rate: u64) { + if self.individual_lock.flags() != self.aggregate_lock.flags() { + log::error!( + "Cannot roll conviction model with different individual and aggregate classes" + ); + return; + } + + self.individual_lock = self + .individual_lock + .roll_forward(now, unlock_rate, maturity_rate); + self.aggregate_lock = self + .aggregate_lock + .roll_forward(now, unlock_rate, maturity_rate); + + self.collect_individual_dust(); + } + + /// Rolls the model forward and adds locked mass while keeping the individual and + /// aggregate contributions synchronized. + fn add_locked_mass( + &mut self, + amount: AlphaBalance, + now: u64, + unlock_rate: u64, + maturity_rate: u64, + ) { + self.roll_forward(now, unlock_rate, maturity_rate); + + let owner_lock = match (&self.individual_lock, &self.aggregate_lock) { + (LockClass::PerpetualGeneral(_), LockClass::PerpetualGeneral(_)) + | (LockClass::DecayingGeneral(_), LockClass::DecayingGeneral(_)) => false, + (LockClass::PerpetualOwner(_), LockClass::PerpetualOwner(_)) + | (LockClass::DecayingOwner(_), LockClass::DecayingOwner(_)) => true, + _ => { + log::error!("Cannot add locked mass to different individual and aggregate classes"); + return; + } }; + let individual = self.individual_lock.lock_mut(); + let aggregate = self.aggregate_lock.lock_mut(); + individual.locked_mass = individual.locked_mass.saturating_add(amount); + aggregate.locked_mass = aggregate.locked_mass.saturating_add(amount); if owner_lock { - rolled.conviction = U64F64::saturating_from_num(u64::from(rolled.locked_mass)); + individual.conviction = U64F64::saturating_from_num(u64::from(individual.locked_mass)); + aggregate.conviction = U64F64::saturating_from_num(u64::from(aggregate.locked_mass)); } - if rolled.is_zero() { - rolled.locked_mass = AlphaBalance::ZERO; - rolled.conviction = U64F64::saturating_from_num(0); + self.collect_individual_dust(); + } + + fn force_reduce_individual(&mut self, amount: AlphaBalance, now: u64) { + if self.individual_lock.flags() != self.aggregate_lock.flags() { + log::error!("Cannot reduce lock with different individual and aggregate classes"); + return; } - let roll_delta = RollDelta { - locked_mass_delta: previous_locked_mass.saturating_sub(rolled.locked_mass), - conviction_decay: previous_conviction.saturating_sub(rolled.conviction), - conviction_growth: rolled.conviction.saturating_sub(previous_conviction), + let before = self.individual_lock.lock().clone(); + let new_locked_mass = before.locked_mass.saturating_sub(amount); + let new_conviction = if new_locked_mass.is_zero() { + U64F64::saturating_from_num(0) + } else { + let remaining = U64F64::saturating_from_num(u64::from(new_locked_mass)) + .safe_div(U64F64::saturating_from_num(u64::from(before.locked_mass))); + before.conviction.saturating_mul(remaining) }; - (rolled, roll_delta) + let individual = self.individual_lock.lock_mut(); + individual.locked_mass = new_locked_mass; + individual.conviction = new_conviction; + individual.last_update = now; + + let aggregate = self.aggregate_lock.lock_mut(); + aggregate.locked_mass = aggregate + .locked_mass + .saturating_sub(before.locked_mass.saturating_sub(new_locked_mass)); + aggregate.conviction = aggregate + .conviction + .saturating_sub(before.conviction.saturating_sub(new_conviction)); + + self.collect_individual_dust(); + } + + fn collect_individual_dust(&mut self) { + if !self.individual_lock.lock().is_dust() { + return; + } + + let dust = self.individual_lock.lock().clone(); + let aggregate = self.aggregate_lock.lock_mut(); + aggregate.locked_mass = aggregate.locked_mass.saturating_sub(dust.locked_mass); + aggregate.conviction = aggregate.conviction.saturating_sub(dust.conviction); + + let individual = self.individual_lock.lock_mut(); + individual.locked_mass = AlphaBalance::ZERO; + individual.conviction = U64F64::saturating_from_num(0); + } + + fn roll_forward_aggregate(&mut self, now: u64, unlock_rate: u64, maturity_rate: u64) { + self.aggregate_lock = self + .aggregate_lock + .roll_forward(now, unlock_rate, maturity_rate); + } + + fn cloned(&self) -> Self { + Self { + individual_lock: self.individual_lock.clone(), + aggregate_lock: self.aggregate_lock.clone(), + } + } + + fn remove_individual_contribution(&mut self) -> LockState { + let contribution = self.individual_lock.lock().clone(); + let aggregate = self.aggregate_lock.lock_mut(); + aggregate.locked_mass = aggregate + .locked_mass + .saturating_sub(contribution.locked_mass); + aggregate.conviction = aggregate.conviction.saturating_sub(contribution.conviction); + *self.individual_lock.lock_mut() = LockState { + locked_mass: AlphaBalance::ZERO, + conviction: U64F64::saturating_from_num(0), + last_update: contribution.last_update, + }; + contribution + } + + fn replace_individual(&mut self, replacement: LockState) { + let previous = self.individual_lock.lock().clone(); + let aggregate = self.aggregate_lock.lock_mut(); + aggregate.locked_mass = aggregate + .locked_mass + .saturating_sub(previous.locked_mass) + .saturating_add(replacement.locked_mass); + aggregate.conviction = aggregate + .conviction + .saturating_sub(previous.conviction) + .saturating_add(replacement.conviction); + *self.individual_lock.lock_mut() = replacement; + } + + pub fn set_perpetual(&mut self, perpetual: bool) -> Self { + let individual_flags = self.individual_lock.flags(); + if individual_flags != self.aggregate_lock.flags() { + log::error!( + "Cannot change perpetual behavior for different individual and aggregate classes" + ); + return self.cloned(); + } + + let (owner, currently_perpetual) = individual_flags; + if currently_perpetual == perpetual { + return self.cloned(); + } + + let contribution = self.remove_individual_contribution(); + ConvictionModel::new(owner, perpetual, contribution.clone(), contribution) + } + + pub fn set_owner(&mut self, owner: bool) -> Self { + let individual_flags = self.individual_lock.flags(); + if individual_flags != self.aggregate_lock.flags() { + log::error!( + "Cannot change owner behavior for different individual and aggregate classes" + ); + return self.cloned(); + } + + let (currently_owner, perpetual) = individual_flags; + if currently_owner == owner { + return self.cloned(); + } + + let mut contribution = self.remove_individual_contribution(); + if owner { + contribution.conviction = + U64F64::saturating_from_num(u64::from(contribution.locked_mass)); + } + + ConvictionModel::new(owner, perpetual, contribution.clone(), contribution) + } + + fn aggregate_mut(&mut self) -> &mut LockState { + self.aggregate_lock.lock_mut() } } impl Pallet { pub fn add_locking_coldkey(hotkey: &T::AccountId, netuid: NetUid, coldkey: &T::AccountId) { + if LockingColdkeys::::contains_key((netuid, hotkey, coldkey)) { + return; + } LockingColdkeys::::insert((netuid, hotkey, coldkey), ()); } @@ -482,7 +606,7 @@ impl Pallet { netuid: NetUid, coldkey: &T::AccountId, ) { - LockingColdkeys::::remove((netuid, hotkey, coldkey)); + let _ = LockingColdkeys::::take((netuid, hotkey, coldkey)); } pub fn account_rejects_locked_alpha(coldkey: &T::AccountId) -> bool { @@ -526,7 +650,7 @@ impl Pallet { hotkey: &T::AccountId, lock_state: LockState, ) { - if lock_state.is_zero() { + if lock_state.is_dust() { Self::maybe_remove_locking_coldkey(hotkey, netuid, coldkey); // If there is no record previously, this is a no-op Lock::::remove((coldkey, netuid, hotkey)); @@ -602,14 +726,43 @@ impl Pallet { hotkey: &T::AccountId, now: u64, ) -> ConvictionModel { + let owner_lock = Self::is_subnet_owner_hotkey(netuid, hotkey); + let perpetual_lock = Self::is_perpetual_lock(coldkey, netuid); + Self::read_conviction_model_for_class( + coldkey, + netuid, + hotkey, + now, + owner_lock, + perpetual_lock, + ) + } + + fn read_conviction_model_for_class( + coldkey: &T::AccountId, + netuid: NetUid, + hotkey: &T::AccountId, + now: u64, + owner_lock: bool, + perpetual_lock: bool, + ) -> ConvictionModel { + let aggregate_lock = match (owner_lock, perpetual_lock) { + (false, true) => { + HotkeyLock::::get(netuid, hotkey).unwrap_or_else(|| Self::empty_lock(now)) + } + (false, false) => DecayingHotkeyLock::::get(netuid, hotkey) + .unwrap_or_else(|| Self::empty_lock(now)), + (true, true) => OwnerLock::::get(netuid).unwrap_or_else(|| Self::empty_lock(now)), + (true, false) => { + DecayingOwnerLock::::get(netuid).unwrap_or_else(|| Self::empty_lock(now)) + } + }; + ConvictionModel::new( - Self::is_subnet_owner_hotkey(netuid, hotkey), - Self::is_perpetual_lock(coldkey, netuid), + owner_lock, + perpetual_lock, Lock::::get((coldkey, netuid, hotkey)).unwrap_or_else(|| Self::empty_lock(now)), - HotkeyLock::::get(netuid, hotkey).unwrap_or_else(|| Self::empty_lock(now)), - DecayingHotkeyLock::::get(netuid, hotkey).unwrap_or_else(|| Self::empty_lock(now)), - OwnerLock::::get(netuid).unwrap_or_else(|| Self::empty_lock(now)), - DecayingOwnerLock::::get(netuid).unwrap_or_else(|| Self::empty_lock(now)), + aggregate_lock, ) } @@ -632,24 +785,21 @@ impl Pallet { hotkey: &T::AccountId, model: ConvictionModel, ) { - if model.individual_lock_dirty() { - Self::insert_lock_state(coldkey, netuid, hotkey, model.individual_lock().clone()); - } - if model.agg_perpetual_general_dirty() { - Self::insert_hotkey_lock_state(netuid, hotkey, model.agg_perpetual_general().clone()); - } - if model.agg_decaying_general_dirty() { - Self::insert_decaying_hotkey_lock_state( - netuid, - hotkey, - model.agg_decaying_general().clone(), - ); - } - if model.agg_perpetual_owner_dirty() { - Self::insert_owner_lock_state(netuid, model.agg_perpetual_owner().clone()); - } - if model.agg_decaying_owner_dirty() { - Self::insert_decaying_owner_lock_state(netuid, model.agg_decaying_owner().clone()); + Self::insert_lock_state(coldkey, netuid, hotkey, model.individual_lock().clone()); + + match model.aggregate_lock { + LockClass::PerpetualGeneral(aggregate) => { + Self::insert_hotkey_lock_state(netuid, hotkey, aggregate.lock); + } + LockClass::DecayingGeneral(aggregate) => { + Self::insert_decaying_hotkey_lock_state(netuid, hotkey, aggregate.lock); + } + LockClass::PerpetualOwner(aggregate) => { + Self::insert_owner_lock_state(netuid, aggregate.lock); + } + LockClass::DecayingOwner(aggregate) => { + Self::insert_decaying_owner_lock_state(netuid, aggregate.lock); + } } } @@ -661,23 +811,24 @@ impl Pallet { ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); let now = Self::get_current_block_as_u64(); + let unlock_rate = UnlockRate::::get(); + let maturity_rate = MaturityRate::::get(); let current_enabled = Self::is_perpetual_lock(coldkey, netuid); - if let Some((hotkey, mut model)) = Self::read_conviction_model(coldkey, netuid, now) { - model.roll_forward(now, UnlockRate::::get(), MaturityRate::::get()); - let rolled = model.individual_lock().clone(); - Self::save_conviction_model(coldkey, netuid, &hotkey, model); - - if current_enabled != enabled { - Self::reduce_aggregate_lock( - coldkey, - &hotkey, - netuid, - rolled.locked_mass, - rolled.conviction, - ); + let reclassified_model = if current_enabled == enabled { + if let Some((hotkey, mut model)) = Self::read_conviction_model(coldkey, netuid, now) { + model.roll_forward(now, unlock_rate, maturity_rate); + Self::save_conviction_model(coldkey, netuid, &hotkey, model); } - } + None + } else { + Self::read_conviction_model(coldkey, netuid, now).map(|(hotkey, mut model)| { + model.roll_forward(now, unlock_rate, maturity_rate); + let reclassified = model.set_perpetual(enabled); + Self::save_conviction_model(coldkey, netuid, &hotkey, model); + (hotkey, reclassified) + }) + }; if enabled { DecayingLock::::insert(coldkey, netuid, false); @@ -685,11 +836,14 @@ impl Pallet { DecayingLock::::remove(coldkey, netuid); } - if current_enabled != enabled - && let Some((hotkey, model)) = Self::read_conviction_model(coldkey, netuid, now) - { - Self::add_aggregate_lock(coldkey, &hotkey, netuid, model.individual_lock().clone()); + if let Some((hotkey, reclassified)) = reclassified_model { + let mut destination = + Self::read_conviction_model_for_hotkey(coldkey, netuid, &hotkey, now); + destination.roll_forward(now, unlock_rate, maturity_rate); + destination.merge(&reclassified); + Self::save_conviction_model(coldkey, netuid, &hotkey, destination); } + Self::deposit_event(Event::PerpetualLockUpdated { coldkey: coldkey.clone(), netuid, @@ -712,9 +866,10 @@ impl Pallet { pub fn get_current_locked(coldkey: &T::AccountId, netuid: NetUid) -> AlphaBalance { let now = Self::get_current_block_as_u64(); Self::read_conviction_model(coldkey, netuid, now) - .map(|(_hotkey, mut model)| { - model.roll_forward(now, UnlockRate::::get(), MaturityRate::::get()); - model.individual_lock().locked_mass + .map(|(_hotkey, model)| { + model + .rolled_individual(now, UnlockRate::::get(), MaturityRate::::get()) + .locked_mass }) .unwrap_or(AlphaBalance::ZERO) } @@ -723,9 +878,10 @@ impl Pallet { pub fn get_conviction(coldkey: &T::AccountId, netuid: NetUid) -> U64F64 { let now = Self::get_current_block_as_u64(); Self::read_conviction_model(coldkey, netuid, now) - .map(|(_hotkey, mut model)| { - model.roll_forward(now, UnlockRate::::get(), MaturityRate::::get()); - model.individual_lock().conviction + .map(|(_hotkey, model)| { + model + .rolled_individual(now, UnlockRate::::get(), MaturityRate::::get()) + .conviction }) .unwrap_or_else(|| U64F64::saturating_from_num(0)) } @@ -733,9 +889,8 @@ impl Pallet { /// Returns the current lock for a coldkey on a subnet, rolled forward to now. pub fn get_coldkey_lock(coldkey: &T::AccountId, netuid: NetUid) -> Option { let now = Self::get_current_block_as_u64(); - Self::read_conviction_model(coldkey, netuid, now).map(|(_hotkey, mut model)| { - model.roll_forward(now, UnlockRate::::get(), MaturityRate::::get()); - model.individual_lock().clone() + Self::read_conviction_model(coldkey, netuid, now).map(|(_hotkey, model)| { + model.rolled_individual(now, UnlockRate::::get(), MaturityRate::::get()) }) } @@ -807,39 +962,19 @@ impl Pallet { && model.individual_lock().conviction == U64F64::saturating_from_num(0) { ensure!(total >= amount, Error::::InsufficientStakeForLock); - - model.set_rolled_individual_lock( - LockState { - locked_mass: amount, - conviction: U64F64::saturating_from_num(0), - last_update: now, - }, - now, - UnlockRate::::get(), - MaturityRate::::get(), - ); } else { - let mut lock = model.individual_lock().clone(); - lock.locked_mass = lock.locked_mass.saturating_add(amount); ensure!( - total >= lock.locked_mass, + total >= model.individual_lock().locked_mass.saturating_add(amount), Error::::InsufficientStakeForLock ); - model.set_rolled_individual_lock( - lock, - now, - UnlockRate::::get(), - MaturityRate::::get(), - ); } - model.roll_forward_aggregate(now, UnlockRate::::get(), MaturityRate::::get()); - model.add_to_aggregate(&LockState { - locked_mass: amount, - conviction: U64F64::saturating_from_num(0), - last_update: now, - }); - model.roll_forward_aggregate(now, UnlockRate::::get(), MaturityRate::::get()); + model.add_locked_mass( + amount, + now, + UnlockRate::::get(), + MaturityRate::::get(), + ); Self::save_conviction_model(coldkey, netuid, hotkey, model); Self::deposit_event(Event::StakeLocked { @@ -858,7 +993,6 @@ impl Pallet { let now = Self::get_current_block_as_u64(); if let Some((hotkey, mut model)) = Self::read_conviction_model(coldkey, netuid, now) { model.roll_forward(now, UnlockRate::::get(), MaturityRate::::get()); - model.roll_forward_aggregate(now, UnlockRate::::get(), MaturityRate::::get()); model.force_reduce_individual(amount, now); Self::save_conviction_model(coldkey, netuid, &hotkey, model); } @@ -918,8 +1052,9 @@ impl Pallet { let now = Self::get_current_block_as_u64(); let mut model = Self::read_conviction_model_for_hotkey(coldkey, netuid, hotkey, now); model.roll_forward_aggregate(now, UnlockRate::::get(), MaturityRate::::get()); - model.add_to_aggregate(&added); - model.roll_forward_aggregate(now, UnlockRate::::get(), MaturityRate::::get()); + let aggregate = model.aggregate_mut(); + aggregate.locked_mass = aggregate.locked_mass.saturating_add(added.locked_mass); + aggregate.conviction = aggregate.conviction.saturating_add(added.conviction); Self::save_conviction_model(coldkey, netuid, hotkey, model); } @@ -934,7 +1069,9 @@ impl Pallet { let now = Self::get_current_block_as_u64(); let mut model = Self::read_conviction_model_for_hotkey(coldkey, netuid, hotkey, now); model.roll_forward_aggregate(now, UnlockRate::::get(), MaturityRate::::get()); - model.reduce_aggregate(amount, conviction); + let aggregate = model.aggregate_mut(); + aggregate.locked_mass = aggregate.locked_mass.saturating_sub(amount); + aggregate.conviction = aggregate.conviction.saturating_sub(conviction); Self::save_conviction_model(coldkey, netuid, hotkey, model); } @@ -946,60 +1083,24 @@ impl Pallet { let maturity_rate = MaturityRate::::get(); let perpetual_conviction = HotkeyLock::::get(netuid, hotkey) .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - true, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, false, true).conviction }) .unwrap_or_else(|| U64F64::saturating_from_num(0)); let decaying_conviction = DecayingHotkeyLock::::get(netuid, hotkey) .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - false, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, false, false).conviction }) .unwrap_or_else(|| U64F64::saturating_from_num(0)); let hotkey_conviction = perpetual_conviction.saturating_add(decaying_conviction); if hotkey == &SubnetOwnerHotkey::::get(netuid) { let owner_conviction = OwnerLock::::get(netuid) .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - true, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, true, true).conviction }) .unwrap_or_else(|| U64F64::saturating_from_num(0)); let decaying_owner_conviction = DecayingOwnerLock::::get(netuid) .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - false, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, true, false).conviction }) .unwrap_or_else(|| U64F64::saturating_from_num(0)); hotkey_conviction @@ -1017,62 +1118,26 @@ impl Pallet { let maturity_rate = MaturityRate::::get(); let hotkey_conviction = HotkeyLock::::iter_prefix(netuid) .map(|(_hotkey, lock)| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - true, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, false, true).conviction }) .fold(U64F64::saturating_from_num(0), |acc, conviction| { acc.saturating_add(conviction) }); let decaying_hotkey_conviction = DecayingHotkeyLock::::iter_prefix(netuid) .map(|(_hotkey, lock)| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - false, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, false, false).conviction }) .fold(U64F64::saturating_from_num(0), |acc, conviction| { acc.saturating_add(conviction) }); let owner_conviction = OwnerLock::::get(netuid) .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - true, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, true, true).conviction }) .unwrap_or_else(|| U64F64::saturating_from_num(0)); let decaying_owner_conviction = DecayingOwnerLock::::get(netuid) .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - false, - ) - .0 - .conviction + roll_lock_state(lock, now, unlock_rate, maturity_rate, true, false).conviction }) .unwrap_or_else(|| U64F64::saturating_from_num(0)); @@ -1097,62 +1162,34 @@ impl Pallet { let mut scores: BTreeMap = BTreeMap::new(); HotkeyLock::::iter_prefix(netuid).for_each(|(hotkey, lock)| { - let rolled = ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - true, - ); + let rolled = roll_lock_state(lock, now, unlock_rate, maturity_rate, false, true); let entry = scores .entry(hotkey) .or_insert_with(|| U64F64::saturating_from_num(0)); - *entry = entry.saturating_add(rolled.0.conviction); + *entry = entry.saturating_add(rolled.conviction); }); DecayingHotkeyLock::::iter_prefix(netuid).for_each(|(hotkey, lock)| { - let rolled = ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - false, - ); + let rolled = roll_lock_state(lock, now, unlock_rate, maturity_rate, false, false); let entry = scores .entry(hotkey) .or_insert_with(|| U64F64::saturating_from_num(0)); - *entry = entry.saturating_add(rolled.0.conviction); + *entry = entry.saturating_add(rolled.conviction); }); if let Some(lock) = OwnerLock::::get(netuid) { let owner_hotkey = SubnetOwnerHotkey::::get(netuid); - let rolled = ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - true, - ); + let rolled = roll_lock_state(lock, now, unlock_rate, maturity_rate, true, true); let entry = scores .entry(owner_hotkey) .or_insert_with(|| U64F64::saturating_from_num(0)); - *entry = entry.saturating_add(rolled.0.conviction); + *entry = entry.saturating_add(rolled.conviction); } if let Some(lock) = DecayingOwnerLock::::get(netuid) { let owner_hotkey = SubnetOwnerHotkey::::get(netuid); - let rolled = ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - false, - ); + let rolled = roll_lock_state(lock, now, unlock_rate, maturity_rate, true, false); let entry = scores .entry(owner_hotkey) .or_insert_with(|| U64F64::saturating_from_num(0)); - *entry = entry.saturating_add(rolled.0.conviction); + *entry = entry.saturating_add(rolled.conviction); } scores @@ -1160,6 +1197,91 @@ impl Pallet { .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal)) } + fn transition_hotkey_lock_owner_class( + netuid: NetUid, + hotkey: &T::AccountId, + previous_owner: bool, + new_owner: bool, + now: u64, + unlock_rate: u64, + maturity_rate: u64, + ) { + let coldkeys: Vec = LockingColdkeys::::iter_prefix((netuid, hotkey)) + .map(|(coldkey, ())| coldkey) + .collect(); + + for coldkey in coldkeys { + if !Lock::::contains_key((&coldkey, netuid, hotkey)) { + Self::maybe_remove_locking_coldkey(hotkey, netuid, &coldkey); + continue; + } + + let perpetual_lock = Self::is_perpetual_lock(&coldkey, netuid); + let mut source = Self::read_conviction_model_for_class( + &coldkey, + netuid, + hotkey, + now, + previous_owner, + perpetual_lock, + ); + source.roll_forward(now, unlock_rate, maturity_rate); + let contribution = source.set_owner(new_owner); + Self::save_conviction_model(&coldkey, netuid, hotkey, source); + + let mut destination = Self::read_conviction_model_for_class( + &coldkey, + netuid, + hotkey, + now, + new_owner, + perpetual_lock, + ); + destination.roll_forward(now, unlock_rate, maturity_rate); + destination.merge(&contribution); + Self::save_conviction_model(&coldkey, netuid, hotkey, destination); + } + } + + /// Reclassify canonical individual locks and their aggregate buckets when a + /// subnet's owner hotkey changes. + /// + /// This must run before updating [`SubnetOwnerHotkey`]. Every member of the + /// outgoing and incoming hotkeys is rolled to now and moved between the + /// corresponding aggregate classes. Keeping the owner boost on both + /// representations prevents it becoming orphaned after demotion. + pub(crate) fn transition_subnet_owner_lock_aggregates( + netuid: NetUid, + old_owner_hotkey: &T::AccountId, + new_owner_hotkey: &T::AccountId, + ) { + let now = Self::get_current_block_as_u64(); + let unlock_rate = UnlockRate::::get(); + let maturity_rate = MaturityRate::::get(); + if old_owner_hotkey == new_owner_hotkey { + return; + } + + Self::transition_hotkey_lock_owner_class( + netuid, + old_owner_hotkey, + true, + false, + now, + unlock_rate, + maturity_rate, + ); + Self::transition_hotkey_lock_owner_class( + netuid, + new_owner_hotkey, + false, + true, + now, + unlock_rate, + maturity_rate, + ); + } + /// Reassigns subnet ownership to the current lock-conviction leader when the subnet /// is mature enough and that leader has accumulated enough conviction on its own. /// @@ -1232,8 +1354,6 @@ impl Pallet { return; } let old_owner_hotkey = SubnetOwnerHotkey::::get(netuid); - let unlock_rate = UnlockRate::::get(); - let maturity_rate = MaturityRate::::get(); // Register new owner as a neuron if not yet registered. if Self::get_uid_for_net_and_hotkey(netuid, &king_hotkey).is_err() @@ -1242,165 +1362,7 @@ impl Pallet { return; } - // Move aggregate buckets using the hotkey's new role. - if let Some(owner_lock) = OwnerLock::::take(netuid) { - let moved_owner_lock = ConvictionModel::roll_forward_lock( - owner_lock, - now, - unlock_rate, - maturity_rate, - true, - true, - ); - let current = HotkeyLock::::get(netuid, &old_owner_hotkey) - .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - true, - ) - .0 - }) - .unwrap_or_else(|| Self::empty_lock(now)); - Self::insert_hotkey_lock_state( - netuid, - &old_owner_hotkey, - LockState { - locked_mass: current - .locked_mass - .saturating_add(moved_owner_lock.0.locked_mass), - conviction: current - .conviction - .saturating_add(moved_owner_lock.0.conviction), - last_update: now, - }, - ); - } - if let Some(owner_lock) = DecayingOwnerLock::::take(netuid) { - let moved_owner_lock = ConvictionModel::roll_forward_lock( - owner_lock, - now, - unlock_rate, - maturity_rate, - true, - false, - ); - let current = DecayingHotkeyLock::::get(netuid, &old_owner_hotkey) - .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - false, - ) - .0 - }) - .unwrap_or_else(|| Self::empty_lock(now)); - Self::insert_decaying_hotkey_lock_state( - netuid, - &old_owner_hotkey, - LockState { - locked_mass: current - .locked_mass - .saturating_add(moved_owner_lock.0.locked_mass), - conviction: current - .conviction - .saturating_add(moved_owner_lock.0.conviction), - last_update: now, - }, - ); - } - if let Some(king_lock) = HotkeyLock::::take(netuid, &king_hotkey) { - let moved_king_lock = ConvictionModel::roll_forward_lock( - king_lock, - now, - unlock_rate, - maturity_rate, - false, - true, - ); - let current = OwnerLock::::get(netuid) - .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - true, - ) - .0 - }) - .unwrap_or_else(|| Self::empty_lock(now)); - Self::insert_owner_lock_state( - netuid, - ConvictionModel::roll_forward_lock( - LockState { - locked_mass: current - .locked_mass - .saturating_add(moved_king_lock.0.locked_mass), - conviction: current - .conviction - .saturating_add(moved_king_lock.0.conviction), - last_update: now, - }, - now, - unlock_rate, - maturity_rate, - true, - true, - ) - .0, - ); - } - if let Some(king_lock) = DecayingHotkeyLock::::take(netuid, &king_hotkey) { - let moved_king_lock = ConvictionModel::roll_forward_lock( - king_lock, - now, - unlock_rate, - maturity_rate, - false, - false, - ); - let current = DecayingOwnerLock::::get(netuid) - .map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - false, - ) - .0 - }) - .unwrap_or_else(|| Self::empty_lock(now)); - Self::insert_decaying_owner_lock_state( - netuid, - ConvictionModel::roll_forward_lock( - LockState { - locked_mass: current - .locked_mass - .saturating_add(moved_king_lock.0.locked_mass), - conviction: current - .conviction - .saturating_add(moved_king_lock.0.conviction), - last_update: now, - }, - now, - unlock_rate, - maturity_rate, - true, - false, - ) - .0, - ); - } + Self::transition_subnet_owner_lock_aggregates(netuid, &old_owner_hotkey, &king_hotkey); // Reassign subnet owner coldkey and owner hotkey. SubnetOwner::::insert(netuid, new_owner_coldkey.clone()); @@ -1420,7 +1382,7 @@ impl Pallet { let maturity_rate = MaturityRate::::get(); for ((netuid, hotkey), lock) in Lock::::iter_prefix((coldkey,)) { - let rolled = ConvictionModel::roll_forward_lock( + let rolled = roll_lock_state( lock, now, unlock_rate, @@ -1428,7 +1390,7 @@ impl Pallet { Self::is_subnet_owner_hotkey(netuid, &hotkey), Self::is_perpetual_lock(coldkey, netuid), ); - if rolled.0.locked_mass > AlphaBalance::ZERO { + if rolled.locked_mass > AlphaBalance::ZERO { return Err(Error::::ActiveLockExists); } } @@ -1468,7 +1430,7 @@ impl Pallet { let perpetual_lock = decaying_locks_to_transfer .iter() .any(|(decaying_netuid, decaying)| *decaying_netuid == netuid && !*decaying); - let (old_lock, _) = ConvictionModel::roll_forward_lock( + let old_lock = roll_lock_state( lock, now, unlock_rate, @@ -1514,15 +1476,14 @@ impl Pallet { // 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( + let new_lock = roll_lock_state( old_lock.clone(), now, unlock_rate, maturity_rate, Self::is_subnet_owner_hotkey(netuid, &hotkey), perpetual_lock, - ) - .0; + ); Self::insert_lock_state(new_coldkey, netuid, &hotkey, new_lock.clone()); Self::add_aggregate_lock(new_coldkey, &hotkey, netuid, new_lock); } @@ -1615,24 +1576,22 @@ impl Pallet { .iter() .any(|(rebuild_netuid, _, is_owner)| *rebuild_netuid == netuid && *is_owner); let perpetual_lock = Self::is_perpetual_lock(&coldkey, netuid); - let rolled = ConvictionModel::roll_forward_lock( + let rolled = roll_lock_state( lock, now, unlock_rate, maturity_rate, old_owner_lock, perpetual_lock, - ) - .0; - let moved = ConvictionModel::roll_forward_lock( + ); + let moved = roll_lock_state( rolled, now, unlock_rate, maturity_rate, new_owner_lock, perpetual_lock, - ) - .0; + ); Lock::::remove((coldkey.clone(), netuid, old_hotkey.clone())); Self::maybe_remove_locking_coldkey(old_hotkey, netuid, &coldkey); Self::insert_lock_state(&coldkey, netuid, new_hotkey, moved); @@ -1644,53 +1603,18 @@ impl Pallet { let unlock_rate = UnlockRate::::get(); let maturity_rate = MaturityRate::::get(); let moved_perpetual_lock = if old_was_owner { - OwnerLock::::take(netuid).map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - true, - ) - .0 - }) + OwnerLock::::take(netuid) + .map(|lock| roll_lock_state(lock, now, unlock_rate, maturity_rate, true, true)) } else { - HotkeyLock::::take(netuid, old_hotkey).map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - true, - ) - .0 - }) + HotkeyLock::::take(netuid, old_hotkey) + .map(|lock| roll_lock_state(lock, now, unlock_rate, maturity_rate, false, true)) }; let moved_decaying_lock = if old_was_owner { - DecayingOwnerLock::::take(netuid).map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - false, - ) - .0 - }) + DecayingOwnerLock::::take(netuid) + .map(|lock| roll_lock_state(lock, now, unlock_rate, maturity_rate, true, false)) } else { DecayingHotkeyLock::::take(netuid, old_hotkey).map(|lock| { - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - false, - ) - .0 + roll_lock_state(lock, now, unlock_rate, maturity_rate, false, false) }) }; @@ -1698,29 +1622,13 @@ impl Pallet { if new_is_owner { Self::insert_owner_lock_state( netuid, - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - true, - ) - .0, + roll_lock_state(lock, now, unlock_rate, maturity_rate, true, true), ); } else { Self::insert_hotkey_lock_state( netuid, new_hotkey, - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - true, - ) - .0, + roll_lock_state(lock, now, unlock_rate, maturity_rate, false, true), ); } } @@ -1728,29 +1636,13 @@ impl Pallet { if new_is_owner { Self::insert_decaying_owner_lock_state( netuid, - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - true, - false, - ) - .0, + roll_lock_state(lock, now, unlock_rate, maturity_rate, true, false), ); } else { Self::insert_decaying_hotkey_lock_state( netuid, new_hotkey, - ConvictionModel::roll_forward_lock( - lock, - now, - unlock_rate, - maturity_rate, - false, - false, - ) - .0, + roll_lock_state(lock, now, unlock_rate, maturity_rate, false, false), ); } } @@ -1770,6 +1662,44 @@ impl Pallet { == Self::get_owning_coldkey_for_hotkey(destination_hotkey) } + /// Saves a synchronized source model before re-reading and mutating the + /// destination model. + /// + /// Source and destination can share an aggregate bucket. Re-reading after + /// the source save prevents a stale destination snapshot from restoring a + /// contribution that the source mutation just removed. + fn save_source_then_update_destination( + source_coldkey: &T::AccountId, + source_hotkey: &T::AccountId, + source_model: ConvictionModel, + destination_coldkey: &T::AccountId, + destination_hotkey: &T::AccountId, + netuid: NetUid, + now: u64, + unlock_rate: u64, + maturity_rate: u64, + update_destination: F, + ) where + F: FnOnce(&mut ConvictionModel), + { + Self::save_conviction_model(source_coldkey, netuid, source_hotkey, source_model); + + let mut destination_model = Self::read_conviction_model_for_hotkey( + destination_coldkey, + netuid, + destination_hotkey, + now, + ); + destination_model.roll_forward(now, unlock_rate, maturity_rate); + update_destination(&mut destination_model); + Self::save_conviction_model( + destination_coldkey, + netuid, + destination_hotkey, + destination_model, + ); + } + /// Moves lock from one hotkey to another and clears conviction /// /// The lock is rolled forward to the current block before switching the @@ -1795,33 +1725,35 @@ impl Pallet { let unlock_rate = UnlockRate::::get(); let maturity_rate = MaturityRate::::get(); model.roll_forward(now, unlock_rate, maturity_rate); - let mut lock = model.individual_lock().clone(); - let removed = lock.clone(); + let mut lock = model.remove_individual_contribution(); if !Self::conviction_survives_hotkey_change(&origin_hotkey, destination_hotkey) { lock.conviction = U64F64::saturating_from_num(0); } - lock = ConvictionModel::roll_forward_lock( + lock = roll_lock_state( lock, now, unlock_rate, maturity_rate, Self::is_subnet_owner_hotkey(netuid, destination_hotkey), Self::is_perpetual_lock(coldkey, netuid), - ) - .0; + ); - Lock::::remove((coldkey.clone(), netuid, origin_hotkey.clone())); - Self::maybe_remove_locking_coldkey(&origin_hotkey, netuid, coldkey); - Self::insert_lock_state(coldkey, netuid, destination_hotkey, lock.clone()); - Self::reduce_aggregate_lock( + Self::save_source_then_update_destination( coldkey, &origin_hotkey, + model, + coldkey, + destination_hotkey, netuid, - removed.locked_mass, - removed.conviction, + now, + unlock_rate, + maturity_rate, + move |destination_model| { + let combined = destination_model.individual_lock().add(&lock); + destination_model.replace_individual(combined); + }, ); - Self::add_aggregate_lock(coldkey, destination_hotkey, netuid, lock); Self::deposit_event(Event::LockMoved { coldkey: coldkey.clone(), @@ -1900,24 +1832,14 @@ impl Pallet { let maturity_rate = MaturityRate::::get(); source_model.roll_forward(now, unlock_rate, maturity_rate); let mut source_lock = source_model.individual_lock().clone(); - let maybe_destination_lock = Self::read_conviction_model(destination_coldkey, netuid, now) - .map(|(hotkey, mut model)| { - model.roll_forward(now, unlock_rate, maturity_rate); - (hotkey, model.individual_lock().clone()) - }); + let maybe_destination_hotkey = + Self::read_conviction_model(destination_coldkey, netuid, now) + .map(|(hotkey, _model)| hotkey); - let destination_lock_hotkey = maybe_destination_lock + let destination_lock_hotkey = maybe_destination_hotkey .as_ref() - .map(|(hotkey, _)| hotkey.clone()) + .cloned() .unwrap_or_else(|| destination_hotkey.clone()); - let mut destination_lock = maybe_destination_lock - .as_ref() - .map(|(_, lock)| lock.clone()) - .unwrap_or(LockState { - locked_mass: AlphaBalance::ZERO, - conviction: U64F64::saturating_from_num(0), - last_update: now, - }); // Calculate available stake by subtracting locked_mass from total alpha. let unavailable = source_lock.locked_mass; @@ -1934,10 +1856,9 @@ impl Pallet { // same amount, reduce conviction on the source coldkey proportionally, and increase conviction // on the destination coldkey proportionally. let mut locked_transfer = AlphaBalance::ZERO; - let mut conviction_transfer = U64F64::saturating_from_num(0); let mut received_conviction = U64F64::saturating_from_num(0); if !remaining_to_transfer.is_zero() { - if let Some((existing_hotkey, _)) = maybe_destination_lock.as_ref() { + if let Some(existing_hotkey) = maybe_destination_hotkey.as_ref() { ensure!( existing_hotkey == destination_hotkey, Error::::LockHotkeyMismatch @@ -1945,7 +1866,8 @@ impl Pallet { } locked_transfer = remaining_to_transfer.min(source_lock.locked_mass); - conviction_transfer = if locked_transfer.is_zero() || source_lock.locked_mass.is_zero() + let conviction_transfer = if locked_transfer.is_zero() + || source_lock.locked_mass.is_zero() { U64F64::saturating_from_num(0) } else { @@ -1971,60 +1893,51 @@ impl Pallet { source_lock.locked_mass = source_lock.locked_mass.saturating_sub(locked_transfer); source_lock.conviction = source_lock.conviction.saturating_sub(conviction_transfer); - destination_lock.locked_mass = - destination_lock.locked_mass.saturating_add(locked_transfer); - destination_lock.conviction = destination_lock - .conviction - .saturating_add(received_conviction); } Self::ensure_can_receive_locked_alpha(destination_coldkey, locked_transfer)?; - source_lock = ConvictionModel::roll_forward_lock( + source_lock = roll_lock_state( source_lock, now, unlock_rate, maturity_rate, Self::is_subnet_owner_hotkey(netuid, &source_hotkey), Self::is_perpetual_lock(origin_coldkey, netuid), - ) - .0; - destination_lock = ConvictionModel::roll_forward_lock( - destination_lock, - now, - unlock_rate, - maturity_rate, - Self::is_subnet_owner_hotkey(netuid, &destination_lock_hotkey), - Self::is_perpetual_lock(destination_coldkey, netuid), - ) - .0; - - // Upsert updated locks (only once per this fn) even if there were no updates because - // of roll-forward - Self::insert_lock_state(origin_coldkey, netuid, &source_hotkey, source_lock); - Self::insert_lock_state( - destination_coldkey, - netuid, - &destination_lock_hotkey, - destination_lock, ); - if !locked_transfer.is_zero() { - Self::reduce_aggregate_lock( + source_model.replace_individual(source_lock); + + if !locked_transfer.is_zero() || maybe_destination_hotkey.is_some() { + let destination_owner_hotkey = destination_lock_hotkey.clone(); + Self::save_source_then_update_destination( origin_coldkey, &source_hotkey, - netuid, - locked_transfer, - conviction_transfer, - ); - Self::add_aggregate_lock( + source_model, destination_coldkey, &destination_lock_hotkey, netuid, - LockState { - locked_mass: locked_transfer, - conviction: received_conviction, - last_update: now, + now, + unlock_rate, + maturity_rate, + move |destination_model| { + let mut destination_lock = destination_model.individual_lock().clone(); + destination_lock.locked_mass = + destination_lock.locked_mass.saturating_add(locked_transfer); + destination_lock.conviction = destination_lock + .conviction + .saturating_add(received_conviction); + destination_lock = roll_lock_state( + destination_lock, + now, + unlock_rate, + maturity_rate, + Self::is_subnet_owner_hotkey(netuid, &destination_owner_hotkey), + Self::is_perpetual_lock(destination_coldkey, netuid), + ); + destination_model.replace_individual(destination_lock); }, ); + } else { + Self::save_conviction_model(origin_coldkey, netuid, &source_hotkey, source_model); } Ok(()) diff --git a/pallets/subtensor/src/subnets/leasing.rs b/pallets/subtensor/src/subnets/leasing.rs index 3aa08b6db1..a462e4b895 100644 --- a/pallets/subtensor/src/subnets/leasing.rs +++ b/pallets/subtensor/src/subnets/leasing.rs @@ -202,6 +202,12 @@ impl Pallet { Self::coldkey_owns_hotkey(&lease.beneficiary, &hotkey), Error::::BeneficiaryDoesNotOwnHotkey ); + ensure!( + Self::is_subnet_account_id(&hotkey).is_none(), + Error::::CannotUseSystemAccount + ); + let old_owner_hotkey = SubnetOwnerHotkey::::get(lease.netuid); + Self::transition_subnet_owner_lock_aggregates(lease.netuid, &old_owner_hotkey, &hotkey); SubnetOwner::::insert(lease.netuid, lease.beneficiary.clone()); Self::set_subnet_owner_hotkey(lease.netuid, &hotkey)?; @@ -227,13 +233,11 @@ impl Pallet { // Lease shares exclude the beneficiary, while the benchmark's `k` includes them. let contributors_count = clear_result.unique.saturating_add(1); if contributors_count < T::MaxContributors::get() { - // We have cleared less than the max number of shareholders, so we need to refund the difference Ok(Some(::WeightInfo::terminate_lease( contributors_count, )) .into()) } else { - // We have cleared the max number of shareholders, so we don't need to refund anything Ok(().into()) } } diff --git a/pallets/subtensor/src/subnets/subnet.rs b/pallets/subtensor/src/subnets/subnet.rs index 558aa7835a..095d615e50 100644 --- a/pallets/subtensor/src/subnets/subnet.rs +++ b/pallets/subtensor/src/subnets/subnet.rs @@ -631,7 +631,14 @@ impl Pallet { current_block, ); - // Insert/update the hotkey + ensure!( + Self::is_subnet_account_id(hotkey).is_none(), + Error::::CannotUseSystemAccount + ); + let old_owner_hotkey = SubnetOwnerHotkey::::get(netuid); + Self::transition_subnet_owner_lock_aggregates(netuid, &old_owner_hotkey, hotkey); + + // Insert/update the hotkey after reclassifying aggregates under their old roles. Self::set_subnet_owner_hotkey(netuid, hotkey)?; // Return success. diff --git a/pallets/subtensor/src/tests/leasing.rs b/pallets/subtensor/src/tests/leasing.rs index 0bc00d139d..3ade72b37a 100644 --- a/pallets/subtensor/src/tests/leasing.rs +++ b/pallets/subtensor/src/tests/leasing.rs @@ -4,7 +4,7 @@ clippy::indexing_slicing )] use super::mock::*; -use crate::{subnets::leasing::SubnetLeaseOf, *}; +use crate::{staking::lock::LockState, subnets::leasing::SubnetLeaseOf, *}; use frame_support::{StorageDoubleMap, assert_err, assert_ok}; use sp_core::U256; use sp_runtime::Percent; @@ -300,7 +300,7 @@ fn test_terminate_lease_works() { Some( <::WeightInfo as crate::weights::WeightInfo>::terminate_lease( contributors_count, - ), + ) ) ); @@ -329,6 +329,142 @@ fn test_terminate_lease_works() { }); } +#[test] +fn test_terminate_lease_reclassifies_locks_and_prevents_stale_beneficiary_takeover() { + new_test_ext(1).execute_with(|| { + let beneficiary = U256::from(1); + let contributions = vec![(U256::from(2), 990_000_000_000)]; + setup_crowdloan( + 0, + 10_000_000_000, + 1_000_000_000_000, + beneficiary, + &contributions, + ); + let (lease_id, lease) = + setup_leased_network(beneficiary, Percent::from_percent(30), Some(500), None); + + let beneficiary_hotkey = U256::from(3); + let challenger_coldkey = U256::from(4); + let challenger_hotkey = U256::from(5); + let old_owner_locker = U256::from(6); + assert_ok!(SubtensorModule::create_account_if_non_existent( + &beneficiary, + &beneficiary_hotkey + )); + assert_ok!(SubtensorModule::create_account_if_non_existent( + &challenger_coldkey, + &challenger_hotkey + )); + + let registered_at = NetworkRegisteredAt::::get(lease.netuid); + let now = registered_at + .saturating_add(crate::staking::lock::ONE_YEAR) + .saturating_add(1); + System::set_block_number(now); + SubnetAlphaOut::::insert(lease.netuid, AlphaBalance::from(10_000u64)); + + // The lease owner has 11% conviction, while a legitimate challenger has 12%. + // Keep matching individual rows so later force reductions exercise the same + // model path that would expose a stale beneficiary bucket. + let old_owner_lock = LockState { + locked_mass: 1_100u64.into(), + conviction: U64F64::from_num(1_100), + last_update: now, + }; + DecayingLock::::insert(old_owner_locker, lease.netuid, false); + SubtensorModule::insert_lock_state( + &old_owner_locker, + lease.netuid, + &lease.hotkey, + old_owner_lock.clone(), + ); + SubtensorModule::insert_owner_lock_state(lease.netuid, old_owner_lock); + + let challenger_lock = LockState { + locked_mass: 1_200u64.into(), + conviction: U64F64::from_num(1_200), + last_update: now, + }; + DecayingLock::::insert(challenger_coldkey, lease.netuid, false); + SubtensorModule::insert_lock_state( + &challenger_coldkey, + lease.netuid, + &challenger_hotkey, + challenger_lock.clone(), + ); + SubtensorModule::insert_hotkey_lock_state( + lease.netuid, + &challenger_hotkey, + challenger_lock, + ); + let beneficiary_lock = LockState { + locked_mass: 100u64.into(), + conviction: U64F64::from_num(100), + last_update: now, + }; + DecayingLock::::insert(beneficiary, lease.netuid, false); + SubtensorModule::insert_lock_state( + &beneficiary, + lease.netuid, + &beneficiary_hotkey, + beneficiary_lock.clone(), + ); + SubtensorModule::insert_hotkey_lock_state( + lease.netuid, + &beneficiary_hotkey, + beneficiary_lock, + ); + + assert_ok!(SubtensorModule::terminate_lease( + RuntimeOrigin::signed(beneficiary), + lease_id, + beneficiary_hotkey, + )); + + // Lease termination must move the outgoing owner's contribution to its + // hotkey bucket instead of leaving it in the owner bucket now attributed + // to the beneficiary. Only the beneficiary's real 1% lock becomes owner + // conviction. + assert_eq!( + OwnerLock::::get(lease.netuid).unwrap().locked_mass, + 100u64.into() + ); + assert_eq!( + HotkeyLock::::get(lease.netuid, lease.hotkey) + .unwrap() + .locked_mass, + 1_100u64.into() + ); + assert!(HotkeyLock::::get(lease.netuid, beneficiary_hotkey).is_none()); + + // The 12% challenger can take over normally. + SubtensorModule::change_subnet_owner_if_needed(lease.netuid); + assert_eq!( + SubnetOwnerHotkey::::get(lease.netuid), + challenger_hotkey + ); + + // Remove both real contributions. Without the lease transition fix, the + // former owner's 11% remains orphaned in the beneficiary bucket and lets + // the beneficiary retake the subnet despite holding only 1% canonically. + SubtensorModule::force_reduce_lock(&old_owner_locker, lease.netuid, 1_100u64.into()); + SubtensorModule::force_reduce_lock(&challenger_coldkey, lease.netuid, 1_200u64.into()); + SubtensorModule::change_subnet_owner_if_needed(lease.netuid); + + assert_eq!( + SubnetOwnerHotkey::::get(lease.netuid), + challenger_hotkey + ); + assert_eq!( + HotkeyLock::::get(lease.netuid, beneficiary_hotkey) + .unwrap() + .conviction, + U64F64::from_num(100) + ); + }); +} + #[test] fn test_terminate_lease_fails_if_bad_origin() { new_test_ext(1).execute_with(|| { diff --git a/pallets/subtensor/src/tests/locks.rs b/pallets/subtensor/src/tests/locks.rs index 03a3297fea..e41cc948d9 100644 --- a/pallets/subtensor/src/tests/locks.rs +++ b/pallets/subtensor/src/tests/locks.rs @@ -16,7 +16,7 @@ use subtensor_runtime_common::{AlphaBalance, NetUidStorageIndex, TaoBalance}; use subtensor_swap_interface::SwapHandler; use super::mock::*; -use crate::staking::lock::{ConvictionModel, LockState}; +use crate::staking::lock::{ConvictionModel, LockState, roll_lock_state}; use crate::*; // --------------------------------------------------------------------------- @@ -71,7 +71,7 @@ fn roll_forward_lock( owner_lock: bool, perpetual_lock: bool, ) -> LockState { - ConvictionModel::roll_forward_lock( + roll_lock_state( lock, now, UnlockRate::::get(), @@ -79,7 +79,6 @@ fn roll_forward_lock( owner_lock, perpetual_lock, ) - .0 } fn roll_forward_individual_lock( @@ -1366,11 +1365,15 @@ fn test_locking_coldkeys_removed_when_lock_is_fully_reduced() { assert!(!LockingColdkeys::::contains_key(( netuid, hotkey, coldkey ))); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, hotkey)).count(), + 0 + ); }); } #[test] -fn test_lock_state_is_zero_uses_dust_threshold() { +fn test_lock_state_is_dust_uses_threshold() { let below_threshold = LockState { locked_mass: AlphaBalance::from(99u64), conviction: U64F64::from_num(99), @@ -1387,9 +1390,9 @@ fn test_lock_state_is_zero_uses_dust_threshold() { last_update: 0, }; - assert!(below_threshold.is_zero()); - assert!(!locked_mass_at_threshold.is_zero()); - assert!(!conviction_at_threshold.is_zero()); + assert!(below_threshold.is_dust()); + assert!(!locked_mass_at_threshold.is_dust()); + assert!(!conviction_at_threshold.is_dust()); } // ========================================================================= @@ -1482,7 +1485,7 @@ fn test_lock_stake_topup_exceeds_total() { #[test] fn test_exp_decay_zero_dt() { new_test_ext(1).execute_with(|| { - let result = ConvictionModel::exp_decay(0, 216000); + let result = LockState::exp_decay(0, 216000); assert_eq!(result, U64F64::from_num(1)); }); } @@ -1490,7 +1493,7 @@ fn test_exp_decay_zero_dt() { #[test] fn test_exp_decay_zero_tau() { new_test_ext(1).execute_with(|| { - let result = ConvictionModel::exp_decay(1000, 0); + let result = LockState::exp_decay(1000, 0); assert_eq!(result, U64F64::from_num(0)); }); } @@ -1499,7 +1502,7 @@ fn test_exp_decay_zero_tau() { fn test_exp_decay_one_tau() { new_test_ext(1).execute_with(|| { let tau = 216000u64; - let result = ConvictionModel::exp_decay(tau, tau); + let result = LockState::exp_decay(tau, tau); // exp(-1) ~= 0.36787944 let expected = U64F64::from_num(0.36787944f64); let diff = if result > expected { @@ -1515,8 +1518,8 @@ fn test_exp_decay_one_tau() { fn test_exp_decay_clamps_large_dt_to_min_ratio() { new_test_ext(1).execute_with(|| { let tau = 216000u64; - let clamped_result = ConvictionModel::exp_decay(40 * tau, tau); - let oversized_result = ConvictionModel::exp_decay(100 * tau, tau); + let clamped_result = LockState::exp_decay(40 * tau, tau); + let oversized_result = LockState::exp_decay(100 * tau, tau); let diff = if oversized_result > clamped_result { oversized_result - clamped_result @@ -1547,20 +1550,42 @@ fn test_roll_forward_individual_lock_uses_lock_owner_and_decay_mode() { let rolled = roll_forward_individual_lock(&coldkey, netuid, &owner_hotkey, lock.clone(), now); - let expected = ConvictionModel::roll_forward_lock( + let expected = roll_lock_state( lock, now, UnlockRate::::get(), MaturityRate::::get(), true, false, - ) - .0; + ); assert_eq!(rolled, expected); }); } +#[test] +fn test_rolled_individual_is_a_pure_view() { + new_test_ext(1).execute_with(|| { + let individual = LockState { + locked_mass: 10_000u64.into(), + conviction: U64F64::from_num(0), + last_update: 0, + }; + let aggregate = individual.clone(); + let model = ConvictionModel::new(false, true, individual.clone(), aggregate.clone()); + + let rolled = model.rolled_individual( + 1_000, + UnlockRate::::get(), + MaturityRate::::get(), + ); + + assert!(rolled.conviction > individual.conviction); + assert_eq!(model.individual_lock(), &individual); + assert_eq!(model.aggregate_lock(), &aggregate); + }); +} + #[test] fn test_roll_forward_hotkey_lock_uses_perpetual_general_mode() { new_test_ext(1).execute_with(|| { @@ -1572,15 +1597,14 @@ fn test_roll_forward_hotkey_lock_uses_perpetual_general_mode() { let now = 1_000u64; let rolled = roll_forward_hotkey_lock(lock.clone(), now); - let expected = ConvictionModel::roll_forward_lock( + let expected = roll_lock_state( lock, now, UnlockRate::::get(), MaturityRate::::get(), false, true, - ) - .0; + ); assert_eq!(rolled, expected); }); @@ -1597,15 +1621,14 @@ fn test_roll_forward_decaying_hotkey_lock_uses_decaying_general_mode() { let now = 1_000u64; let rolled = roll_forward_decaying_hotkey_lock(lock.clone(), now); - let expected = ConvictionModel::roll_forward_lock( + let expected = roll_lock_state( lock, now, UnlockRate::::get(), MaturityRate::::get(), false, false, - ) - .0; + ); assert_eq!(rolled, expected); }); @@ -1645,8 +1668,8 @@ fn test_roll_forward_conviction_uses_unequal_rate_closed_form() { }; let rolled = roll_forward_lock(lock, dt, false, false); - let unlock_decay = ConvictionModel::exp_decay(dt, unlock_rate); - let maturity_decay = ConvictionModel::exp_decay(dt, maturity_rate); + let unlock_decay = LockState::exp_decay(dt, unlock_rate); + let maturity_decay = LockState::exp_decay(dt, maturity_rate); let gamma = U64F64::from_num(unlock_rate) .saturating_mul(maturity_decay.saturating_sub(unlock_decay)) .safe_div(U64F64::from_num(maturity_rate.saturating_sub(unlock_rate))); @@ -2012,7 +2035,130 @@ fn test_unstake_rolls_forward_existing_lock() { } #[test] -fn test_unstake_roll_forward_collects_decaying_lock_dust_from_hotkey_aggregate() { +fn test_cleanup_rolls_forward_sibling_lock_contributions() { + new_test_ext(1).execute_with(|| { + let coldkey_1 = U256::from(1); + let coldkey_2 = U256::from(3); + let hotkey = U256::from(2); + let netuid = setup_subnet_with_stake(coldkey_1, hotkey, 100_000_000_000); + + add_balance_to_coldkey_account(&coldkey_2, 100_000_000_000u64.into()); + SubtensorModule::stake_into_subnet( + &hotkey, + &coldkey_2, + netuid, + 100_000_000_000u64.into(), + ::SwapInterface::max_price(), + false, + ) + .unwrap(); + DecayingLock::::insert(coldkey_2, netuid, false); + + let lock_1: AlphaBalance = 10_000_000_000u64.into(); + let lock_2: AlphaBalance = 20_000_000_000u64.into(); + assert_ok!(SubtensorModule::do_lock_stake( + &coldkey_1, netuid, &hotkey, lock_1, + )); + assert_ok!(SubtensorModule::do_lock_stake( + &coldkey_2, netuid, &hotkey, lock_2, + )); + + let stored_1 = Lock::::get((coldkey_1, netuid, hotkey)).unwrap(); + let stored_2 = Lock::::get((coldkey_2, netuid, hotkey)).unwrap(); + step_block(100); + let now = SubtensorModule::get_current_block_as_u64(); + let expected_1 = roll_forward_hotkey_lock(stored_1, now); + let expected_2 = roll_forward_hotkey_lock(stored_2, now); + + SubtensorModule::cleanup_lock_if_zero(&coldkey_1, netuid); + + let aggregate = HotkeyLock::::get(netuid, hotkey).expect("aggregate should remain"); + assert_eq!( + aggregate.locked_mass, + expected_1 + .locked_mass + .saturating_add(expected_2.locked_mass) + ); + assert_eq!( + aggregate.conviction, + expected_1.conviction.saturating_add(expected_2.conviction) + ); + assert_eq!(aggregate.last_update, now); + }); +} + +#[test] +fn test_lock_top_up_does_not_double_count_after_aggregate_only_roll() { + new_test_ext(1).execute_with(|| { + let large_coldkey = U256::from(1); + let dummy_coldkey = U256::from(3); + let hotkey = U256::from(2); + let netuid = setup_subnet_with_stake(large_coldkey, hotkey, 100_000_000_000); + + add_balance_to_coldkey_account(&dummy_coldkey, 100_000_000_000u64.into()); + SubtensorModule::stake_into_subnet( + &hotkey, + &dummy_coldkey, + netuid, + 100_000_000_000u64.into(), + ::SwapInterface::max_price(), + false, + ) + .unwrap(); + DecayingLock::::insert(dummy_coldkey, netuid, false); + + let large_lock: AlphaBalance = 10_000_000_000u64.into(); + let dummy_lock: AlphaBalance = 1_000_000_000u64.into(); + let top_up: AlphaBalance = 1_000_000_000u64.into(); + assert_ok!(SubtensorModule::do_lock_stake( + &large_coldkey, + netuid, + &hotkey, + large_lock, + )); + + step_block(100); + + // A new member has a zero individual delta, so adding it rolls the + // pre-existing aggregate (including the large lock) forward. + assert_ok!(SubtensorModule::do_lock_stake( + &dummy_coldkey, + netuid, + &hotkey, + dummy_lock, + )); + let aggregate_before_top_up = + HotkeyLock::::get(netuid, hotkey).expect("aggregate should exist"); + assert!(aggregate_before_top_up.conviction > U64F64::from_num(0)); + + // The large individual row is still older than the aggregate. Its + // already-counted maturation must not be added to the aggregate again. + assert_ok!(SubtensorModule::do_lock_stake( + &large_coldkey, + netuid, + &hotkey, + top_up, + )); + let aggregate_after_top_up = + HotkeyLock::::get(netuid, hotkey).expect("aggregate should remain"); + + assert_eq!( + aggregate_after_top_up.locked_mass, + aggregate_before_top_up.locked_mass.saturating_add(top_up) + ); + assert_eq!( + aggregate_after_top_up.conviction, + aggregate_before_top_up.conviction + ); + assert_eq!( + aggregate_after_top_up.last_update, + SubtensorModule::get_current_block_as_u64() + ); + }); +} + +#[test] +fn test_unstake_roll_forward_collects_decaying_sibling_dust_from_hotkey_aggregate() { new_test_ext(1).execute_with(|| { const ONE_ALPHA: u64 = 1_000_000_000; const DUST_ALPHA: u64 = 100; @@ -2082,6 +2228,19 @@ fn test_unstake_roll_forward_collects_decaying_lock_dust_from_hotkey_aggregate() }, now, ); + let rolled_aggregate = roll_forward_decaying_hotkey_lock( + LockState { + locked_mass: (ONE_ALPHA + DUST_ALPHA).into(), + conviction: U64F64::from_num(0), + last_update: lock_block, + }, + now, + ); + let rolled_sibling_dust_mass: AlphaBalance = + LockState::exp_decay(now.saturating_sub(lock_block), UnlockRate::::get()) + .saturating_mul(U64F64::from_num(DUST_ALPHA)) + .to_num::() + .into(); assert_ok!(SubtensorModule::do_remove_stake( RuntimeOrigin::signed(coldkey_1), @@ -2097,9 +2256,7 @@ fn test_unstake_roll_forward_collects_decaying_lock_dust_from_hotkey_aggregate() DecayingHotkeyLock::::get(netuid, hotkey_2) .expect("decaying aggregate should remain") .locked_mass, - rolled_large_lock - .locked_mass - .saturating_add(AlphaBalance::from(DUST_ALPHA)) + rolled_aggregate.locked_mass ); assert_ok!(SubtensorModule::do_remove_stake( @@ -2112,7 +2269,9 @@ fn test_unstake_roll_forward_collects_decaying_lock_dust_from_hotkey_aggregate() DecayingHotkeyLock::::get(netuid, hotkey_2) .expect("decaying aggregate should remain") .locked_mass, - rolled_large_lock.locked_mass + rolled_aggregate + .locked_mass + .saturating_sub(rolled_sibling_dust_mass) ); }); } @@ -2244,10 +2403,7 @@ fn test_do_transfer_stake_same_subnet_transfers_lock_to_destination_coldkey() { false, true, ); - assert_eq!( - hotkey_lock_after.locked_mass, - expected_hotkey_lock.locked_mass - ); + assert_eq!(hotkey_lock_after, expected_hotkey_lock); }); } @@ -3165,6 +3321,7 @@ fn test_change_subnet_owner_if_needed_reassigns_to_subnet_king() { System::set_block_number(now); NetworkRegisteredAt::::insert(netuid, 1); SubnetAlphaOut::::insert(netuid, AlphaBalance::from(10_000u64)); + DecayingLock::::insert(new_owner_coldkey, netuid, false); SubnetProtocolAlpha::::insert(netuid, AlphaBalance::from(2_000u64)); pallet_alpha_assets::AlphaBurned::::insert(netuid, AlphaBalance::from(1_000u64)); @@ -3178,6 +3335,7 @@ fn test_change_subnet_owner_if_needed_reassigns_to_subnet_king() { last_update: now, }, ); + SubtensorModule::add_locking_coldkey(&king_hotkey, netuid, &new_owner_coldkey); HotkeyLock::::insert( netuid, king_hotkey, @@ -3228,6 +3386,7 @@ fn test_run_coinbase_reassigns_subnet_owner_by_conviction_on_epoch() { SubtensorModule::set_tempo_unchecked(netuid, 1); LastEpochBlock::::insert(netuid, now.saturating_sub(1)); PendingEpochAt::::insert(netuid, 0); + DecayingLock::::insert(new_owner_coldkey, netuid, false); let locked_mass = AlphaBalance::from(2_000u64); Lock::::insert( @@ -3238,6 +3397,7 @@ fn test_run_coinbase_reassigns_subnet_owner_by_conviction_on_epoch() { last_update: now, }, ); + SubtensorModule::add_locking_coldkey(&king_hotkey, netuid, &new_owner_coldkey); HotkeyLock::::insert( netuid, king_hotkey, @@ -3283,6 +3443,7 @@ fn test_change_subnet_owner_rebuilds_old_owner_hotkey_by_lock_mode() { NetworkRegisteredAt::::insert(netuid, 1); SubnetAlphaOut::::insert(netuid, AlphaBalance::from(10_000u64)); DecayingLock::::insert(perpetual_coldkey, netuid, false); + DecayingLock::::insert(king_coldkey, netuid, false); Lock::::insert( (perpetual_coldkey, netuid, old_owner_hotkey), @@ -3292,6 +3453,7 @@ fn test_change_subnet_owner_rebuilds_old_owner_hotkey_by_lock_mode() { last_update: now, }, ); + SubtensorModule::add_locking_coldkey(&old_owner_hotkey, netuid, &perpetual_coldkey); Lock::::insert( (decaying_coldkey, netuid, old_owner_hotkey), LockState { @@ -3300,6 +3462,7 @@ fn test_change_subnet_owner_rebuilds_old_owner_hotkey_by_lock_mode() { last_update: now, }, ); + SubtensorModule::add_locking_coldkey(&old_owner_hotkey, netuid, &decaying_coldkey); OwnerLock::::insert( netuid, LockState { @@ -3326,6 +3489,7 @@ fn test_change_subnet_owner_rebuilds_old_owner_hotkey_by_lock_mode() { last_update: now, }, ); + SubtensorModule::add_locking_coldkey(&king_hotkey, netuid, &king_coldkey); HotkeyLock::::insert( netuid, king_hotkey, @@ -3358,6 +3522,125 @@ fn test_change_subnet_owner_rebuilds_old_owner_hotkey_by_lock_mode() { }); } +#[test] +fn test_owner_demotion_then_member_update_does_not_leave_ghost_conviction() { + new_test_ext(100).execute_with(|| { + let netuid = NetUid::from(1); + let original_owner_hotkey = U256::from(1); + let promoted_hotkey = U256::from(2); + let next_owner_hotkey = U256::from(3); + let first_coldkey = U256::from(10); + let second_coldkey = U256::from(11); + let now = SubtensorModule::get_current_block_as_u64(); + let member = LockState { + locked_mass: 1_000u64.into(), + conviction: U64F64::from_num(100), + last_update: now, + }; + + SubnetOwnerHotkey::::insert(netuid, original_owner_hotkey); + DecayingLock::::insert(first_coldkey, netuid, false); + DecayingLock::::insert(second_coldkey, netuid, false); + SubtensorModule::insert_lock_state( + &first_coldkey, + netuid, + &promoted_hotkey, + member.clone(), + ); + SubtensorModule::insert_lock_state( + &second_coldkey, + netuid, + &promoted_hotkey, + member.clone(), + ); + HotkeyLock::::insert( + netuid, + promoted_hotkey, + LockState { + locked_mass: 2_000u64.into(), + conviction: U64F64::from_num(200), + last_update: now, + }, + ); + + SubtensorModule::transition_subnet_owner_lock_aggregates( + netuid, + &original_owner_hotkey, + &promoted_hotkey, + ); + SubnetOwnerHotkey::::insert(netuid, promoted_hotkey); + + SubtensorModule::transition_subnet_owner_lock_aggregates( + netuid, + &promoted_hotkey, + &next_owner_hotkey, + ); + SubnetOwnerHotkey::::insert(netuid, next_owner_hotkey); + + // Removing one member after demotion must remove its complete canonical + // contribution. Any aggregate-only owner boost would survive as ghost + // conviction in the promoted hotkey's general bucket. + SubtensorModule::force_reduce_lock(&first_coldkey, netuid, 1_000u64.into()); + + assert!(Lock::::get((first_coldkey, netuid, promoted_hotkey)).is_none()); + let remaining = Lock::::get((second_coldkey, netuid, promoted_hotkey)) + .expect("second member should remain"); + assert_eq!(remaining.locked_mass, 1_000u64.into()); + assert_eq!(remaining.conviction, U64F64::from_num(1_000)); + assert_eq!( + HotkeyLock::::get(netuid, promoted_hotkey), + Some(remaining) + ); + assert!(OwnerLock::::get(netuid).is_none()); + }); +} + +#[test] +fn test_decaying_owner_is_rolled_as_owner_before_demotion() { + new_test_ext(1_000).execute_with(|| { + let netuid = NetUid::from(1); + let old_owner_hotkey = U256::from(1); + let new_owner_hotkey = U256::from(2); + let coldkey = U256::from(10); + let now = SubtensorModule::get_current_block_as_u64(); + let unlock_rate = 200; + let maturity_rate = 300; + let lock = LockState { + locked_mass: 100_000u64.into(), + conviction: U64F64::from_num(100_000), + last_update: now.saturating_sub(100), + }; + + UnlockRate::::put(unlock_rate); + MaturityRate::::put(maturity_rate); + SubnetOwnerHotkey::::insert(netuid, old_owner_hotkey); + SubtensorModule::insert_lock_state(&coldkey, netuid, &old_owner_hotkey, lock.clone()); + DecayingOwnerLock::::insert(netuid, lock.clone()); + + let expected = roll_lock_state(lock, now, unlock_rate, maturity_rate, true, false); + + SubtensorModule::transition_subnet_owner_lock_aggregates( + netuid, + &old_owner_hotkey, + &new_owner_hotkey, + ); + + assert_eq!( + Lock::::get((coldkey, netuid, old_owner_hotkey)), + Some(expected.clone()) + ); + assert_eq!( + DecayingHotkeyLock::::get(netuid, old_owner_hotkey), + Some(expected.clone()) + ); + assert_eq!( + expected.conviction, + U64F64::from_num(expected.locked_mass), + "the outgoing row must retain full owner conviction at the transition block" + ); + }); +} + #[test] fn test_swap_hotkey_locks_moves_owner_hotkey_aggregate_to_owner_lock() { new_test_ext(1).execute_with(|| { @@ -3413,6 +3696,10 @@ fn test_swap_hotkey_locks_moves_owner_hotkey_aggregate_to_owner_lock() { new_owner_hotkey, locking_coldkey ))); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, new_owner_hotkey)).count(), + 1 + ); }); } @@ -4329,6 +4616,10 @@ fn test_hotkey_swap_swaps_locks_and_convictions() { assert!(LockingColdkeys::::contains_key(( netuid, new_hotkey, coldkey ))); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, new_hotkey)).count(), + 1 + ); // Hotkey lock data also updated, conviction is not reset let hotkey_lock = HotkeyLock::::get(netuid, new_hotkey).unwrap(); @@ -4923,6 +5214,107 @@ fn test_neuron_replacement_does_not_affect_lock() { // GROUP 19: Moving lock // ========================================================================= +#[test] +fn test_move_lock_removes_dusted_member_from_aggregate() { + new_test_ext(1).execute_with(|| { + let moving_coldkey = U256::from(1); + let sibling_coldkey = U256::from(4); + let origin_hotkey = U256::from(2); + let destination_hotkey = U256::from(3); + let netuid = setup_subnet_with_stake(moving_coldkey, origin_hotkey, 100_000_000_000); + assert_ok!(SubtensorModule::create_account_if_non_existent( + &moving_coldkey, + &destination_hotkey, + )); + DecayingLock::::insert(sibling_coldkey, netuid, false); + + let now = SubtensorModule::get_current_block_as_u64(); + let dust = LockState { + locked_mass: 99u64.into(), + conviction: U64F64::from_num(99), + last_update: now, + }; + let sibling = LockState { + locked_mass: 1_000u64.into(), + conviction: U64F64::from_num(1_000), + last_update: now, + }; + Lock::::insert((moving_coldkey, netuid, origin_hotkey), dust.clone()); + Lock::::insert((sibling_coldkey, netuid, origin_hotkey), sibling.clone()); + HotkeyLock::::insert( + netuid, + origin_hotkey, + LockState { + locked_mass: dust.locked_mass.saturating_add(sibling.locked_mass), + conviction: dust.conviction.saturating_add(sibling.conviction), + last_update: now, + }, + ); + + assert_ok!(SubtensorModule::do_move_lock( + &moving_coldkey, + &destination_hotkey, + netuid, + )); + + assert!(Lock::::get((moving_coldkey, netuid, origin_hotkey)).is_none()); + assert!(Lock::::get((moving_coldkey, netuid, destination_hotkey)).is_none()); + assert_eq!( + HotkeyLock::::get(netuid, origin_hotkey), + Some(sibling) + ); + assert!(HotkeyLock::::get(netuid, destination_hotkey).is_none()); + }); +} + +#[test] +fn test_transfer_lock_removes_dusted_source_from_aggregate() { + new_test_ext(1).execute_with(|| { + let sender_coldkey = U256::from(1); + let receiver_coldkey = U256::from(5); + let sibling_coldkey = U256::from(4); + let hotkey = U256::from(2); + let netuid = setup_subnet_with_stake(sender_coldkey, hotkey, 100_000_000_000); + DecayingLock::::insert(sibling_coldkey, netuid, false); + + let now = SubtensorModule::get_current_block_as_u64(); + let dust = LockState { + locked_mass: 99u64.into(), + conviction: U64F64::from_num(99), + last_update: now, + }; + let sibling = LockState { + locked_mass: 1_000u64.into(), + conviction: U64F64::from_num(1_000), + last_update: now, + }; + Lock::::insert((sender_coldkey, netuid, hotkey), dust.clone()); + Lock::::insert((sibling_coldkey, netuid, hotkey), sibling.clone()); + HotkeyLock::::insert( + netuid, + hotkey, + LockState { + locked_mass: dust.locked_mass.saturating_add(sibling.locked_mass), + conviction: dust.conviction.saturating_add(sibling.conviction), + last_update: now, + }, + ); + + let total_alpha = SubtensorModule::total_coldkey_alpha_on_subnet(&sender_coldkey, netuid); + assert_ok!(SubtensorModule::transfer_lock( + &sender_coldkey, + &receiver_coldkey, + &hotkey, + netuid, + total_alpha, + )); + + assert!(Lock::::get((sender_coldkey, netuid, hotkey)).is_none()); + assert!(Lock::::get((receiver_coldkey, netuid, hotkey)).is_none()); + assert_eq!(HotkeyLock::::get(netuid, hotkey), Some(sibling)); + }); +} + #[test] fn test_moving_lock() { new_test_ext(1).execute_with(|| { @@ -4975,6 +5367,70 @@ fn test_moving_lock() { }); } +#[test] +fn test_moving_lock_merges_preexisting_destination_individual_and_aggregate() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(1); + let hotkey_a = U256::from(2); + let hotkey_b = U256::from(3); + let netuid = setup_subnet_with_stake(coldkey, hotkey_a, 100_000_000_000); + assert_ok!(SubtensorModule::create_account_if_non_existent( + &coldkey, &hotkey_b, + )); + + let now = SubtensorModule::get_current_block_as_u64(); + let lock_a = LockState { + locked_mass: 5_000u64.into(), + conviction: U64F64::from_num(1_000), + last_update: now, + }; + let lock_b = LockState { + locked_mass: 2_000u64.into(), + conviction: U64F64::from_num(300), + last_update: now, + }; + Lock::::insert((coldkey, netuid, hotkey_a), lock_a.clone()); + HotkeyLock::::insert(netuid, hotkey_a, lock_a); + Lock::::insert((coldkey, netuid, hotkey_b), lock_b.clone()); + HotkeyLock::::insert(netuid, hotkey_b, lock_b); + + // Match production's storage-prefix selection instead of assuming a + // numeric hotkey order under the map's hashing scheme. + let (origin_hotkey, origin) = Lock::::iter_prefix((coldkey, netuid)) + .next() + .expect("one source lock should be selected"); + let destination_hotkey = if origin_hotkey == hotkey_a { + hotkey_b + } else { + hotkey_a + }; + let destination = Lock::::get((coldkey, netuid, destination_hotkey)) + .expect("destination lock should exist"); + + assert_ok!(SubtensorModule::do_move_lock( + &coldkey, + &destination_hotkey, + netuid, + )); + + let expected = LockState { + locked_mass: origin.locked_mass.saturating_add(destination.locked_mass), + conviction: origin.conviction.saturating_add(destination.conviction), + last_update: now, + }; + assert!(Lock::::get((coldkey, netuid, origin_hotkey)).is_none()); + assert!(HotkeyLock::::get(netuid, origin_hotkey).is_none()); + assert_eq!( + Lock::::get((coldkey, netuid, destination_hotkey)), + Some(expected.clone()) + ); + assert_eq!( + HotkeyLock::::get(netuid, destination_hotkey), + Some(expected) + ); + }); +} + #[test] fn test_moving_lock_to_subnet_owner_hotkey_gets_owner_conviction_for_non_owner_coldkey() { new_test_ext(1).execute_with(|| { diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 6805dbe8bd..5e0aee49e4 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -7,7 +7,7 @@ )] use super::mock::*; -use crate::staking::lock::LockState; +use crate::staking::lock::{ConvictionModel, LockState, roll_lock_state}; use crate::*; use alloc::collections::BTreeMap; use approx::{assert_abs_diff_eq, assert_relative_eq}; @@ -369,6 +369,71 @@ fn test_migrate_fix_subnet_hotkey_lock_swaps_moves_or_discards_conflicts() { ); }); } + +#[test] +fn test_runtime_upgrade_fixes_hotkey_swaps_before_rebuilding_aggregates() { + new_test_ext(1).execute_with(|| { + const REMOVE_DEPRECATED_MIGRATION: &[u8] = b"migrate_remove_deprecated_conviction_maps"; + const RESET_MIGRATION: &[u8] = b"migrate_reset_tnet_conviction_locks"; + const SWAP_FIX_MIGRATION: &[u8] = b"migrate_fix_subnet_hotkey_lock_swaps"; + const REBUILD_MIGRATION: &[u8] = b"migrate_rebuild_conviction_aggregates"; + + let old_hotkey = + decode_account_id32::("5Ca8L8PkbqXUtzohKtSM3i1naGQxANGLx51kJsEPNB14Admz") + .expect("old hotkey should decode"); + let new_owner_hotkey = + decode_account_id32::("5Evgh9QTXJLxYLusVy3tcY5S6Z3GgRSNDb9AzXUchX5dco3P") + .expect("new owner hotkey should decode"); + let coldkey = U256::from(28_001); + let netuid = NetUid::from(28); + let lock = LockState { + locked_mass: AlphaBalance::from(10_000_u64), + conviction: U64F64::from_num(10_000), + last_update: 1, + }; + + // Preserve the fixture from the older reset migration while leaving both migrations + // under test pending, exactly as they can be on a runtime upgrade. + HasMigrationRun::::insert(REMOVE_DEPRECATED_MIGRATION, true); + HasMigrationRun::::insert(RESET_MIGRATION, true); + HasMigrationRun::::remove(SWAP_FIX_MIGRATION); + HasMigrationRun::::remove(REBUILD_MIGRATION); + + SubnetOwnerHotkey::::insert(netuid, new_owner_hotkey); + DecayingLock::::insert(coldkey, netuid, false); + Lock::::insert((coldkey, netuid, old_hotkey), lock.clone()); + LockingColdkeys::::insert((netuid, old_hotkey, coldkey), ()); + + // The historical subnet hotkey swap transitioned the aggregate only. The swap-fix + // migration relies on this owner aggregate when moving the canonical individual row. + OwnerLock::::insert(netuid, lock.clone()); + + let _ = as Hooks>::on_runtime_upgrade(); + + assert!(HasMigrationRun::::get(SWAP_FIX_MIGRATION)); + assert!(HasMigrationRun::::get(REBUILD_MIGRATION)); + assert!(Lock::::get((coldkey, netuid, old_hotkey)).is_none()); + assert_eq!( + Lock::::get((coldkey, netuid, new_owner_hotkey)), + Some(lock.clone()) + ); + assert!(!LockingColdkeys::::contains_key(( + netuid, old_hotkey, coldkey + ))); + assert!(LockingColdkeys::::contains_key(( + netuid, + new_owner_hotkey, + coldkey + ))); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, new_owner_hotkey)).count(), + 1 + ); + assert!(HotkeyLock::::get(netuid, old_hotkey).is_none()); + assert_eq!(OwnerLock::::get(netuid), Some(lock)); + }); +} + #[test] fn test_migration_transfer_nets_to_foundation() { new_test_ext(1).execute_with(|| { @@ -1619,6 +1684,283 @@ fn test_migrate_populate_locking_coldkeys_removes_dust_from_aggregate() { }); } +#[test] +fn test_migrate_rebuild_conviction_aggregates_from_individual_locks() { + new_test_ext(1).execute_with(|| { + const MIGRATION_NAME: &[u8] = b"migrate_rebuild_conviction_aggregates"; + + let netuid = NetUid::from(72); + let current_owner = U256::from(7200); + let former_owner = U256::from(7201); + let general_hotkey = U256::from(7202); + let orphan_hotkey = U256::from(7299); + let former_owner_coldkey = U256::from(7210); + let owner_perpetual_coldkey = U256::from(7211); + let owner_decaying_coldkey = U256::from(7212); + let general_perpetual_coldkey_1 = U256::from(7213); + let general_perpetual_coldkey_2 = U256::from(7214); + let general_decaying_coldkey = U256::from(7215); + let now = 1_000u64; + let unlock_rate = 200u64; + let maturity_rate = 300u64; + + System::set_block_number(now); + UnlockRate::::put(unlock_rate); + MaturityRate::::put(maturity_rate); + SubnetOwnerHotkey::::insert(netuid, current_owner); + HasMigrationRun::::remove(MIGRATION_NAME); + + // This reproduces the only SN72 row that still spans the ownership + // transition: a former-owner perpetual lock already at conviction == + // locked_mass. That state remains a fixed point after becoming a + // non-owner, so current-role roll-forward is exact without history. + let former_owner_lock = LockState { + locked_mass: AlphaBalance::from(6_000_u64), + conviction: U64F64::from_num(6_000_u64), + last_update: 100, + }; + let owner_perpetual_lock = LockState { + locked_mass: AlphaBalance::from(50_000_u64), + conviction: U64F64::from_num(20_000_u64), + last_update: 800, + }; + let owner_decaying_lock = LockState { + locked_mass: AlphaBalance::from(40_000_u64), + conviction: U64F64::from_num(10_000_u64), + last_update: 800, + }; + let general_perpetual_lock_1 = LockState { + locked_mass: AlphaBalance::from(10_000_u64), + conviction: U64F64::from_num(1_000_u64), + last_update: 700, + }; + let general_perpetual_lock_2 = LockState { + locked_mass: AlphaBalance::from(20_000_u64), + conviction: U64F64::from_num(2_000_u64), + last_update: 750, + }; + let general_decaying_lock = LockState { + locked_mass: AlphaBalance::from(30_000_u64), + conviction: U64F64::from_num(3_000_u64), + last_update: 700, + }; + + for coldkey in [ + former_owner_coldkey, + owner_perpetual_coldkey, + general_perpetual_coldkey_1, + general_perpetual_coldkey_2, + ] { + DecayingLock::::insert(coldkey, netuid, false); + } + + let seeded_locks = [ + ( + former_owner_coldkey, + former_owner, + former_owner_lock.clone(), + false, + true, + ), + ( + owner_perpetual_coldkey, + current_owner, + owner_perpetual_lock.clone(), + true, + true, + ), + ( + owner_decaying_coldkey, + current_owner, + owner_decaying_lock.clone(), + true, + false, + ), + ( + general_perpetual_coldkey_1, + general_hotkey, + general_perpetual_lock_1.clone(), + false, + true, + ), + ( + general_perpetual_coldkey_2, + general_hotkey, + general_perpetual_lock_2.clone(), + false, + true, + ), + ( + general_decaying_coldkey, + general_hotkey, + general_decaying_lock.clone(), + false, + false, + ), + ]; + + for (coldkey, hotkey, lock, _, _) in &seeded_locks { + Lock::::insert((*coldkey, netuid, *hotkey), lock.clone()); + LockingColdkeys::::insert((netuid, *hotkey, *coldkey), ()); + } + + let corrupt = LockState { + locked_mass: AlphaBalance::from(999_999_u64), + conviction: U64F64::from_num(888_888_u64), + last_update: 999, + }; + HotkeyLock::::insert(netuid, former_owner, corrupt.clone()); + HotkeyLock::::insert(netuid, general_hotkey, corrupt.clone()); + HotkeyLock::::insert(netuid, orphan_hotkey, corrupt.clone()); + DecayingHotkeyLock::::insert(netuid, general_hotkey, corrupt.clone()); + OwnerLock::::insert(netuid, corrupt.clone()); + DecayingOwnerLock::::insert(netuid, corrupt); + + let expected: Vec<_> = seeded_locks + .iter() + .map(|(coldkey, hotkey, lock, owner, perpetual)| { + ( + *coldkey, + *hotkey, + roll_lock_state( + lock.clone(), + now, + unlock_rate, + maturity_rate, + *owner, + *perpetual, + ), + ) + }) + .collect(); + + let weight = crate::migrations::migrate_rebuild_conviction_aggregates:: + migrate_rebuild_conviction_aggregates::(); + + assert!(!weight.is_zero()); + assert!(HasMigrationRun::::get(MIGRATION_NAME)); + assert_eq!(Lock::::iter().count(), seeded_locks.len()); + assert_eq!(LockingColdkeys::::iter().count(), seeded_locks.len()); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, former_owner)).count(), + 1 + ); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, current_owner)).count(), + 2 + ); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, general_hotkey)).count(), + 3 + ); + + for (coldkey, hotkey, expected_lock) in &expected { + assert_eq!( + Lock::::get((*coldkey, netuid, *hotkey)), + Some(expected_lock.clone()) + ); + assert_eq!(expected_lock.last_update, now); + assert!(LockingColdkeys::::contains_key(( + netuid, *hotkey, *coldkey + ))); + } + + let expected_former_owner = expected[0].2.clone(); + assert_eq!( + expected_former_owner.conviction, + U64F64::from_num(expected_former_owner.locked_mass) + ); + assert_eq!( + HotkeyLock::::get(netuid, former_owner), + Some(expected_former_owner) + ); + + let mut expected_general_perpetual = expected[3].2.clone(); + expected_general_perpetual.locked_mass = expected_general_perpetual + .locked_mass + .saturating_add(expected[4].2.locked_mass); + expected_general_perpetual.conviction = expected_general_perpetual + .conviction + .saturating_add(expected[4].2.conviction); + assert_eq!( + HotkeyLock::::get(netuid, general_hotkey), + Some(expected_general_perpetual) + ); + assert_eq!( + DecayingHotkeyLock::::get(netuid, general_hotkey), + Some(expected[5].2.clone()) + ); + assert_eq!(OwnerLock::::get(netuid), Some(expected[1].2.clone())); + assert_eq!( + DecayingOwnerLock::::get(netuid), + Some(expected[2].2.clone()) + ); + assert!(HotkeyLock::::get(netuid, orphan_hotkey).is_none()); + + let aggregate_snapshot = ( + HotkeyLock::::iter().collect::>(), + DecayingHotkeyLock::::iter().collect::>(), + OwnerLock::::iter().collect::>(), + DecayingOwnerLock::::iter().collect::>(), + ); + let second_weight = crate::migrations::migrate_rebuild_conviction_aggregates:: + migrate_rebuild_conviction_aggregates::(); + assert_eq!( + second_weight, + ::DbWeight::get().reads(1) + ); + assert_eq!( + aggregate_snapshot, + ( + HotkeyLock::::iter().collect::>(), + DecayingHotkeyLock::::iter().collect::>(), + OwnerLock::::iter().collect::>(), + DecayingOwnerLock::::iter().collect::>(), + ) + ); + }); +} + +#[test] +fn test_migrate_rebuild_conviction_aggregates_removes_dust_and_orphan_index_rows() { + new_test_ext(1).execute_with(|| { + const MIGRATION_NAME: &[u8] = b"migrate_rebuild_conviction_aggregates"; + + let netuid = NetUid::from(73); + let coldkey = U256::from(7300); + let orphan_coldkey = U256::from(7301); + let hotkey = U256::from(7310); + let orphan_hotkey = U256::from(7311); + let now = 1_000u64; + + System::set_block_number(now); + UnlockRate::::put(1); + MaturityRate::::put(1); + HasMigrationRun::::remove(MIGRATION_NAME); + + let decayed_to_dust = LockState { + locked_mass: AlphaBalance::from(1_000_u64), + conviction: U64F64::from_num(0), + last_update: 1, + }; + Lock::::insert((coldkey, netuid, hotkey), decayed_to_dust.clone()); + LockingColdkeys::::insert((netuid, hotkey, coldkey), ()); + LockingColdkeys::::insert((netuid, orphan_hotkey, orphan_coldkey), ()); + DecayingHotkeyLock::::insert(netuid, hotkey, decayed_to_dust.clone()); + HotkeyLock::::insert(netuid, orphan_hotkey, decayed_to_dust); + + crate::migrations::migrate_rebuild_conviction_aggregates:: + migrate_rebuild_conviction_aggregates::(); + + assert!(Lock::::get((coldkey, netuid, hotkey)).is_none()); + assert_eq!(LockingColdkeys::::iter().count(), 0); + assert_eq!(HotkeyLock::::iter().count(), 0); + assert_eq!(DecayingHotkeyLock::::iter().count(), 0); + assert_eq!(OwnerLock::::iter().count(), 0); + assert_eq!(DecayingOwnerLock::::iter().count(), 0); + }); +} + #[test] fn test_migrate_fix_staking_hot_keys() { new_test_ext(1).execute_with(|| { diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index 14ae4a2254..db0a4aa86a 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -1132,9 +1132,9 @@ fn destroy_alpha_in_out_stakes_cleans_locking_coldkeys() { }; Lock::::insert((coldkey, netuid, hotkey), lock.clone()); - LockingColdkeys::::insert((netuid, hotkey, coldkey), ()); + SubtensorModule::add_locking_coldkey(&hotkey, netuid, &coldkey); Lock::::insert((coldkey, other_netuid, hotkey), lock); - LockingColdkeys::::insert((other_netuid, hotkey, coldkey), ()); + SubtensorModule::add_locking_coldkey(&hotkey, other_netuid, &coldkey); DissolveCleanupQueue::::set(vec![netuid]); run_block_idle(); @@ -1143,12 +1143,20 @@ fn destroy_alpha_in_out_stakes_cleans_locking_coldkeys() { assert!(!LockingColdkeys::::contains_key(( netuid, hotkey, coldkey ))); + assert_eq!( + LockingColdkeys::::iter_prefix((netuid, hotkey)).count(), + 0 + ); assert!(Lock::::contains_key((coldkey, other_netuid, hotkey))); assert!(LockingColdkeys::::contains_key(( other_netuid, hotkey, coldkey ))); + assert_eq!( + LockingColdkeys::::iter_prefix((other_netuid, hotkey)).count(), + 1 + ); }); } @@ -2710,13 +2718,13 @@ fn dissolve_clears_all_lock_maps_for_removed_network() { // --- Lock: (coldkey, netuid, hotkey) Lock::::insert((cold_1, net, hot_1), lock_a.clone()); - LockingColdkeys::::insert((net, hot_1, cold_1), ()); + SubtensorModule::add_locking_coldkey(&hot_1, net, &cold_1); Lock::::insert((cold_2, net, hot_2), lock_b.clone()); - LockingColdkeys::::insert((net, hot_2, cold_2), ()); + SubtensorModule::add_locking_coldkey(&hot_2, net, &cold_2); // Same cold/hot on another net should survive. Lock::::insert((cold_1, other_net, hot_1), lock_a.clone()); - LockingColdkeys::::insert((other_net, hot_1, cold_1), ()); + SubtensorModule::add_locking_coldkey(&hot_1, other_net, &cold_1); // --- HotkeyLock HotkeyLock::::insert(net, hot_1, lock_a.clone()); @@ -2773,6 +2781,11 @@ fn dissolve_clears_all_lock_maps_for_removed_network() { assert!(!Lock::::contains_key((cold_2, net, hot_2))); assert!(!LockingColdkeys::::contains_key((net, hot_1, cold_1))); assert!(!LockingColdkeys::::contains_key((net, hot_2, cold_2))); + assert!( + LockingColdkeys::::iter_prefix((net,)) + .next() + .is_none() + ); assert!(!HotkeyLock::::contains_key(net, hot_1)); assert!(!HotkeyLock::::contains_key(net, hot_2)); @@ -2796,6 +2809,10 @@ fn dissolve_clears_all_lock_maps_for_removed_network() { assert!(LockingColdkeys::::contains_key(( other_net, hot_1, cold_1 ))); + assert_eq!( + LockingColdkeys::::iter_prefix((other_net, hot_1)).count(), + 1 + ); assert!(HotkeyLock::::contains_key(other_net, hot_1)); assert!(DecayingHotkeyLock::::contains_key(other_net, hot_1)); assert!(OwnerLock::::contains_key(other_net)); diff --git a/precompiles/src/staking.rs b/precompiles/src/staking.rs index e0015ac8f8..1d07626047 100644 --- a/precompiles/src/staking.rs +++ b/precompiles/src/staking.rs @@ -574,7 +574,7 @@ where let now = pallet_subtensor::Pallet::::get_current_block_as_u64(); let owner_lock = hotkey == pallet_subtensor::SubnetOwnerHotkey::::get(netuid); - let (lock, _) = pallet_subtensor::staking::lock::ConvictionModel::roll_forward_lock( + let lock = pallet_subtensor::staking::lock::roll_lock_state( lock, now, pallet_subtensor::UnlockRate::::get(), @@ -582,7 +582,7 @@ where owner_lock, perpetual, ); - let exists = !lock.is_zero(); + let exists = !lock.is_dust(); let hotkey: [u8; 32] = hotkey.into(); Ok(( @@ -619,7 +619,7 @@ where owner_lock: bool, perpetual_lock: bool| { if let Some(lock) = maybe_lock { - let (lock, _) = pallet_subtensor::staking::lock::ConvictionModel::roll_forward_lock( + let lock = pallet_subtensor::staking::lock::roll_lock_state( lock, now, unlock_rate, @@ -3026,16 +3026,15 @@ mod tests { frame_system::Pallet::::set_block_number(100); let raw_lock = pallet_subtensor::Lock::::get((&coldkey, netuid, &hotkey)) .expect("stale individual lock remains in storage"); - let (rolled_lock, _) = - pallet_subtensor::staking::lock::ConvictionModel::roll_forward_lock( - raw_lock, - 100, - pallet_subtensor::UnlockRate::::get(), - pallet_subtensor::MaturityRate::::get(), - true, - false, - ); - assert!(rolled_lock.is_zero()); + let rolled_lock = pallet_subtensor::staking::lock::roll_lock_state( + raw_lock, + 100, + pallet_subtensor::UnlockRate::::get(), + pallet_subtensor::MaturityRate::::get(), + true, + false, + ); + assert!(rolled_lock.is_dust()); precompiles .prepare_test( diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 185469a8dc..87cda31f7c 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -235,7 +235,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: 452, + spec_version: 453, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1,