From 8099812a14c4df2484eebf77539c58e47f01d4fe Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Fri, 13 Jun 2025 12:18:08 -0400 Subject: [PATCH 1/2] Add subsubnets proposal --- bits/BIT-0006-subsubnets.md | 114 ++++++++++++++++++++++++++++++++++++ bits/subsubnet-test-plan.md | 77 ++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 bits/BIT-0006-subsubnets.md create mode 100644 bits/subsubnet-test-plan.md diff --git a/bits/BIT-0006-subsubnets.md b/bits/BIT-0006-subsubnets.md new file mode 100644 index 0000000..f3da2a5 --- /dev/null +++ b/bits/BIT-0006-subsubnets.md @@ -0,0 +1,114 @@ +# BIT-0006: Sub-subnets + +- **BIT Number:** 0006 +- **Title:** Sub-subnets +- **Author(s):** Rhef, Greg Zaitsev +- **Discussions-to:** [URL for discussion thread] +- **Status:** Draft +- **Type:** Core +- **Created:** 2025-06-13 +- **Updated:** 2025-06-13 + +## πŸ” Abstract + +This BIT proposes the introduction of subsubnets, a hierarchical layer within each subnet to support multiple, independent weight spaces, emissions, and incentive flows under a single subnet umbrella. Subsubnets enable fine-grained control over miner task allocation, incentive distribution, and validator decision-making. Each subnet can define up to 8 subsubnets, with weights and rewards tracked independently per subsubnet. + +## πŸ”§ Motivation + +Subnets today treat miner weights as a single flat structure, limiting expressivity in multi-task or multi-objective networks. Introducing subsubnets allows a single subnet to support multiple distinct task markets, enabling specialized incentive tracking, validator scoring, and emission logic per group. This facilitates complex workloads, parallel task handling, and flexible protocol design without fragmenting network security or duplicating validator logic. + +## πŸ§ͺ Specification + +### Subsubnet Limits +- Each subnet defines a `desired_subsubnet_limit` hyperparameter (default = 1). +- A global limit `global_subsubnet_limit_per_subnet` acts as a ceiling. +- The active value `subsubnet_limit_in_force` is updated every subnet superblock (every 20 tempos) as: + +``` +subsubnet_limit_in_force = min( + desired_subsubnet_limit, + global_subsubnet_limit_per_subnet, + subsubnet_limit_in_force_last + global_subsubnet_decrease_per_subnet_superblock +) +``` + +### Weight Operations +- `set_weights`, `commit_weights`, and `reveal_weights` accept a `subsubnet_id` argument. +- Legacy operations default to `subsubnet_id = 0`. +- Weight writes are disallowed above the current `subsubnet_limit_in_force`. + +### Validator Permissions +- Only the subnet owner or sudo can change the `desired_subsubnet_limit` or emission proportions. +- Validators may set weights for any subsubnet within the current limit. + +### Emission Logic +- Emission is distributed across subsubnets according to a configured ratio (default Fibonacci: [1, 2, 3, 5, 8, 13, 21, 34]). +- Each subsubnet computes trust, consensus, and incentive separately. +- Final vtrust is aggregated across subsubnets weighted by their emissions. +- Rounding is preserved across subsubnet splits to ensure exact conservation of emitted tokens. + +### Edge Cases +- If a subsubnet has zero consensus, it enters β€œYuma emergency mode” and allocates emission proportional to stake. +- Miners without weights in a subsubnet receive no emission from it. +- Subsubnets with no miners are gracefully handled. + +### Compatibility +- Subnets with a single subsubnet behave exactly as today (ID 0). +- Legacy miners/validators interoperate with subsubnet-enabled subnets via `subsubnet_id = 0`. +- Storage and RPC interfaces remain backward-compatible. + +## βœ… Rationale + +Subsubnets allow a single subnet to support multiple incentive partitions, enabling more advanced use cases (e.g. routing, filtering, classification, multitask models). They preserve validator overhead by avoiding new subnets while enabling greater expressivity. The design enforces strict backward compatibility and safe transitions when limits change. + +## πŸ“˜ Reference Implementation + +- Will be implemented in the `subtensor` core repo. +- Interfaces for weight setting, emission, and validator ranking will be extended to include `subsubnet_id`. + +## 🧱 Backward Compatibility + +- All existing weight operations apply to `subsubnet_id = 0`. +- Subnets not opting into subsubnets will remain functionally identical. +- Miners and validators on older versions will continue functioning under subsubnet_id 0. + +## πŸ“ˆ Test Cases + +See BIT test document `subsubnet_test_plan.bit`. + +## πŸ’¬ Discussion + +- Emission proportion customization per subsubnet opens design space for subnet-specific task prioritization. +- Validator voting on miner subsubnet weights (via kappa) may be used for consensus and reward routing. +- Handling of dynamic emission distribution and cleanups when limits decrease must be conservative and race-free. + +## πŸ› οΈ Future Work + +- **Custom Emission Proportions**: Subnet owners will be able to customize the proportion of emission allocated to each subsubnet, enabling tailored incentive strategies based on task complexity or utility. + +- **Dynamic Global Subsubnet Limit**: A globally enforced ceiling on subsubnet counts will be adjustable over time. Reductions to this limit will automatically trigger cleanup of excess subsubnet data across all subnets. + +- **Hyperparameter Governance**: Subnet owners will gain control over additional subsubnet-specific hyperparameters beyond the subsubnet limit, allowing more granular tuning of behavior. + +- **Validator-Driven Incentive Routing**: Using the kappa stake majority mechanism, validators may vote to adjust miner incentive shares within subsubnets, supporting flexible prioritization of behaviors and tasks. + +- **Additional Governance Extensions**: Future extensions may include subsubnet-specific pruning policies, trust calculation curves, or dynamic validator selection strategies. + + +## πŸ” Security Considerations + +The introduction of subsubnets introduces additional state surfaces and per-subsubnet tracking, which must be secured against manipulation: + +- **Permission Enforcement**: Only subnet owners or sudo must be able to modify `desired_subsubnet_limit`, emission proportions, or trigger subsubnet resets. Improper permission checks could allow hostile takeovers of reward logic. + +- **Weight Isolation**: Weights across subsubnets must remain isolated. Cross-contamination could allow miners to gain rewards in unintended subsubnets. + +- **Rounding and Overflow**: Emission rounding and aggregation must be implemented carefully to prevent underflow/overflow or token inflation. + +- **Backward Compatibility**: Any logic paths introduced for subsubnet IDs must default to safe values (e.g., subsubnet_id = 0) to avoid denial-of-service for legacy miners and validators. + +- **Cleanups**: When limits are decreased, weight purging must be idempotent and bounded to prevent validator or miner state desynchronization. + +## Β© Copyright + +This document is licensed under [The Unlicense](https://unlicense.org/). diff --git a/bits/subsubnet-test-plan.md b/bits/subsubnet-test-plan.md new file mode 100644 index 0000000..bb98acf --- /dev/null +++ b/bits/subsubnet-test-plan.md @@ -0,0 +1,77 @@ +# Subsubnet Functionality BIT + +## Test Areas + +### βœ… Weight Cleanup +- [ ] Weights are cleaned when `subsubnet_limit_in_force` decreases +- [ ] For each subsubnet, when a miner is deregistered or leaves, their weights are cleaned across **all** subsubnets + +### βœ… Limit Update Logic +- [ ] Decreasing `desired_subsubnet_limit` reduces `subsubnet_limit_in_force` by no more than `global_subsubnet_decrease_per_subnet_superblock` +- [ ] Validate update timing during the subnet superblock (every 20 tempos) +- [ ] Confirm fallback to `min(desired_subsubnet_limit, global_subsubnet_limit_per_subnet)` + +### βœ… Validator Permissions & Hyperparameters +- [ ] Ensure only subnet owners (or sudo) can modify their subnet's subsubnet hyperparameters +- [ ] Max allowed `desired_subsubnet_limit` is 8 (ids 0-7) +- [ ] Validators **cannot** modify other subnets’ parameters +- [ ] `desired_subsubnet_limit` is readable by API per subnet and globally + +### βœ… Compatibility with Existing Systems +- [ ] Confirm `subsubnet_id = 0` acts as default fallback for weight operations +- [ ] Validate `set_weights`, `commit_weights`, `reveal_weights` apply correctly on subsubnet_id=0 +- [ ] Confirm legacy operations still work as expected when subsubnet feature is not used (regression) + +### βœ… Weight Setting Restrictions +- [ ] Prevent weight setting above `subsubnet_limit_in_force` +- [ ] Prevent CR2/CR3 weight commits/reveals for disabled subsubnets +- [ ] Validators **can** set weights above hyperparameter but below `subsubnet_limit_in_force` + +### βœ… Miner-Subsubnet Interaction +- [ ] Miner can participate in multiple or all subsubnets (bond, weights, rewards) +- [ ] Support miner existence with no weights on any subsubnet +- [ ] Support subsubnet existence with no miner weights at all +- [ ] Ensure correct weights can be retrieved per miner per subsubnet + +### βœ… Emissions and Incentives +- [ ] Ensure `subsubnet_limit_in_force` does not exceed global limit +- [ ] Validate weight independence across subsubnets +- [ ] Check total emission is split among subsubnets based on pre-defined distribution (Fibonacci default): + - id0 = 1 + - id1 = 2 + - id2 = 3 + - id3 = 5 + - id4 = 8 + - id5 = 13 + - id6 = 21 + - id7 = 34 +- [ ] Validate that per-subsubnet incentives are distributed proportionally to miner weights +- [ ] Trust, vtrust, consensus, etc. are calculated per subsubnet and then aggregated +- [ ] Rounding logic does not lose incentive +- [ ] Sum of all subsubnet emissions matches total emission + +### βœ… Emergency and Recycling Behavior +- [ ] Empty subsubnet enters "Yuma Emergency Mode", emission distributed by stake +- [ ] If consensus sum is 0 for a subsubnet, trigger emergency fallback +- [ ] Recycling incentives per subsubnet via validator vote by subnet owner ID +- [ ] Miner without any subsubnet weights receives **no** reward + +### βœ… Subnet Compatibility Testing +- [ ] Subnets with `desired_subsubnet_limit = 1` (id = 0 only) operate without change +- [ ] Regression tests confirm staking, pruning, emissions, and validator selection work as before +- [ ] Switching subsubnets off restores old behavior without side effects +- [ ] Storage layout and RPC responses remain compatible for older miners/validators +- [ ] Validate upgrade paths: backward compatibility for older nodes (v2+) + +### βœ… Governance and Parameter Controls +- [ ] Subnet owner can set incentive proportions per subsubnet +- [ ] Global subsubnet limit is dynamically adjustable +- [ ] Decreasing global limit propagates cleanups in all subnets +- [ ] Subnet owners can update subsubnet-specific hyperparameters +- [ ] Validator vote (via `kappa`) determines miner emission share per subsubnet + +--- + +## References +- Implements part of: Subsubnet BIP (TBD) +- Affects: Miner incentives, emission mechanics, subnet behavior From d96cd440d564ee5987bfaf4195f4c00cf091c020 Mon Sep 17 00:00:00 2001 From: Greg Zaitsev Date: Wed, 23 Jul 2025 17:50:00 -0400 Subject: [PATCH 2/2] Add BIT-0013, stake locks --- bits/BIT-0013-subnet-locks.md | 713 ++++++++++++++++++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 bits/BIT-0013-subnet-locks.md diff --git a/bits/BIT-0013-subnet-locks.md b/bits/BIT-0013-subnet-locks.md new file mode 100644 index 0000000..ab4d167 --- /dev/null +++ b/bits/BIT-0013-subnet-locks.md @@ -0,0 +1,713 @@ +# BIT-0013: Stake locks + +- **BIT Number:** 0013 +- **Title:** Stake Locks +- **Author(s):** Greg Zaitsev +- **Discussions-to:** [URL for discussion thread] +- **Status:** Draft +- **Type:** Core +- **Created:** 2025-07-23 +- **Updated:** 2025-07-23 + +**Note:** This BIT in fact tables the (PR #1860)[https://github.com/opentensor/subtensor/pull/1860]. + +## πŸ” Abstract + +This BIT proposes introduction of stake locks, where lock conviction will determine subnet ownership. + +## πŸ”§ Motivation + +There are some low quality subnets in the ecosystem, we can call them abandoned subnets. They are not actively managed by their owners and (1) are the source of attacks on the bittensor network because they can be occupied/taken over by malignant neurons, and (2) waste bittensor compute resource. One way to address the issue is allowing the subnet to be taken over by staking and locking a significant amount of TAO. + +## πŸ§ͺ Specification + +- Change subnet owner based on subnet stake lock. Locks are linear with 100% being locked at the start block and 0% on the end block. Any staker can lock their stake. Approximately once per month (7200 * 30 blocks) the lock EMAs are recalculated and, if stake lock EMA is the highest for some account, it becomes the owner of the subnet. +- If stake is locked, then only unlocked portion of it can be unstaked at a given block. +- In order to prevent subnet sniping, the lock EMA is applied, which gives subnet owner enough warning to act. +- Conviction should also increase with higher lock duration (not implemented in reference impl). + +### State maps + +Additional state maps to handle the stake locks are: + +```rust +/// ====================== +/// ==== Stake locks ===== +/// ====================== +#[pallet::storage] +/// --- ITEM ( lock_interval_blocks ) | Stake lock EMA half-life factor +pub type LockIntervalBlocks = + StorageValue<_, u64, ValueQuery, DefaultLockIntervalBlocks>; + +#[pallet::storage] +/// --- NMAP ( netuid, hot, cold ) --> stake_lock | Returns the stake_lock struct for netuid, hot and cold triplet. +pub type Locks = StorageNMap< + _, + ( + NMapKey, // subnet + NMapKey, // hot + NMapKey, // cold + ), + StakeLock, + ValueQuery, +>; + +#[pallet::storage] +/// --- NMAP ( netuid, hot, cold ) --> stake conviction ema | Returns the stake conviction EMA for netuid, hot and cold triplet. +pub type ConvictionEma = StorageNMap< + _, + ( + NMapKey, // subnet + NMapKey, // hot + NMapKey, // cold + ), + AlphaCurrency, + ValueQuery, +>; +``` + +### Extrinsic to lock stake + +```rust +pub fn lock_stake( + origin: OriginFor, + hotkey: T::AccountId, + netuid: NetUid, + duration: u64, + alpha_locked: AlphaCurrency, +) -> DispatchResult { + Self::do_lock(origin, hotkey, netuid, duration, alpha_locked) +} +``` + +### Extrinsic implementation + +```rust +use super::*; +use safe_math::*; +use substrate_fixed::types::{I96F32, U64F64}; +use subtensor_runtime_common::NetUid; + +#[freeze_struct("f92b0bb7408af4d8")] +#[derive( + Clone, Copy, Decode, Default, Encode, Eq, MaxEncodedLen, PartialEq, RuntimeDebug, TypeInfo, +)] +pub struct StakeLock { + pub alpha_locked: AlphaCurrency, + pub start_block: u64, + pub end_block: u64, +} + +impl Pallet { + /// Sets the lock interval in blocks. + /// + /// This function updates the minimum duration for which stakes can be locked. + /// + /// # Arguments + /// + /// * `new_interval` - The new lock interval in blocks. + /// + /// # Events + /// + /// Emits a `LockIntervalSet` event with the new interval value. + pub fn set_lock_interval_blocks(new_interval: u64) { + // Update the lock interval storage + LockIntervalBlocks::::put(new_interval); + + // Emit an event for the new lock interval + Self::deposit_event(Event::LockIntervalSet { new_interval }); + } + + /// Gets the current lock interval in blocks. + /// + /// This function retrieves the current value of the lock interval. + /// + /// # Returns + /// + /// * `u64` - The current lock interval in blocks. + pub fn get_lock_interval_blocks() -> u64 { + LockIntervalBlocks::::get() + } + + /// Calculates the conviction score for a specific hotkey and coldkey pair on a given subnet. + /// + /// This function retrieves the locked stake amount from the `Locks` storage and calculates + /// the conviction score based on the locked amount and the lock duration. + /// + /// # Arguments + /// + /// * `hotkey` - The hotkey account ID. + /// * `coldkey` - The coldkey account ID. + /// * `netuid` - The subnet ID. + /// + /// # Returns + /// + /// * `AlphaCurrency` - The conviction score calculated from the locked stake. + pub fn get_conviction_for_hotkey_and_coldkey_on_subnet( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + netuid: NetUid, + ) -> AlphaCurrency { + let stake_lock = Locks::::get((netuid, hotkey.clone(), coldkey.clone())); + + Self::calculate_conviction(&stake_lock, Self::get_current_block_as_u64()) + } + + /// Locks a specified amount of stake for a given duration on a subnet. + /// + /// This function allows a user to lock their stake, increasing their conviction score. + /// The locked stake cannot be withdrawn until the lock period expires, and the new lock + /// must not decrease the current conviction score. + /// + /// # Arguments + /// + /// * `origin` - The origin of the call, must be signed by the coldkey. + /// * `hotkey` - The hotkey associated with the stake to be locked. + /// * `netuid` - The ID of the subnet where the stake is locked. + /// * `duration` - The duration (in blocks) for which the stake will be locked. + /// * `alpha_locked` - The amount of stake to be locked. + /// + /// # Returns + /// + /// * `DispatchResult` - The result of the lock operation. + /// + /// # Errors + /// + /// * `SubnetNotExists` - If the specified subnet does not exist. + /// * `HotKeyAccountNotExists` - If the hotkey account does not exist. + /// * `HotKeyNotRegisteredInSubNet` - If the hotkey is not registered on the specified subnet. + /// * `NotEnoughStakeToWithdraw` - If the user doesn't have enough stake to lock, or if the new lock would decrease the current conviction. + /// + /// # Events + /// + /// * `LockIncreased` - Emitted when the lock is successfully increased. + /// + /// # TODO + /// + /// * Consider implementing a maximum lock duration to prevent excessively long locks. + /// * Implement a mechanism to partially unlock stakes as the lock period progresses. + /// * Add more granular error handling for different failure scenarios. + pub fn do_lock( + origin: T::RuntimeOrigin, + hotkey: T::AccountId, + netuid: NetUid, + duration: u64, + alpha_locked: AlphaCurrency, + ) -> dispatch::DispatchResult { + // Step 1: Validate inputs and check conditions + // Ensure the origin is valid. + let coldkey = ensure_signed(origin)?; + + // Ensure that the subnet exists. + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + + // Ensure that the hotkey account exists. + ensure!( + Self::hotkey_account_exists(&hotkey), + Error::::HotKeyAccountNotExists + ); + + // Ensure the the lock is above zero. + ensure!( + alpha_locked > 0.into(), + Error::::NotEnoughStakeToWithdraw + ); + + // Ensure the lock duration is at least the minimum + ensure!( + duration >= DefaultMinLockDuration::::get(), + Error::::DurationTooShort + ); + + // Get the lockers current stake. + let current_alpha_stake = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); + + // Ensure that the caller has enough stake to lock. + ensure!( + alpha_locked <= current_alpha_stake, + Error::::NotEnoughStakeToWithdraw + ); + + // Step 2: Calculate and compare convictions + // Get the current block. + let current_block = Self::get_current_block_as_u64(); + let new_end_block = current_block.saturating_add(duration); + + // Check that we are not decreasing the current conviction. + if Locks::::contains_key((netuid, hotkey.clone(), coldkey.clone())) { + // Get the current lock. + let stake_lock = Locks::::get((netuid, &hotkey, &coldkey)); + + // Calculate the current conviction. + let current_conviction = Self::calculate_conviction(&stake_lock, current_block); + + // Calculate the new conviction. + let new_conviction = Self::calculate_conviction( + &StakeLock { + alpha_locked, + start_block: current_block, + end_block: new_end_block, + }, + current_block, + ); + + // Ensure the new lock does not decrease the current conviction + ensure!( + new_conviction >= current_conviction, + Error::::NotEnoughStakeToWithdraw + ); + } + + // Step 3: Set the new lock + Locks::::insert( + (netuid, hotkey.clone(), coldkey.clone()), + StakeLock { + alpha_locked, + start_block: current_block, + end_block: current_block.saturating_add(duration), + }, + ); + + // Step 4: Emit event and return + // Lock increased event. + log::info!( + "LockIncreased( coldkey:{:?}, hotkey:{:?}, netuid:{:?}, alpha_locked:{:?} )", + coldkey.clone(), + hotkey.clone(), + netuid, + alpha_locked + ); + Self::deposit_event(Event::LockIncreased { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + netuid, + alpha_locked, + }); + + // Ok and return. + Ok(()) + } + + /// Updates the stake lock EMAs and owners of all subnets periodically. + /// + /// This function checks if it's time to update subnet owners based on the current block number + /// and a predefined update interval. If the condition is met, it iterates through all subnet + /// network IDs and calls the `update_subnet_owner` function for each subnet. + /// + /// # Details + /// - The update interval is set to 7200 * 15 blocks (approximately 15 days, assuming 7200 blocks per day). + /// - The update is triggered every two intervals (30 days) when the current block number is divisible by twice the update interval. + /// + /// # Effects + /// - When the update condition is met, it calls `update_subnet_owner` for each subnet, + /// potentially changing the owner of each subnet based on conviction scores. + pub fn update_stake_locks(current_block: u64) { + let update_interval = 216_000; // Approx 30 days. + if current_block.checked_rem(update_interval).unwrap_or(1) == 0 { + for netuid in Self::get_all_subnet_netuids() { + Self::update_subnet_owner(netuid, update_interval); + } + } + } + + /// Calculates the exponentially moving average (EMA) of conviction for a given (hotkey, coldkey) pair in a subnet. + /// + /// # Arguments + /// + /// * `netuid` - The identifier of the subnet. + /// * `update_period` - The number of blocks since the last update. Used to compute the smoothing factor. + /// * `conviction` - The current conviction value to blend into the EMA. + /// * `hotkey` - The hotkey account associated with the stake lock. + /// * `coldkey` - The coldkey account associated with the stake lock. + /// + /// # Returns + /// + /// Returns the updated conviction EMA as a `u64`. + /// + /// # Description + /// + /// This function uses the formula: + /// + /// ```text + /// new_ema = old_ema * (1 - alpha) + conviction * alpha + /// where alpha = update_period / lock_interval + /// ``` + /// + /// - If `alpha` (smoothing factor) exceeds `1.0`, it is capped at `1.0`. + /// - `ConvictionEma` is retrieved from storage for the key `(netuid, hotkey, coldkey)`. + /// - Floating point arithmetic is performed using `U64F64` fixed-point type to maintain precision. + /// + /// # Notes + /// + /// - This function is pure: it does not mutate any storage. + /// - Use the result to update `ConvictionEma` if necessary. + /// - Zero LockIntervalBLocks will result in constant EMA + /// + pub fn get_conviction_ema( + netuid: NetUid, + update_period: u64, + conviction: AlphaCurrency, + hotkey: &T::AccountId, + coldkey: &T::AccountId, + ) -> AlphaCurrency { + let one = U64F64::saturating_from_num(1.0); + let zero = U64F64::saturating_from_num(1.0); + let lock_interval_blocks = U64F64::saturating_from_num(Self::get_lock_interval_blocks()); + let mut smoothing_factor = + U64F64::saturating_from_num(update_period).safe_div_or(lock_interval_blocks, zero); + if smoothing_factor > one { + smoothing_factor = one; + } + + let old_ema = + U64F64::saturating_from_num(ConvictionEma::::get((netuid, hotkey, coldkey))); + + AlphaCurrency::from( + old_ema + .saturating_mul(one.saturating_sub(smoothing_factor)) + .saturating_add( + smoothing_factor.saturating_mul(U64F64::saturating_from_num(conviction)), + ) + .saturating_to_num::(), + ) + } + + /// Determines the subnet owner based on the highest conviction score. + /// + /// This function calculates the conviction score for each hotkey in the subnet, + /// considering the lock amount and duration. The hotkey with the highest total + /// conviction score becomes the subnet owner. + /// + /// # Arguments + /// * `netuid` - The network ID of the subnet + /// * `update_period` - How frequently this call is made (for EMA calculation) + /// + /// # Effects + /// * Updates the SubnetOwner storage item with the coldkey of the highest conviction hotkey + pub fn update_subnet_owner(netuid: NetUid, update_period: u64) { + let current_block = Self::get_current_block_as_u64(); + + // Get the updated current owner's conviction first + let owner_coldkey = SubnetOwner::::get(netuid); + let owner_hotkey = SubnetOwnerHotkey::::get(netuid); + let owner_lock = Locks::::get((netuid, owner_hotkey.clone(), owner_coldkey.clone())); + let updated_owner_conviction = Self::calculate_conviction(&owner_lock, current_block); + let mut updated_owner_conviction_ema = Self::get_conviction_ema( + netuid, + update_period, + updated_owner_conviction, + &owner_hotkey, + &owner_coldkey, + ); + let mut new_owner_coldkey = owner_coldkey.clone(); + let mut new_owner_hotkey = owner_hotkey.clone(); + let mut owner_updated = false; + let mut total_conviction = AlphaCurrency::from(0); + + for ((hotkey, coldkey), stake_lock) in Locks::::iter_prefix((netuid,)) { + // Update EMAs. The update value depends on the update_period so that even if we change how + // frequently we update EMAs, the EMA curve doesn't change (except getting less or more + // accurate) + + let new_conviction = Self::calculate_conviction(&stake_lock, current_block); + let new_ema = + Self::get_conviction_ema(netuid, update_period, new_conviction, &hotkey, &coldkey); + ConvictionEma::::insert((netuid, hotkey.clone(), coldkey.clone()), new_ema); + total_conviction = total_conviction.saturating_add(new_conviction); + + // In case of a tie, lower value coldkey wins + if (new_ema > updated_owner_conviction_ema) + || (new_ema == updated_owner_conviction_ema && coldkey < new_owner_coldkey) + { + new_owner_coldkey = coldkey; + new_owner_hotkey = hotkey; + updated_owner_conviction_ema = new_ema; + owner_updated = true; + } + } + + // Implement a minimum conviction threshold for becoming a subnet owner + let min_conviction_threshold = AlphaCurrency::from(1000); // TODO: adjust as needed + if total_conviction < min_conviction_threshold { + owner_updated = false; + } + + // Set the subnet owner to the coldkey of the hotkey with highest conviction + if owner_updated { + SubnetOwner::::insert(netuid, new_owner_coldkey.clone()); + SubnetOwnerHotkey::::insert(netuid, new_owner_hotkey.clone()); + } + + // Update subnet locked + SubnetLocked::::insert(netuid, total_conviction); + } + + /// Calculates the conviction score for a locked stake. + /// + /// This function computes a conviction score based on the amount of locked stake, the time + /// this lock existed (since start_block) and the remaining lock duration. The score increases + /// with both the lock amount and duration, but with diminishing returns for longer lock + /// periods. + /// + /// # Arguments + /// + /// * `lock_amount` - The amount of stake locked, as a u64. + /// * `start_block` - The block number when the lock was set, as a u64. + /// * `end_block` - The block number when the lock expires, as a u64. + /// * `current_block` - The current block number, as a u64. + /// + /// # Returns + /// + /// * A u64 representing the calculated conviction score. + /// + /// # Formula + /// + /// The conviction score is linear of blocks, starting with 100% locked at start_block and going + /// down to 0% locked at the end_block: + /// + /// conviction = alpha_locked * (ax + b), + /// + /// where a = 1 / (start_block - end_block) + /// b = end_block / (end_block - start_block) + /// x is current block + /// + pub fn calculate_conviction(lock: &StakeLock, current_block: u64) -> AlphaCurrency { + // Handle corner cases first (with 100% precision) + if current_block < lock.start_block { + return 0.into(); + } else if current_block == lock.start_block { + return lock.alpha_locked; + } else if current_block >= lock.end_block { + return 0.into(); + } + + // Handle the cases between start and end + let lock_duration = + I96F32::saturating_from_num(lock.end_block.saturating_sub(lock.start_block)); + let minus_one = I96F32::saturating_from_num(-1); + let a = minus_one.safe_div(lock_duration); + let b = I96F32::saturating_from_num(lock.end_block).safe_div(lock_duration); + let x = I96F32::saturating_from_num(current_block); + let locked_alpha_fixed = I96F32::saturating_from_num(lock.alpha_locked); + let conviction_score = + locked_alpha_fixed.saturating_mul(a.saturating_mul(x).saturating_add(b)); + + AlphaCurrency::from(conviction_score.saturating_to_num::()) + } + + pub fn check_locks_on_stake_reduction( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + netuid: NetUid, + alpha_unstaked: AlphaCurrency, + ) -> dispatch::DispatchResult { + if Locks::::contains_key((netuid, &hotkey, &coldkey)) { + let total_stake = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid); + let current_block = Self::get_current_block_as_u64(); + // Retrieve the lock information for the given netuid, hotkey, and coldkey + let stake_lock = Locks::::get((netuid, hotkey.clone(), coldkey.clone())); + let conviction = Self::calculate_conviction(&stake_lock, current_block); + + let stake_after_unstake = total_stake.saturating_sub(alpha_unstaked); + // Ensure the requested unstake amount is not more than what's allowed + ensure!( + stake_after_unstake >= conviction, + Error::::NotEnoughStakeToWithdraw + ); + // If conviction is 0, remove the lock + if conviction == 0.into() { + Locks::::remove((netuid, hotkey.clone(), coldkey.clone())); + } + } + + Ok(()) + } +} +``` + +### Events + +```rust +/// The new stake lock interval half-life factor was set +LockIntervalSet { + /// New interval value (in blocks) + new_interval: u64, +}, + +/// Stake lock has increased +LockIncreased { + /// The owner coldkey of the stake + coldkey: T::AccountId, + /// The hotkey the stake is made to + hotkey: T::AccountId, + /// Subnet ID + netuid: NetUid, + /// Amount of alpha locked + alpha_locked: AlphaCurrency, +}, +``` + +### Additional check in remove_stake and all other extrinsics where stake is reduced + +```rust +// Check stake locks +Self::check_locks_on_stake_reduction( + origin_hotkey, + origin_coldkey, + origin_netuid, + alpha_amount, +)?; +``` + +### Handling locks in swap coldkey + +```rust +// 3. Swap Stake. +... + +let stake_lock_old = Locks::::get((netuid, &hotkey, old_coldkey)); + +... + +// Merge locks +let stake_lock_new = Locks::::get((netuid, &hotkey, new_coldkey)); +let stake_lock_merged = StakeLock { + alpha_locked: stake_lock_old + .alpha_locked + .saturating_add(stake_lock_new.alpha_locked), + start_block: stake_lock_old.start_block.max(stake_lock_new.start_block), + end_block: stake_lock_old.end_block.max(stake_lock_new.end_block), +}; +Locks::::insert((netuid, &hotkey, old_coldkey), stake_lock_merged); + +// Remove old stake lock +Locks::::remove((netuid, &hotkey, old_coldkey)); +``` + +## πŸ“˜ Reference Implementation + +The feature was once implemented and tabled: +https://github.com/opentensor/subtensor/pull/1860 + +## Test Plan + +### Lock Stake: Success Cases +- [ ] `test_do_lock_success` + Lock a portion of staked tokens. +- [ ] `test_do_lock_hotkey_not_registered` + Lock stake from a different coldkey than original registration. +- [ ] `test_do_lock_max_duration` + Lock stake with maximum allowed duration. +- [ ] `test_do_lock_multiple_times` + Lock multiple times, updating the locked amount and duration. +- [ ] `test_do_lock_different_subnets` + Lock stake independently on different subnets. +- [ ] `test_do_lock_increase_conviction` + Increase locked stake amount and duration. + +### Lock Stake: Failure Cases +- [ ] `test_do_lock_subnet_does_not_exist` + Lock on non-existent subnet. +- [ ] `test_do_lock_hotkey_does_not_exist` + Lock from non-existent hotkey. +- [ ] `test_do_lock_zero_amount` + Lock with zero amount. +- [ ] `test_do_lock_insufficient_stake` + Lock more than available stake. +- [ ] `test_do_lock_decrease_conviction` + Decrease conviction by reducing locked amount or duration. +- [ ] `test_do_lock_too_short` + Lock duration is too short. + +### Remove Stake: Lock Enforcement +- [ ] `test_remove_stake_fully_locked` + Cannot remove fully locked stake. +- [ ] `test_remove_stake_partially_locked` + Remove only unlocked portion if partially locked. +- [ ] `test_remove_stake_after_lock_expiry` + Full stake removal after lock expiry. +- [ ] `test_remove_stake_multiple_locks` + Prevent removal of locked stake exceeding unlockable amount. +- [ ] `test_remove_stake_conviction_calculation` + Validate conviction calculation before removal. +- [ ] `test_remove_stake_partial_lock_removal` + Partial stake removal preserves lock. +- [ ] `test_remove_stake_full_lock_removal` + Full removal after lock expiry clears lock. +- [ ] `test_remove_stake_across_subnets` + Remove stake separately across multiple subnets. + +### Conviction Calculation +- [ ] `test_calculate_conviction_zero_lock_amount` + Conviction is 0 when locked amount is 0. +- [ ] `test_calculate_conviction_zero_duration` + Conviction is 0 when duration is 0. +- [ ] `test_calculate_conviction_max_lock_amount` + Conviction scales with lock amount. +- [ ] `test_calculate_conviction_max_duration` + Conviction scales with lock duration. +- [ ] `test_calculate_conviction_overflow_check` + Overflow-safe calculation. +- [ ] `test_calculate_conviction_precision_small_values` + High precision maintained for small values. +- [ ] `test_calculate_conviction_precision_large_values` + Large values are preserved with precision. +- [ ] `test_calculate_conviction_rounding` + Conviction increases with longer duration. +- [ ] `test_calculate_conviction_expired_lock` + Conviction drops to zero after lock expiry. +- [ ] `test_calculate_conviction_lock_interval_boundary` + Conviction boundary at lock interval. +- [ ] `test_calculate_conviction_consistency` + Conviction increases consistently with lock amount or duration. + +### Conviction EMA Calculation +- [ ] `test_conviction_ema_basic` + EMA starts from 0. +- [ ] `test_conviction_ema_existing_ema` + EMA with existing value converges. +- [ ] `test_conviction_ema_zero_update` + EMA doesn't change with update period = 0. +- [ ] `test_conviction_ema_zero_lockint` + EMA equals conviction when lock interval is 0. +- [ ] `test_conviction_ema_large_update` + EMA capped at conviction when update period > lock interval. +- [ ] `test_conviction_ema_conviction_0` + EMA decays when conviction = 0. +- [ ] `test_conviction_ema_conviction_max` + EMA increases when conviction = max. + +### Subnet Ownership and Lock Updates +- [ ] `test_update_subnet_owner_no_locks` + No lock β†’ no subnet owner. +- [ ] `test_update_subnet_owner_single_lock` + Single lock β†’ sets owner and updates locked value. +- [ ] `test_update_subnet_owner_multiple_locks` + Multiple locks β†’ pick owner with highest conviction. +- [ ] `test_update_subnet_owner_tie_breaking` + Tie-breaking when convictions are equal. +- [ ] `test_update_subnet_owner_below_threshold` + Conviction below threshold β†’ no owner set. +- [ ] `test_update_subnet_owner_ownership_change` + Conviction changes over time updates owner/locked value. +- [ ] `test_update_subnet_owner_storage_updates` + Verify correct storage updates across block intervals. +- [ ] `test_update_subnet_owner_conviction_calculation` + Conviction calculated properly across different stake locks. +- [ ] `test_update_subnet_owner_different_subnets` + Subnet updates handle multiple subnets independently. +- [ ] `test_update_subnet_owner_large_subnet` + Scalability test with large subnet (1500 locks). +- [ ] `test_locks_are_updated_in_block_step` + Subnet owner auto-updates at block step. + + +## πŸ’¬ Discussion + +- Consider support of the current owner (or any owner-to-be) by other keys, i.e. allow others to join their locks + +## Β© Copyright + +This document is licensed under [The Unlicense](https://unlicense.org/).