//! 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 hotkey emission earned //! (miner incentive and validator dividends), so the only way to recover the //! collateral is to earn emission 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 stops earning emission //! 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 `(hotkey, coldkey)` has standing miner collateral on `netuid` /// (or any subnet when `netuid` is `None`). pub fn has_miner_collateral( hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: Option, ) -> bool { match netuid { Some(netuid) => MinerCollateral::::contains_key((netuid, hotkey, coldkey)), None => Self::get_all_subnet_netuids() .into_iter() .any(|netuid| MinerCollateral::::contains_key((netuid, hotkey, coldkey))), } } /// 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); } } /// Record `hotkey` in [`ColdkeyCollateralHotkeys`] when a collateral row /// appears for `(netuid, coldkey)`. fn index_coldkey_collateral_hotkey( netuid: NetUid, coldkey: &T::AccountId, hotkey: &T::AccountId, ) -> Result<(), Error> { ColdkeyCollateralHotkeys::::try_mutate(netuid, coldkey, |hotkeys| { if hotkeys.contains(hotkey) { return Ok(()); } hotkeys .try_push(hotkey.clone()) .map_err(|_| Error::::ColdkeyCollateralPositionsFull) }) } /// Drop `hotkey` from [`ColdkeyCollateralHotkeys`] when its collateral row /// is removed. fn deindex_coldkey_collateral_hotkey( netuid: NetUid, coldkey: &T::AccountId, hotkey: &T::AccountId, ) { let mut hotkeys = ColdkeyCollateralHotkeys::::get(netuid, coldkey); let before = hotkeys.len(); hotkeys.retain(|h| h != hotkey); if hotkeys.len() == before { return; } if hotkeys.is_empty() { ColdkeyCollateralHotkeys::::remove(netuid, coldkey); } else { ColdkeyCollateralHotkeys::::insert(netuid, coldkey, hotkeys); } } /// Keep [`ColdkeyCollateralHotkeys`] aligned with whether a /// [`MinerCollateral`] row exists for `(netuid, hotkey, coldkey)`. fn sync_coldkey_collateral_hotkey_index( netuid: NetUid, hotkey: &T::AccountId, coldkey: &T::AccountId, ) -> Result<(), Error> { if MinerCollateral::::contains_key((netuid, hotkey, coldkey)) { Self::index_coldkey_collateral_hotkey(netuid, coldkey, hotkey) } else { Self::deindex_coldkey_collateral_hotkey(netuid, coldkey, hotkey); Ok(()) } } /// 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 emission. 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)?; // `transfer_tao_to_subnet` clips to keep-alive; never accept a short fill. ensure!( tao_paid == total_charge, Error::::NotEnoughBalanceToStake ); // 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, ) -> Result> { // Reserve the index slot before writing the row so a full cap fails // closed with no storage side effects. if !MinerCollateral::::contains_key((netuid, hotkey, coldkey)) { Self::index_coldkey_collateral_hotkey(netuid, coldkey, hotkey)?; } 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); Ok(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 hotkey's collateral against this tempo's earned emission /// (miner incentive or validator dividends). Called from the emission /// distribution path. /// /// `emission` drives lifetime earned and the release rate /// (`k × emission`). `capturable` is the maximum that may be diverted /// into the lock when below the floor — it must be value that already /// belongs to `owner` (full miner incentive, or only the validator's /// take from a dividend pool). Nominator / root-claimable shares must /// never be passed as capturable. /// /// Two directions around the miner-set floor (`min_locked`): /// - Below the floor, up to `min(capturable, shortfall)` is captured into /// the lock (staked to the hotkey itself, never an auto-stake /// destination). /// - Above the floor, `min(drain_ratio * emission, locked - min_locked)` /// is released back to withdrawable stake. /// /// Returns the captured amount; the caller credits only the remainder of /// the capturable slice to the owner. The entry is removed once fully /// drained with no floor set. pub fn settle_miner_collateral( netuid: NetUid, hotkey: &T::AccountId, owner: &T::AccountId, emission: AlphaBalance, capturable: AlphaBalance, ) -> AlphaBalance { if emission.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(emission); let shortfall = state.min_locked.saturating_sub(state.locked); if !shortfall.is_zero() { let captured = capturable.min(shortfall); if captured.is_zero() { return AlphaBalance::ZERO; } 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(emission.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); // Settle only updates / removes existing rows; index shrink cannot fail. let _ = Self::sync_coldkey_collateral_hotkey_index(netuid, hotkey, owner); captured } /// Lock `alpha` 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)` and /// only buys the shortfall with TAO (priced at the subnet moving-average /// price). 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, alpha: AlphaBalance, 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!(!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(alpha); let shortfall_alpha = alpha.saturating_sub(from_stake); let tao_to_buy = if shortfall_alpha.is_zero() { TaoBalance::ZERO } else { let alpha_price = Self::get_moving_alpha_price(netuid); ensure!( alpha_price > U64F64::saturating_from_num(0), Error::::InsufficientLiquidity ); 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; emission 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 ); let existed = MinerCollateral::::contains_key((netuid, &hotkey, &coldkey)); if !existed && !min_locked.is_zero() { // Fail before creating a row if the coldkey is already at the cap. Self::index_coldkey_collateral_hotkey(netuid, &coldkey, &hotkey)?; } 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, }); } } } }); if !MinerCollateral::::contains_key((netuid, &hotkey, &coldkey)) { Self::deindex_coldkey_collateral_hotkey(netuid, &coldkey, &hotkey); } Self::deposit_event(Event::MinCollateralSet { netuid, hotkey, min_locked, }); Ok(()) } /// Merge `from` into an existing or vacant `MinerCollateral` slot. /// /// Sums `locked` / `min_locked` / `earned` and keeps the slower (`min`) /// drain ratio. Shared by hotkey-leg and coldkey-leg moves. fn merge_miner_collateral_state( maybe_state: &mut Option, from: MinerCollateralState, ) { match maybe_state { Some(state) => { state.locked = state.locked.saturating_add(from.locked); state.drain_ratio = state.drain_ratio.min(from.drain_ratio); state.min_locked = state.min_locked.saturating_add(from.min_locked); state.earned = state.earned.saturating_add(from.earned); } None => *maybe_state = Some(from), } } /// Read-only check that a hotkey collateral rename can proceed without /// exceeding [`crate::MAX_COLDKEY_COLLATERAL_HOTKEYS`]. Does not mutate /// storage — call before charging fees or writing swap state. pub fn ensure_hotkey_collateral_index_can_swap( netuid: NetUid, old_hotkey: &T::AccountId, new_hotkey: &T::AccountId, coldkey: &T::AccountId, ) -> Result<(), Error> { if !MinerCollateral::::contains_key((netuid, old_hotkey, coldkey)) { return Ok(()); } let hotkeys = ColdkeyCollateralHotkeys::::get(netuid, coldkey); if hotkeys.contains(new_hotkey) || hotkeys.iter().any(|h| h == old_hotkey) { // Destination already indexed, or source can be renamed in place. return Ok(()); } ensure!( (hotkeys.len() as u32) < crate::MAX_COLDKEY_COLLATERAL_HOTKEYS, Error::::ColdkeyCollateralPositionsFull ); Ok(()) } /// Preflight index capacity for every subnet that will migrate collateral /// during a hotkey swap. Call before charging fees or writing swap state. pub fn ensure_hotkey_collateral_swappable( old_hotkey: &T::AccountId, new_hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: Option, ) -> Result<(), Error> { match netuid { Some(netuid) => Self::ensure_hotkey_collateral_index_can_swap( netuid, old_hotkey, new_hotkey, coldkey, ), None => { for netuid in Self::get_all_subnet_netuids() { if MinerCollateral::::contains_key((netuid, old_hotkey, coldkey)) { Self::ensure_hotkey_collateral_index_can_swap( netuid, old_hotkey, new_hotkey, coldkey, )?; } } Ok(()) } } } /// Ensure the destination hotkey will have an index slot before any /// collateral mutation. Renames the old index entry in place when possible /// so a coldkey already at the cap can still swap; otherwise reserves a /// new slot (failing closed if the cap is full). fn prepare_hotkey_collateral_index_swap( netuid: NetUid, old_hotkey: &T::AccountId, new_hotkey: &T::AccountId, coldkey: &T::AccountId, ) -> Result<(), Error> { // Fail closed before any index write (same capacity rules as the // read-only preflight). Self::ensure_hotkey_collateral_index_can_swap(netuid, old_hotkey, new_hotkey, coldkey)?; if !MinerCollateral::::contains_key((netuid, old_hotkey, coldkey)) { return Ok(()); } let mut hotkeys = ColdkeyCollateralHotkeys::::get(netuid, coldkey); let old_pos = hotkeys.iter().position(|h| h == old_hotkey); let new_indexed = hotkeys.contains(new_hotkey); if new_indexed { return Ok(()); } if let Some(i) = old_pos { let Some(slot) = hotkeys.get_mut(i) else { // `position` implies a valid index; reserve instead if not. return Self::index_coldkey_collateral_hotkey(netuid, coldkey, new_hotkey); }; *slot = new_hotkey.clone(); ColdkeyCollateralHotkeys::::insert(netuid, coldkey, hotkeys); return Ok(()); } // Legacy / unindexed source row: require a free destination slot now. Self::index_coldkey_collateral_hotkey(netuid, coldkey, new_hotkey) } /// 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 via [`Self::merge_miner_collateral_state`]. pub fn swap_miner_collateral( old_hotkey: &T::AccountId, new_hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid, ) -> Result<(), Error> { // Reserve / rename the index slot before mutating rows so a full-cap // failure cannot leave the swap half-applied. Self::prepare_hotkey_collateral_index_swap(netuid, old_hotkey, new_hotkey, coldkey)?; let Some(old_state) = MinerCollateral::::take((netuid, old_hotkey, coldkey)) else { return Ok(()); }; MinerCollateral::::mutate((netuid, new_hotkey, coldkey), |maybe_state| { Self::merge_miner_collateral_state(maybe_state, old_state); }); // Drop any leftover old-hotkey index entry (no-op if already renamed). Self::deindex_coldkey_collateral_hotkey(netuid, coldkey, old_hotkey); Ok(()) } /// Move the collateral entry when a coldkey is swapped. The hotkey is /// unchanged; only the coldkey leg of the key moves. Merge rules match /// [`Self::swap_miner_collateral`]. Updates [`ColdkeyMinerCollateral`] and /// [`ColdkeyCollateralHotkeys`] for both coldkeys. No-op when the source /// row is absent. pub fn transfer_miner_collateral_coldkey( netuid: NetUid, hotkey: &T::AccountId, old_coldkey: &T::AccountId, new_coldkey: &T::AccountId, ) -> Result<(), Error> { if old_coldkey == new_coldkey { return Ok(()); } if !MinerCollateral::::contains_key((netuid, hotkey, old_coldkey)) { return Ok(()); } // Reserve the destination index slot before mutating so a full-cap // failure cannot leave the coldkey swap half-applied. if !MinerCollateral::::contains_key((netuid, hotkey, new_coldkey)) { Self::index_coldkey_collateral_hotkey(netuid, new_coldkey, hotkey)?; } let Some(old_state) = MinerCollateral::::take((netuid, hotkey, old_coldkey)) else { return Ok(()); }; let moved_locked = old_state.locked; Self::adjust_coldkey_miner_collateral( old_coldkey, netuid, moved_locked, AlphaBalance::ZERO, ); Self::deindex_coldkey_collateral_hotkey(netuid, old_coldkey, hotkey); let dest_before = Self::get_miner_collateral_locked(netuid, hotkey, new_coldkey); MinerCollateral::::mutate((netuid, hotkey, new_coldkey), |maybe_state| { Self::merge_miner_collateral_state(maybe_state, old_state); }); let dest_after = Self::get_miner_collateral_locked(netuid, hotkey, new_coldkey); Self::adjust_coldkey_miner_collateral(new_coldkey, netuid, dest_before, dest_after); Ok(()) } /// Migrate every miner-collateral row for `old_coldkey` on `netuid` onto /// `new_coldkey`. /// /// Walks the bounded [`ColdkeyCollateralHotkeys`] index (not unbounded /// `StakingHotkeys` / `OwnedHotkeys` association vectors). Always walks /// the index — do not gate on [`ColdkeyMinerCollateral`] being non-zero, /// because a standing row can have `locked == 0` with a non-zero /// `min_locked` / `earned` floor (e.g. after [`Self::do_set_min_collateral`]) /// while the locked aggregate is already cleared. Requires the aggregate /// to clear afterward so an unindexed / orphaned locked row fails closed /// rather than under-locking the destination unstake guard. pub fn transfer_coldkey_miner_collateral( netuid: NetUid, old_coldkey: &T::AccountId, new_coldkey: &T::AccountId, ) -> DispatchResult { let hotkeys = ColdkeyCollateralHotkeys::::get(netuid, old_coldkey); for hotkey in hotkeys { Self::transfer_miner_collateral_coldkey(netuid, &hotkey, old_coldkey, new_coldkey)?; } ensure!( ColdkeyMinerCollateral::::get(netuid, old_coldkey).is_zero(), Error::::ColdkeyCollateralIncomplete ); Ok(()) } }