Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions pallets/subtensor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,43 @@ pub mod pallet {
ValueQuery,
>;

/// Exponential lock state for a coldkey on a subnet.
#[derive(
Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, Debug, TypeInfo,
)]
pub struct LockState<AccountId> {
/// The hotkey this stake is locked to.
pub hotkey: AccountId,
/// Exponentially decaying locked amount.
pub locked_mass: U64F64,
/// Matured decaying score (integral of locked_mass over time).
pub conviction: U64F64,
/// Block number of last roll-forward.
pub last_update: u64,
}

/// --- DMAP ( coldkey, netuid ) --> LockState | Exponential lock per coldkey per subnet.
#[pallet::storage]
pub type Lock<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
T::AccountId, // coldkey
Identity,
NetUid, // subnet
LockState<T::AccountId>,
OptionQuery,
>;

/// Default decay timescale: ~30 days at 12s blocks.
#[pallet::type_value]
pub fn DefaultTauBlocks<T: Config>() -> u64 {
7200 * 30
}

/// --- ITEM( tau_blocks ) | Decay timescale in blocks for exponential lock.
#[pallet::storage]
pub type TauBlocks<T: Config> = StorageValue<_, u64, ValueQuery, DefaultTauBlocks<T>>;

/// Contains last Alpha storage map key to iterate (check first)
#[pallet::storage]
pub type AlphaMapLastKey<T: Config> =
Expand Down
33 changes: 33 additions & 0 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2626,5 +2626,38 @@ mod dispatches {
Self::deposit_event(Event::ColdkeySwapCleared { who });
Ok(())
}

/// Locks stake on a subnet to a specific hotkey, building conviction over time.
///
/// If no lock exists for (coldkey, subnet), a new one is created.
/// If a lock exists, the destination hotkey must match the existing lock's hotkey.
/// Top-up adds to the locked amount after rolling the lock state forward.
///
/// # Arguments
/// * `origin` - Must be signed by the coldkey.
/// * `hotkey` - The hotkey to lock stake to.
/// * `netuid` - The subnet on which to lock.
/// * `amount` - The alpha amount to lock.
#[pallet::call_index(134)]
#[pallet::weight((Weight::from_parts(46_000_000, 0)
.saturating_add(T::DbWeight::get().reads(4))
.saturating_add(T::DbWeight::get().writes(1)),
DispatchClass::Normal,
Pays::Yes
))]
pub fn lock_stake(
origin: OriginFor<T>,
hotkey: T::AccountId,
netuid: NetUid,
amount: u64,
) -> DispatchResult {
let coldkey = ensure_signed(origin)?;
Self::do_lock_stake(
&coldkey,
netuid,
&hotkey,
U64F64::saturating_from_num(amount),
)
}
}
}
4 changes: 4 additions & 0 deletions pallets/subtensor/src/macros/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,5 +293,9 @@ mod errors {
ColdkeySwapClearTooEarly,
/// Disabled temporarily.
DisabledTemporarily,
/// Lock hotkey mismatch: existing lock is for a different hotkey.
LockHotkeyMismatch,
/// Insufficient stake on subnet to cover the lock amount.
InsufficientStakeForLock,
}
}
12 changes: 12 additions & 0 deletions pallets/subtensor/src/macros/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,5 +533,17 @@ mod events {
/// The account ID of the coldkey that cleared the announcement.
who: T::AccountId,
},

/// Stake has been locked to a hotkey on a subnet.
StakeLocked {
/// The coldkey that locked the stake.
coldkey: T::AccountId,
/// The hotkey the stake is locked to.
hotkey: T::AccountId,
/// The subnet the stake is locked on.
netuid: NetUid,
/// The alpha amount locked.
amount: u64,
},
}
}
211 changes: 211 additions & 0 deletions pallets/subtensor/src/staking/lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
use super::*;
use substrate_fixed::transcendental::exp;
use substrate_fixed::types::{I64F64, U64F64};
use subtensor_runtime_common::NetUid;

const DUST_THRESHOLD: u64 = 100;

