//! Miner registration collateral. //! //! When a subnet sets a nonzero [`CollateralLockShare`] (p), the floating //! registration price is split: the `(1 - p)` share is burned exactly like a //! classic burned registration, and the `p` share is staked to the registering //! hotkey and locked as collateral. The lock is released back to free stake at //! [`CollateralDrainRatio`] (k) alpha per alpha of miner incentive earned, so //! the only way to recover the collateral is validated work on that subnet. //! //! Collateral is keyed by `(netuid, hotkey, coldkey)` — the bonded stake //! position — so nominators on the same hotkey are never frozen by the owner's //! bond. The lock survives deregistration and is credited against the //! collateral requirement the next time the same `(hotkey, coldkey)` registers, //! so a pruned miner re-registers by paying only the burned share (plus any //! shortfall if the requirement rose). There is no other exit path: collateral //! is never directly withdrawable, and a position that validators stop scoring //! keeps its remaining collateral frozen indefinitely. use frame_support::storage::{TransactionOutcome, with_transaction}; use safe_math::FixedExt; use substrate_fixed::types::U64F64; use subtensor_runtime_common::{AuthorshipInfo, NetUid}; use subtensor_swap_interface::SwapHandler; use super::*; impl Pallet { /// Collateral lock share (p) for a subnet as a fixed-point fraction in [0, 1). pub fn get_collateral_lock_share_float(netuid: NetUid) -> U64F64 { U64F64::saturating_from_num(CollateralLockShare::::get(netuid)) .safe_div(U64F64::saturating_from_num(u16::MAX)) } /// Alpha currently locked as registration collateral on a /// `(hotkey, coldkey)` stake position. pub fn get_miner_collateral_locked( netuid: NetUid, hotkey: &T::AccountId, coldkey: &T::AccountId, ) -> AlphaBalance { MinerCollateral::::get((netuid, hotkey, coldkey)) .map(|state| state.locked) .unwrap_or(AlphaBalance::ZERO) } /// Whether a hotkey swap should be refused because `(hotkey, coldkey)` has /// standing miner collateral on `netuid` (or any subnet when `netuid` is /// `None`) **and** does not currently hold validator permit there. /// /// Bonded miners cannot cheaply rotate identity; permitted validators on /// collateral-enabled subnets still can. A deregistered-but-still-bonded /// hotkey has no permit and is treated as a miner. pub fn miner_collateral_blocks_hotkey_swap( hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: Option, ) -> bool { match netuid { Some(netuid) => { Self::miner_collateral_blocks_hotkey_swap_on_subnet(hotkey, coldkey, netuid) } None => Self::get_all_subnet_netuids().into_iter().any(|netuid| { Self::miner_collateral_blocks_hotkey_swap_on_subnet(hotkey, coldkey, netuid) }), } } fn miner_collateral_blocks_hotkey_swap_on_subnet( hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid, ) -> bool { if !MinerCollateral::::contains_key((netuid, hotkey, coldkey)) { return false; } match Uids::::try_get(netuid, hotkey) { Ok(uid) => !Self::get_validator_permit_for_uid(netuid, uid), Err(_) => true, } } /// Total alpha locked as registration collateral across all hotkeys owned /// by a coldkey on a subnet. Used by the unstake guard. pub fn total_miner_collateral_for_coldkey( coldkey: &T::AccountId, netuid: NetUid, ) -> AlphaBalance { ColdkeyMinerCollateral::::get(netuid, coldkey) } /// Keep [`ColdkeyMinerCollateral`] in sync when a position's locked amount /// changes. fn adjust_coldkey_miner_collateral( coldkey: &T::AccountId, netuid: NetUid, old_locked: AlphaBalance, new_locked: AlphaBalance, ) { if old_locked == new_locked { return; } ColdkeyMinerCollateral::::mutate(netuid, coldkey, |total| { *total = total.saturating_sub(old_locked).saturating_add(new_locked); }); if ColdkeyMinerCollateral::::get(netuid, coldkey).is_zero() { ColdkeyMinerCollateral::::remove(netuid, coldkey); } } /// Alpha that can leave this `(coldkey, hotkey, netuid)` position without /// violating that position's miner collateral or the coldkey's conviction /// lock. /// /// Used by alpha fee withdrawal and as the free balance under /// [`Self::ensure_hotkey_covers_collateral`]. pub fn available_to_unstake_from_hotkey( coldkey: &T::AccountId, hotkey: &T::AccountId, netuid: NetUid, ) -> AlphaBalance { let stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid); let collateral = Self::get_miner_collateral_locked(netuid, hotkey, coldkey); let position_free = stake.saturating_sub(collateral); position_free.min(Self::available_to_unstake(coldkey, netuid)) } /// Ensures removing `amount` alpha from a `(hotkey, coldkey)` position /// leaves enough stake to still cover that position's `MinerCollateral`. /// /// Collateral is keyed by `(netuid, hotkey, coldkey)`, so a nominator's /// stake on the same hotkey is unaffected by the owner's bond. pub fn ensure_hotkey_covers_collateral( coldkey: &T::AccountId, hotkey: &T::AccountId, netuid: NetUid, amount: AlphaBalance, ) -> Result<(), Error> { let stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid); let collateral = Self::get_miner_collateral_locked(netuid, hotkey, coldkey); let removable = stake.saturating_sub(collateral); ensure!(amount <= removable, Error::::StakeUnavailable); Ok(()) } /// Ensures an ownership-changing, same-subnet stake transfer leaves the /// origin coldkey with enough alpha to still cover its miner collateral. /// /// Unlike the general unstake guard, this only accounts for collateral, not /// conviction locks: on a same-subnet transfer the conviction lock follows /// the stake to the destination via `transfer_lock`, but miner collateral /// has no transfer exit and stays on the origin `(hotkey, coldkey)`. /// Without this, transferring staked-and-locked alpha to a second coldkey /// would liberate collateral that is only meant to be recovered through /// earned incentive. Prefer [`ensure_hotkey_covers_collateral`] at call /// sites that know the origin hotkey; this coldkey-wide check remains as a /// belt-and-suspenders for ownership-changing transfers. pub fn ensure_transfer_respects_collateral( coldkey: &T::AccountId, netuid: NetUid, amount: AlphaBalance, ) -> Result<(), Error> { let total = Self::total_coldkey_alpha_on_subnet(coldkey, netuid); let collateral = Self::total_miner_collateral_for_coldkey(coldkey, netuid); let transferable = total.saturating_sub(collateral); ensure!(amount <= transferable, Error::::StakeUnavailable); Ok(()) } /// The collateral requirement at registration: `p * registration_cost` in TAO. pub fn get_collateral_requirement_tao( netuid: NetUid, registration_cost: TaoBalance, ) -> TaoBalance { let lock_share = Self::get_collateral_lock_share_float(netuid); TaoBalance::from( U64F64::saturating_from_num(u64::from(registration_cost)) .saturating_mul(lock_share) .saturating_to_num::(), ) } /// TAO the coldkey must provide at registration on top of the burned /// share: the collateral requirement `p * registration_cost` minus the /// TAO value of collateral already locked for this `(hotkey, coldkey)`. pub fn get_collateral_topup_tao( netuid: NetUid, hotkey: &T::AccountId, coldkey: &T::AccountId, registration_cost: TaoBalance, ) -> TaoBalance { let requirement_tao: u64 = Self::get_collateral_requirement_tao(netuid, registration_cost).into(); if requirement_tao == 0 { return TaoBalance::ZERO; } // Value the standing lock at the subnet's moving-average price rather // than instantaneous spot: a returning miner could otherwise pump spot // in the same block to inflate the credit and re-register while // under-collateralized. The EMA resists single-block manipulation. let locked_alpha = Self::get_miner_collateral_locked(netuid, hotkey, coldkey); let alpha_price: U64F64 = Self::get_moving_alpha_price(netuid); let locked_value_tao: u64 = U64F64::saturating_from_num(locked_alpha.to_u64()) .saturating_mul(alpha_price) .saturating_to_num(); TaoBalance::from(requirement_tao.saturating_sub(locked_value_tao)) } /// Worst alpha price (RAO per alpha) accepted for a collateral AMM buy. /// /// Spot × (1 + 5%). Callers that already took a user-supplied limit (e.g. /// `add_collateral`) should pass that through instead; this bound is for /// registration paths that have no separate AMM limit argument. pub fn collateral_purchase_limit_price(netuid: NetUid) -> Result { let spot = T::SwapInterface::current_alpha_price(netuid); ensure!( spot > U64F64::saturating_from_num(0), Error::::InsufficientLiquidity ); // 5% above spot, in RAO-per-alpha (same units as `add_stake_limit`). let limited = spot .saturating_mul(U64F64::saturating_from_num(105)) .safe_div(U64F64::saturating_from_num(100)); let as_rao = limited .saturating_mul(U64F64::saturating_from_num(1_000_000_000u64)) .saturating_to_num::(); ensure!(as_rao > 0, Error::::InsufficientLiquidity); Ok(TaoBalance::from(as_rao)) } /// Pay the registration charge as one transfer + one swap, then split the /// resulting alpha by the TAO weights of the burned share vs collateral /// top-up. /// /// Dust that swaps to zero alpha is treated as fully burned — there is no /// second transfer. A zero top-up (standing collateral already covers the /// requirement) still re-snapshots the drain ratio. /// /// Callers that also mutate registration state should wrap this in /// `with_transaction` so a later failure rolls the payment back. pub fn pay_registration( netuid: NetUid, hotkey: &T::AccountId, coldkey: &T::AccountId, burned_share: TaoBalance, collateral_topup: TaoBalance, ) -> DispatchResult { let total_charge = burned_share.saturating_add(collateral_topup); if total_charge.is_zero() { Self::resnapshot_collateral_drain(netuid, hotkey, coldkey); return Ok(()); } let tao_paid = Self::transfer_tao_to_subnet(netuid, coldkey, total_charge)?; // Bound the AMM fill whenever any of the charge is collateral. A naked // `max_price()` lets a delayed/shielded inclusion clear at an // arbitrarily worse rate; burn-only registrations keep the historical // unbounded path (the burn share is destroyed, not kept as a position). let limit_price = if collateral_topup.is_zero() { T::SwapInterface::max_price() } else { Self::collateral_purchase_limit_price(netuid)? }; let swap_result = Self::swap_tao_for_alpha(netuid, tao_paid, limit_price, false)?; // Fee to block author (same as `stake_into_subnet`). let maybe_block_author_coldkey = T::AuthorshipProvider::author(); if let Some(block_author_coldkey) = maybe_block_author_coldkey { Self::transfer_tao_from_subnet( netuid, &block_author_coldkey, swap_result.fee_to_block_author.into(), )?; } else if let Some(subnet_account_id) = Self::get_subnet_account_id(netuid) { let _ = Self::burn_tao(&subnet_account_id, swap_result.fee_to_block_author.into()); } let consumed_tao = swap_result .amount_paid_in .saturating_add(swap_result.fee_paid); let refund_tao = tao_paid.saturating_sub(consumed_tao); if !refund_tao.is_zero() { Self::transfer_tao_from_subnet(netuid, coldkey, refund_tao)?; TotalStake::::mutate(|total| *total = total.saturating_sub(refund_tao)); } Self::record_tao_inflow(netuid, swap_result.amount_paid_in.into()); let total_alpha: AlphaBalance = swap_result.amount_paid_out.into(); if total_alpha.is_zero() { // Dust: payment already settled via the single swap; nothing to // stake or remove from AlphaOut. Self::resnapshot_collateral_drain(netuid, hotkey, coldkey); return Ok(()); } let burn_w = u64::from(burned_share) as u128; let lock_w = u64::from(collateral_topup) as u128; let total_w = burn_w.saturating_add(lock_w).max(1); let lock_alpha = if lock_w == 0 { AlphaBalance::ZERO } else if burn_w == 0 { total_alpha } else { AlphaBalance::from( (total_alpha.to_u64() as u128) .saturating_mul(lock_w) .checked_div(total_w) .unwrap_or(0) as u64, ) }; let burn_alpha = total_alpha.saturating_sub(lock_alpha); if !burn_alpha.is_zero() { SubnetAlphaOut::::mutate(netuid, |total| { *total = total.saturating_sub(burn_alpha.into()) }); } if lock_alpha.is_zero() { Self::resnapshot_collateral_drain(netuid, hotkey, coldkey); return Ok(()); } ensure!( Self::try_increase_stake_for_hotkey_and_coldkey_on_subnet(hotkey, netuid, lock_alpha,), Error::::InsufficientLiquidity ); Self::increase_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid, lock_alpha); let mut staking_hotkeys = StakingHotkeys::::get(coldkey); if !staking_hotkeys.contains(hotkey) { staking_hotkeys.push(hotkey.clone()); StakingHotkeys::::insert(coldkey, staking_hotkeys); } Self::cleanup_lock_if_zero(coldkey, netuid); LastColdkeyHotkeyStakeBlock::::insert(coldkey, hotkey, Self::get_current_block_as_u64()); let lock_tao = if total_w == 0 { TaoBalance::ZERO } else { TaoBalance::from( (u64::from(tao_paid) as u128) .saturating_mul(lock_w) .checked_div(total_w) .unwrap_or(0) as u64, ) }; Self::deposit_event(Event::StakeAdded( coldkey.clone(), hotkey.clone(), lock_tao, lock_alpha, netuid, swap_result.fee_paid.to_u64(), )); let total_locked = Self::credit_miner_collateral( netuid, hotkey, coldkey, lock_alpha, true, // re-snapshot drain ratio on registration ); Self::deposit_event(Event::CollateralLocked { netuid, hotkey: hotkey.clone(), locked: lock_alpha, total_locked, }); Ok(()) } /// Credit `alpha` onto `(netuid, hotkey, coldkey)` collateral. When /// `resnapshot_drain` is set, refresh the drain-ratio snapshot (registration /// / returning registration). Voluntary top-ups leave the ratio alone. fn credit_miner_collateral( netuid: NetUid, hotkey: &T::AccountId, coldkey: &T::AccountId, alpha: AlphaBalance, resnapshot_drain: bool, ) -> AlphaBalance { let old_locked = Self::get_miner_collateral_locked(netuid, hotkey, coldkey); let new_locked = MinerCollateral::::mutate( (netuid, hotkey, coldkey), |maybe_state| match maybe_state { Some(state) => { state.locked = state.locked.saturating_add(alpha); if resnapshot_drain { state.drain_ratio = CollateralDrainRatio::::get(netuid); } state.locked } None => { *maybe_state = Some(MinerCollateralState { locked: alpha, drain_ratio: CollateralDrainRatio::::get(netuid), min_locked: AlphaBalance::ZERO, earned: AlphaBalance::ZERO, }); alpha } }, ); Self::adjust_coldkey_miner_collateral(coldkey, netuid, old_locked, new_locked); new_locked } /// Re-snapshot a standing collateral entry's drain ratio to the subnet's /// current `CollateralDrainRatio`. No-op when the position has no entry. fn resnapshot_collateral_drain(netuid: NetUid, hotkey: &T::AccountId, coldkey: &T::AccountId) { MinerCollateral::::mutate_exists((netuid, hotkey, coldkey), |maybe_state| { if let Some(state) = maybe_state { state.drain_ratio = CollateralDrainRatio::::get(netuid); } }); } /// Settle a miner's collateral against this tempo's earned incentive. /// Called from the incentive distribution path. /// /// Two directions around the miner-set floor (`min_locked`): /// - Below the floor, incentive is captured into the lock until the floor /// is met. The captured share is staked to the miner hotkey itself (the /// guarded position), never to an auto-stake destination. /// - Above the floor, `min(drain_ratio * incentive, locked - min_locked)` /// is released back to withdrawable stake. /// /// Returns the captured amount; the caller credits only the remainder of /// the incentive to the miner's usual destination. The entry is removed /// once fully drained with no floor set. pub fn settle_miner_collateral( netuid: NetUid, hotkey: &T::AccountId, owner: &T::AccountId, incentive: AlphaBalance, ) -> AlphaBalance { if incentive.is_zero() { return AlphaBalance::ZERO; } let old_locked = Self::get_miner_collateral_locked(netuid, hotkey, owner); let captured = MinerCollateral::::mutate_exists((netuid, hotkey, owner), |maybe_state| { let Some(state) = maybe_state else { return AlphaBalance::ZERO; }; state.earned = state.earned.saturating_add(incentive); let shortfall = state.min_locked.saturating_sub(state.locked); if !shortfall.is_zero() { let captured = incentive.min(shortfall); Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, owner, netuid, captured, ); state.locked = state.locked.saturating_add(captured); return captured; } let release: u64 = U64F64::saturating_from_num(incentive.to_u64()) .saturating_mul(state.drain_ratio) .saturating_to_num(); let releasable = state.locked.saturating_sub(state.min_locked); state.locked = state.locked.saturating_sub(releasable.min(release.into())); if state.locked.is_zero() && state.min_locked.is_zero() { *maybe_state = None; } AlphaBalance::ZERO }); let new_locked = Self::get_miner_collateral_locked(netuid, hotkey, owner); Self::adjust_coldkey_miner_collateral(owner, netuid, old_locked, new_locked); captured } /// Lock about `tao` worth of additional registration collateral on the /// signer's own hotkey (e.g. per-machine deposits required by a subnet's /// validators). /// /// Prefers free alpha already staked on `(hotkey, coldkey, netuid)` — /// valued at the subnet moving-average price, same as re-registration /// credit — and only buys the shortfall with TAO. Keeps the existing /// drain-ratio snapshot: a top-up is not a new registration and does not /// re-price the contract. The buy leg is fill-or-kill against /// `limit_price` (same units as `add_stake_limit`), and the whole path is /// transactional. pub fn do_add_collateral( origin: OriginFor, netuid: NetUid, hotkey: T::AccountId, tao: TaoBalance, limit_price: TaoBalance, ) -> dispatch::DispatchResult { let coldkey = ensure_signed(origin)?; ensure!( !netuid.is_root(), Error::::RegistrationNotPermittedOnRootSubnet ); ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); Self::ensure_subtoken_enabled(netuid)?; ensure!( Self::hotkey_account_exists(&hotkey), Error::::HotKeyAccountNotExists ); ensure!( Self::coldkey_owns_hotkey(&coldkey, &hotkey), Error::::NonAssociatedColdKey ); ensure!(!tao.is_zero(), Error::::AmountTooLow); let alpha_price = Self::get_moving_alpha_price(netuid); ensure!( alpha_price > U64F64::saturating_from_num(0), Error::::InsufficientLiquidity ); let target_alpha = AlphaBalance::from( U64F64::saturating_from_num(u64::from(tao)) .safe_div(alpha_price) .saturating_to_num::(), ); ensure!(!target_alpha.is_zero(), Error::::AmountTooLow); let stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); let already_locked = Self::get_miner_collateral_locked(netuid, &hotkey, &coldkey); let free_alpha = stake.saturating_sub(already_locked); let from_stake = free_alpha.min(target_alpha); let shortfall_alpha = target_alpha.saturating_sub(from_stake); let tao_to_buy = if shortfall_alpha.is_zero() { TaoBalance::ZERO } else { TaoBalance::from( U64F64::saturating_from_num(shortfall_alpha.to_u64()) .saturating_mul(alpha_price) .saturating_to_num::(), ) }; if !tao_to_buy.is_zero() { Self::ensure_add_stake_input_within_swap_limit(netuid, tao_to_buy)?; Self::validate_add_stake(&coldkey, &hotkey, netuid, tao_to_buy, tao_to_buy, false)?; // `transfer_tao_to_subnet` uses Preservation::Preserve and silently // clips to keep-alive balance. Reject that partial fill up front. ensure!( Self::get_keep_alive_balance(&coldkey) >= tao_to_buy.into(), Error::::NotEnoughBalanceToStake ); } with_transaction(|| { let result = (|| -> DispatchResult { let mut added = AlphaBalance::ZERO; if !from_stake.is_zero() { // Re-check coverage inside the transaction: stake may have // moved since the preflight read. let stake_now = Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); let locked_now = Self::get_miner_collateral_locked(netuid, &hotkey, &coldkey); ensure!( stake_now.saturating_sub(locked_now) >= from_stake, Error::::StakeUnavailable ); Self::credit_miner_collateral(netuid, &hotkey, &coldkey, from_stake, false); added = added.saturating_add(from_stake); } if !tao_to_buy.is_zero() { // Preflight the limit the same way as `add_stake_limit` so a // too-tight bound fails before transferring TAO. let max_amount: TaoBalance = Self::get_max_amount_add(netuid, limit_price)?.into(); ensure!(tao_to_buy <= max_amount, Error::::SlippageTooHigh); let bought = Self::stake_into_subnet( &hotkey, &coldkey, netuid, tao_to_buy, limit_price, false, )?; ensure!(!bought.is_zero(), Error::::AmountTooLow); Self::credit_miner_collateral(netuid, &hotkey, &coldkey, bought, false); added = added.saturating_add(bought); } ensure!(!added.is_zero(), Error::::AmountTooLow); let total_locked = Self::get_miner_collateral_locked(netuid, &hotkey, &coldkey); Self::deposit_event(Event::CollateralLocked { netuid, hotkey: hotkey.clone(), locked: added, total_locked, }); Ok(()) })(); match result { Ok(()) => TransactionOutcome::Commit(Ok(())), Err(e) => TransactionOutcome::Rollback(Err(e)), } }) } /// Set the miner's collateral floor for a hotkey on a subnet. The lock /// self-maintains around the floor (drain stops at it; incentive fills a /// shortfall), so miners tracking a validator-published per-machine /// requirement do not need to keep re-locking drained collateral. Zero /// clears the floor. pub fn do_set_min_collateral( origin: OriginFor, netuid: NetUid, hotkey: T::AccountId, min_locked: AlphaBalance, ) -> dispatch::DispatchResult { let coldkey = ensure_signed(origin)?; ensure!( !netuid.is_root(), Error::::RegistrationNotPermittedOnRootSubnet ); ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); ensure!( Self::hotkey_account_exists(&hotkey), Error::::HotKeyAccountNotExists ); ensure!( Self::coldkey_owns_hotkey(&coldkey, &hotkey), Error::::NonAssociatedColdKey ); MinerCollateral::::mutate_exists((netuid, &hotkey, &coldkey), |maybe_state| { match maybe_state { Some(state) => { state.min_locked = min_locked; if state.locked.is_zero() && state.min_locked.is_zero() { *maybe_state = None; } } None => { if !min_locked.is_zero() { *maybe_state = Some(MinerCollateralState { locked: AlphaBalance::ZERO, drain_ratio: CollateralDrainRatio::::get(netuid), min_locked, earned: AlphaBalance::ZERO, }); } } } }); Self::deposit_event(Event::MinCollateralSet { netuid, hotkey, min_locked, }); Ok(()) } /// Move the collateral entry when a hotkey is swapped. The coldkey is /// unchanged (ownership stays with the same coldkey); only the hotkey leg /// of the key moves. If the new `(hotkey, coldkey)` already has collateral /// on the subnet, the locks merge, the base (slower) drain ratio is kept, /// and the floors add (they represent distinct per-machine commitments). pub fn swap_miner_collateral( old_hotkey: &T::AccountId, new_hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid, ) { let Some(old_state) = MinerCollateral::::take((netuid, old_hotkey, coldkey)) else { return; }; MinerCollateral::::mutate( (netuid, new_hotkey, coldkey), |maybe_state| match maybe_state { Some(state) => { state.locked = state.locked.saturating_add(old_state.locked); state.drain_ratio = state.drain_ratio.min(old_state.drain_ratio); state.min_locked = state.min_locked.saturating_add(old_state.min_locked); state.earned = state.earned.saturating_add(old_state.earned); } None => *maybe_state = Some(old_state), }, ); } }