impl<T: Config> Pallet<T> {
/// Computes exp(-dt / tau) as a U64F64 decay factor.
pub fn exp_decay(dt: u64, tau: u64) -> U64F64 {
if tau == 0 || dt == 0 {
if dt == 0 {
return U64F64::saturating_from_num(1);
}
return U64F64::saturating_from_num(0);
}
let neg_ratio =
I64F64::saturating_from_num(-(dt as i128)).saturating_div(I64F64::saturating_from_num(tau));
let clamped = neg_ratio.max(I64F64::saturating_from_num(-40));
let result: I64F64 = exp(clamped).unwrap_or(I64F64::saturating_from_num(0));
if result < I64F64::saturating_from_num(0) {
U64F64::saturating_from_num(0)
} else {
U64F64::saturating_from_num(result)
}
}

/// Rolls a LockState forward to `now` using exponential decay.
///
/// X_new = decay * X_old
/// Y_new = decay * (Y_old + dt * X_old)
pub fn roll_forward_lock(
lock: LockState<T::AccountId>,
now: u64,
) -> LockState<T::AccountId> {
if now <= lock.last_update {
return lock;
}
let dt = now.saturating_sub(lock.last_update);
let tau = TauBlocks::<T>::get();
let decay = Self::exp_decay(dt, tau);

let dt_fixed = U64F64::saturating_from_num(dt);
let new_locked_mass = decay.saturating_mul(lock.locked_mass);
let new_conviction =
decay.saturating_mul(lock.conviction.saturating_add(dt_fixed.saturating_mul(lock.locked_mass)));

LockState {
hotkey: lock.hotkey,
locked_mass: new_locked_mass,
conviction: new_conviction,
last_update: now,
}
}

/// Returns the sum of raw alpha shares for a coldkey across all hotkeys on a given subnet.
pub fn total_coldkey_alpha_on_subnet(coldkey: &T::AccountId, netuid: NetUid) -> U64F64 {
let hotkeys = StakingHotkeys::<T>::get(coldkey);
let mut total = U64F64::saturating_from_num(0);
for hotkey in hotkeys.iter() {
total = total.saturating_add(Alpha::<T>::get((&hotkey, coldkey, netuid)));
}
total
}

/// Returns the current locked amount for a coldkey on a subnet (rolled forward to now).
pub fn get_current_locked(coldkey: &T::AccountId, netuid: NetUid) -> U64F64 {
let now = Self::get_current_block_as_u64();
match Lock::<T>::get(coldkey, netuid) {
Some(lock) => Self::roll_forward_lock(lock, now).locked_mass,
None => U64F64::saturating_from_num(0),
}
}

/// Returns the current conviction for a coldkey on a subnet (rolled forward to now).
pub fn get_conviction(coldkey: &T::AccountId, netuid: NetUid) -> U64F64 {
let now = Self::get_current_block_as_u64();
match Lock::<T>::get(coldkey, netuid) {
Some(lock) => Self::roll_forward_lock(lock, now).conviction,
None => U64F64::saturating_from_num(0),
}
}

/// Returns the alpha amount available to unstake for a coldkey on a subnet.
pub fn available_to_unstake(coldkey: &T::AccountId, netuid: NetUid) -> U64F64 {
let total = Self::total_coldkey_alpha_on_subnet(coldkey, netuid);
let locked = Self::get_current_locked(coldkey, netuid);
if total > locked {
total.saturating_sub(locked)
} else {
U64F64::saturating_from_num(0)
}
}

/// Locks stake for a coldkey on a subnet to a specific hotkey.
/// If no lock exists, creates one. If one exists, the hotkey must match.
/// Top-up adds to locked_mass after rolling forward.
pub fn do_lock_stake(
coldkey: &T::AccountId,
netuid: NetUid,
hotkey: &T::AccountId,
amount: U64F64,
) -> dispatch::DispatchResult {
ensure!(
amount > U64F64::saturating_from_num(0),
Error::<T>::AmountTooLow
);

let total = Self::total_coldkey_alpha_on_subnet(coldkey, netuid);
let now = Self::get_current_block_as_u64();

match Lock::<T>::get(coldkey, netuid) {
None => {
ensure!(total >= amount, Error::<T>::InsufficientStakeForLock);
Lock::<T>::insert(
coldkey,
netuid,
LockState {
hotkey: hotkey.clone(),
locked_mass: amount,
conviction: U64F64::saturating_from_num(0),
last_update: now,
},
);
}
Some(existing) => {
let lock = Self::roll_forward_lock(existing, now);
ensure!(
*hotkey == lock.hotkey,
Error::<T>::LockHotkeyMismatch
);
let new_locked = lock.locked_mass.saturating_add(amount);
ensure!(total >= new_locked, Error::<T>::InsufficientStakeForLock);
Lock::<T>::insert(
coldkey,
netuid,
LockState {
hotkey: lock.hotkey,
locked_mass: new_locked,
conviction: lock.conviction,
last_update: now,
},
);
}
}

Self::deposit_event(Event::StakeLocked {
coldkey: coldkey.clone(),
hotkey: hotkey.clone(),
netuid,
amount: amount.saturating_to_num::<u64>(),
});

Ok(())
}

/// Clears the lock if both locked_mass and conviction have decayed below the dust threshold.
pub fn maybe_cleanup_lock(coldkey: &T::AccountId, netuid: NetUid) {
if let Some(existing) = Lock::<T>::get(coldkey, netuid) {
let now = Self::get_current_block_as_u64();
let lock = Self::roll_forward_lock(existing, now);
let dust = U64F64::saturating_from_num(DUST_THRESHOLD);
if lock.locked_mass < dust && lock.conviction < dust {
Lock::<T>::remove(coldkey, netuid);
} else {
Lock::<T>::insert(coldkey, netuid, lock);
}
}
}

/// Returns the total conviction for a hotkey on a subnet,
/// summed over all coldkeys that have locked to this hotkey.
pub fn hotkey_conviction(hotkey: &T::AccountId, netuid: NetUid) -> U64F64 {
let now = Self::get_current_block_as_u64();
let mut total = U64F64::saturating_from_num(0);
for (_coldkey, _subnet_id, lock) in Lock::<T>::iter() {
if _subnet_id != netuid {
continue;
}
if *hotkey == lock.hotkey {
let rolled = Self::roll_forward_lock(lock, now);
total = total.saturating_add(rolled.conviction);
}
}
total
}

/// Finds the hotkey with the highest conviction on a given subnet.
pub fn subnet_king(netuid: NetUid) -> Option<T::AccountId> {
let now = Self::get_current_block_as_u64();
let mut scores: sp_std::collections::btree_map::BTreeMap<Vec<u8>, (T::AccountId, U64F64)> =
sp_std::collections::btree_map::BTreeMap::new();

for (_coldkey, subnet_id, lock) in Lock::<T>::iter() {
if subnet_id != netuid {
continue;
}
let rolled = Self::roll_forward_lock(lock, now);
let key = rolled.hotkey.encode();
let entry = scores
.entry(key)
.or_insert_with(|| (rolled.hotkey.clone(), U64F64::saturating_from_num(0)));
entry.1 = entry.1.saturating_add(rolled.conviction);
}

scores
.into_values()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(sp_std::cmp::Ordering::Equal))
.map(|(hotkey, _)| hotkey)
}
}
1 change: 1 addition & 0 deletions pallets/subtensor/src/staking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod claim_root;
pub mod decrease_take;
pub mod helpers;
pub mod increase_take;
pub mod lock;
pub mod move_stake;
pub mod recycle_alpha;
pub mod remove_stake;
Expand Down
15 changes: 15 additions & 0 deletions pallets/subtensor/src/staking/stake_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,11 @@ impl<T: Config> Pallet<T> {
Error::<T>::HotKeyAccountNotExists
);

let alpha_after = Self::total_coldkey_alpha_on_subnet(coldkey, netuid)
.saturating_sub(U64F64::saturating_from_num(alpha_unstaked.to_u64()));
let locked = Self::get_current_locked(coldkey, netuid);
ensure!(alpha_after >= locked, Error::<T>::CannotUnstakeLock);

Ok(())
}

Expand Down Expand Up @@ -1248,6 +1253,16 @@ impl<T: Config> Pallet<T> {
}
}

// Enforce lock invariant: if the operation reduces total coldkey alpha on origin subnet
// (cross-coldkey transfer or cross-subnet move), the remaining amount must cover the lock.
if origin_coldkey != destination_coldkey || origin_netuid != destination_netuid {
let sender_total = Self::total_coldkey_alpha_on_subnet(origin_coldkey, origin_netuid);
let sender_locked = Self::get_current_locked(origin_coldkey, origin_netuid);
let sender_after = sender_total
.saturating_sub(U64F64::saturating_from_num(alpha_amount.to_u64()));
ensure!(sender_after >= sender_locked, Error::<T>::CannotUnstakeLock);
}

Ok(())
}

Expand Down
Loading
Loading