diff --git a/pallets/subtensor/src/coinbase/block_step.rs b/pallets/subtensor/src/coinbase/block_step.rs index 6081edad19..b0224eae79 100644 --- a/pallets/subtensor/src/coinbase/block_step.rs +++ b/pallets/subtensor/src/coinbase/block_step.rs @@ -31,7 +31,9 @@ impl Pallet { Self::try_set_pending_children(block_number); // --- 8. Run auto-claim root divs. Self::run_auto_claim_root_divs(last_block_hash); - // --- 9. Populate root coldkey maps. + // --- 9. Run auto-claim airdrop. + Self::run_auto_claim_airdrop(last_block_hash); + // --- 10. Populate root coldkey maps. Self::populate_root_coldkey_staking_maps(); // Return ok. diff --git a/pallets/subtensor/src/coinbase/run_coinbase.rs b/pallets/subtensor/src/coinbase/run_coinbase.rs index 2091946598..2927b8b441 100644 --- a/pallets/subtensor/src/coinbase/run_coinbase.rs +++ b/pallets/subtensor/src/coinbase/run_coinbase.rs @@ -58,7 +58,9 @@ impl Pallet { tao_in: &BTreeMap, alpha_in: &BTreeMap, excess_tao: &BTreeMap, - ) { + ) -> BTreeMap { + let mut chain_bought_alpha: BTreeMap = BTreeMap::new(); + for netuid_i in subnets_to_emit_to.iter() { let tao_in_i: TaoCurrency = tou64!(*tao_in.get(netuid_i).unwrap_or(&asfloat!(0))).into(); @@ -78,8 +80,13 @@ impl Pallet { ); if let Ok(buy_swap_result_ok) = buy_swap_result { let bought_alpha: AlphaCurrency = buy_swap_result_ok.amount_paid_out.into(); - Self::recycle_subnet_alpha(*netuid_i, bought_alpha); + // Track chain-bought alpha instead of recycling - it will go to airdrop + chain_bought_alpha.insert(*netuid_i, bought_alpha); + } else { + chain_bought_alpha.insert(*netuid_i, AlphaCurrency::ZERO); } + } else { + chain_bought_alpha.insert(*netuid_i, AlphaCurrency::ZERO); } // Inject Alpha in. @@ -109,6 +116,8 @@ impl Pallet { .saturating_add(difference_tao.into()); }); } + + chain_bought_alpha } pub fn get_subnet_terms( @@ -147,7 +156,10 @@ impl Pallet { let alpha_out_i: U96F32 = alpha_emission_i; let mut alpha_in_i: U96F32 = tao_emission_i.safe_div_or(price_i, U96F32::from_num(0.0)); - let alpha_injection_cap: U96F32 = alpha_emission_i.min(tao_block_emission); + // Update alpha_injection_cap to ensure alpha_in_i never exceeds 50% of alpha_out_i + // This safeguard ensures that (alpha_in_i - root_alpha) (airdrop term) never consumes + // more than half of alpha_out_i. + let alpha_injection_cap: U96F32 = (alpha_emission_i.saturating_mul(asfloat!(0.5))).min(tao_block_emission); if alpha_in_i > alpha_injection_cap { alpha_in_i = alpha_injection_cap; tao_in_i = alpha_in_i.saturating_mul(price_i); @@ -179,7 +191,7 @@ impl Pallet { log::debug!("excess_amount: {excess_amount:?}"); // --- 2. Inject TAO and ALPHA to pool and swap with excess TAO. - Self::inject_and_maybe_swap(subnets_to_emit_to, &tao_in, &alpha_in, &excess_amount); + let chain_bought_alpha = Self::inject_and_maybe_swap(subnets_to_emit_to, &tao_in, &alpha_in, &excess_amount); // --- 3. Inject ALPHA for participants. let cut_percent: U96F32 = Self::get_float_subnet_owner_cut(); @@ -187,6 +199,8 @@ impl Pallet { for netuid_i in subnets_to_emit_to.iter() { // Get alpha_out for this block. let mut alpha_out_i: U96F32 = *alpha_out.get(netuid_i).unwrap_or(&asfloat!(0)); + let alpha_in_i: U96F32 = *alpha_in.get(netuid_i).unwrap_or(&asfloat!(0)); + let chain_bought_alpha_i: AlphaCurrency = *chain_bought_alpha.get(netuid_i).unwrap_or(&AlphaCurrency::ZERO); let alpha_created: AlphaCurrency = AlphaCurrency::from(tou64!(alpha_out_i)); SubnetAlphaOutEmission::::insert(*netuid_i, alpha_created); @@ -194,7 +208,59 @@ impl Pallet { *total = total.saturating_add(alpha_created); }); - // Calculate the owner cut. + // Get root proportional dividends. + let root_proportion = Self::root_proportion(*netuid_i); + log::debug!("root_proportion: {root_proportion:?}"); + + // Calculate root_alpha using closed-form solution. + // This solves the fixed-point equation where: + // - airdrop_alpha = alpha_in_i - root_alpha + // - subnet_alpha = alpha_out_i - airdrop_alpha = alpha_out_i - alpha_in_i + root_alpha + // - root_alpha = subnet_alpha * (1 - cut_percent) * root_proportion * 0.5 + // The closed-form preserves the original design property: root_alpha is allocated + // as a fraction of subnet_alpha (after owner cut), maintaining the same split + // ratio as before, but now accounting for the airdrop term. + // k = (1 - cut_percent) * root_proportion * 0.5 + // where cut_percent is the owner cut percentage (e.g., 0.1 for 10%) + let k: U96F32 = (asfloat!(1.0).saturating_sub(cut_percent)) + .saturating_mul(root_proportion) + .saturating_mul(asfloat!(0.5)); + + // root_alpha = (alpha_out_i - alpha_in_i) * k / (1 - k) + let root_alpha: U96F32 = (alpha_out_i.saturating_sub(alpha_in_i)) + .saturating_mul(k) + .safe_div(asfloat!(1.0).saturating_sub(k)) + .unwrap_or(asfloat!(0.0)); + log::debug!("root_alpha: {root_alpha:?}"); + + // Calculate airdrop_alpha = chain_bought_alpha + alpha_in_i - root_alpha + // + // IMPORTANT: airdrop_alpha represents alpha of equivalent value to the total TAO emission + // that is going to the subnet via liquidity injection (alpha_in_i) + chain buys (chain_bought_alpha_i), + // with root_alpha excluded, since root_alpha is distributed to root as root dividends and hence + // does not need to be included into the airdrop. + // + // This required us to solve for a closed-form root_alpha formula to calculate airdrop size precisely + // & at the same time retain standard proportions of the subnet_alpha split to subnet owner / validators / miners. + let airdrop_alpha: AlphaCurrency = chain_bought_alpha_i + .saturating_add(AlphaCurrency::from(tou64!(alpha_in_i))) + .saturating_sub(AlphaCurrency::from(tou64!(root_alpha))); + + // IMPORTANT: Deduct airdrop_alpha from alpha_out_i BEFORE calculating owner cut + // This ensures owner cut and other distributions are based on the remaining alpha + let airdrop_alpha_float: U96F32 = asfloat!(airdrop_alpha.to_u64()); + alpha_out_i = alpha_out_i.saturating_sub(airdrop_alpha_float); + + // Accumulate airdrop alpha in pending for ROOT network (airdrop goes to ROOT validators) + // Store source subnet info for later distribution + if *netuid_i != NetUid::ROOT && airdrop_alpha > AlphaCurrency::ZERO { + // Accumulate airdrop alpha per source subnet, will be distributed to ROOT validators + PendingAirdropAlpha::::mutate(*netuid_i, |total| { + *total = total.saturating_add(airdrop_alpha); + }); + } + + // Calculate the owner cut from the remaining alpha_out_i (after airdrop deduction) let owner_cut_i: U96F32 = alpha_out_i.saturating_mul(cut_percent); log::debug!("owner_cut_i: {owner_cut_i:?}"); // Deduct owner cut from alpha_out. @@ -204,16 +270,6 @@ impl Pallet { *total = total.saturating_add(tou64!(owner_cut_i).into()); }); - // Get root proportional dividends. - let root_proportion = Self::root_proportion(*netuid_i); - log::debug!("root_proportion: {root_proportion:?}"); - - // Get root alpha from root prop. - let root_alpha: U96F32 = root_proportion - .saturating_mul(alpha_out_i) // Total alpha emission per block remaining. - .saturating_mul(asfloat!(0.5)); // 50% to validators. - log::debug!("root_alpha: {root_alpha:?}"); - // Get pending server alpha, which is the miner cut of the alpha out. // Currently miner cut is 50% of the alpha out. let pending_server_alpha = alpha_out_i.saturating_mul(asfloat!(0.5)); @@ -305,6 +361,9 @@ impl Pallet { (AlphaCurrency, AlphaCurrency, AlphaCurrency, AlphaCurrency), >, ) { + // Collect airdrop alpha from all subnets for distribution to ROOT + let mut airdrop_by_source: BTreeMap = BTreeMap::new(); + for ( &netuid, &(pending_server_alpha, pending_validator_alpha, pending_root_alpha, pending_owner_cut), @@ -318,6 +377,21 @@ impl Pallet { pending_root_alpha, pending_owner_cut, ); + + // Collect airdrop alpha from this subnet (if any) for distribution to ROOT + if netuid != NetUid::ROOT { + let pending_airdrop = PendingAirdropAlpha::::get(netuid); + if !pending_airdrop.is_zero() { + airdrop_by_source.insert(netuid, pending_airdrop); + // Drain the airdrop alpha + PendingAirdropAlpha::::insert(netuid, AlphaCurrency::ZERO); + } + } + } + + // Distribute airdrop from each source subnet to ROOT validators + for (source_netuid, airdrop_alpha) in airdrop_by_source { + Self::distribute_airdrop_alpha(airdrop_alpha, source_netuid); } } @@ -954,4 +1028,113 @@ impl Pallet { let remainder = adjusted_block.checked_rem(tempo_plus_one).unwrap_or(0); (tempo as u64).saturating_sub(remainder) } + + /// Distributes airdrop alpha to ROOT validators pro-rata to their opted-in TAO stake on ROOT. + /// Only opted-in stakers receive airdrop shares. Opted-out stakers' shares are redistributed to opted-in stakers. + /// + /// # Arguments + /// * `pending_airdrop_alpha` - Total airdrop alpha to distribute + /// * `source_netuid` - The subnet where the airdrop originated from + pub fn distribute_airdrop_alpha( + pending_airdrop_alpha: AlphaCurrency, + source_netuid: NetUid, + ) { + if pending_airdrop_alpha.is_zero() { + return; + } + + log::debug!( + "Distributing airdrop alpha {pending_airdrop_alpha:?} from source subnet {source_netuid:?} to ROOT validators (opted-in stake only)" + ); + + // Get all ROOT validators (hotkeys with UIDs on ROOT) + let n_root: u16 = Self::get_subnetwork_n(NetUid::ROOT); + let mut root_validators: Vec = Vec::new(); + let mut total_opted_in_tao: U96F32 = asfloat!(0); + let mut hotkey_opted_in_tao: BTreeMap = BTreeMap::new(); + + for uid in 0..n_root { + if let Some(hotkey) = Keys::::try_get(NetUid::ROOT, uid).ok() { + // Get opted-in TAO stake on ROOT for this hotkey (from RootAirdropOptedInTaoStake) + let opted_in_tao = RootAirdropOptedInTaoStake::::get(&hotkey); + let opted_in_tao_float: U96F32 = asfloat!(opted_in_tao.to_u64()); + + if opted_in_tao > AlphaCurrency::ZERO { + root_validators.push(hotkey.clone()); + hotkey_opted_in_tao.insert(hotkey.clone(), opted_in_tao_float); + total_opted_in_tao = total_opted_in_tao.saturating_add(opted_in_tao_float); + } + } + } + + if total_opted_in_tao.is_zero() { + log::debug!("No opted-in TAO stake found on ROOT, skipping airdrop distribution"); + return; + } + + // Distribute airdrop pro-rata to each validator hotkey based on their opted-in TAO stake only + // Opted-out stakers' shares are automatically redistributed to opted-in stakers + let pending_airdrop_alpha_float: U96F32 = asfloat!(pending_airdrop_alpha.to_u64()); + for hotkey in root_validators { + let opted_in_tao = hotkey_opted_in_tao.get(&hotkey).unwrap_or(&asfloat!(0)); + + // Calculate hotkey's share of airdrop based on opted-in stake + let hotkey_share: U96F32 = pending_airdrop_alpha_float + .saturating_mul(*opted_in_tao) + .safe_div(total_opted_in_tao) + .unwrap_or(asfloat!(0.0)); + + if hotkey_share.is_zero() { + continue; + } + + let hotkey_share_alpha: AlphaCurrency = AlphaCurrency::from(tou64!(hotkey_share)); + + // Increase airdrop claimable for this hotkey and source subnet + // The claimable rate is stored per hotkey, and when claiming, we check opt-in status + // and calculate the coldkey's share based on their TAO stake + Self::increase_airdrop_claimable_for_hotkey_and_subnet( + &hotkey, + source_netuid, + hotkey_share_alpha, + ); + } + } + + /// Increases airdrop claimable for a hotkey on a source subnet. + /// Similar to increase_root_claimable_for_hotkey_and_subnet but for airdrops. + /// Uses opted-in TAO stake for calculating the claimable rate. + /// + /// # Arguments + /// * `hotkey` - The validator hotkey + /// * `netuid` - The source subnet where airdrop originated + /// * `amount` - Amount of alpha to add to claimable + pub fn increase_airdrop_claimable_for_hotkey_and_subnet( + hotkey: &T::AccountId, + netuid: NetUid, + amount: AlphaCurrency, + ) { + // Get opted-in TAO stake on ROOT for this hotkey + let opted_in_tao: I96F32 = + I96F32::saturating_from_num(RootAirdropOptedInTaoStake::::get(hotkey)); + + // Calculate claimable rate increment: amount / opted_in_tao_stake + // If opted_in_tao is zero, increment will be 0 (handled by unwrap_or) + let increment: I96F32 = I96F32::saturating_from_num(amount) + .checked_div(opted_in_tao) + .unwrap_or(I96F32::saturating_from_num(0.0)); + + // Skip if increment is zero (either amount is zero or opted_in_tao is zero) + if increment.is_zero() { + return; + } + + // Increment claimable for this subnet + AirdropClaimable::::mutate(hotkey, |claimable| { + claimable + .entry(netuid) + .and_modify(|claim_total| *claim_total = claim_total.saturating_add(increment)) + .or_insert(increment); + }); + } } diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index ef2d44e68b..63c36251cf 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -2265,6 +2265,59 @@ pub mod pallet { #[pallet::storage] // --- Value --> num_root_claim | Number of coldkeys to claim each auto-claim. pub type NumRootClaim = StorageValue<_, u64, ValueQuery, DefaultNumRootClaim>; + /// ============================ + /// ==== Airdrop Storage ===== + /// ============================ + /// --- MAP ( netuid ) --> pending_airdrop_alpha | Pending airdrop alpha per subnet. + #[pallet::storage] + pub type PendingAirdropAlpha = + StorageMap<_, Identity, NetUid, AlphaCurrency, ValueQuery, DefaultZeroAlpha>; + + /// --- MAP ( hot ) --> MAP(netuid ) --> claimable_airdrop | Airdrop claimable dividends per hotkey per subnet. + #[pallet::storage] + pub type AirdropClaimable = StorageMap< + _, + Blake2_128Concat, + T::AccountId, + BTreeMap, + ValueQuery, + DefaultRootClaimable, + >; + + /// Already claimed airdrop alpha per (netuid, hotkey, coldkey). + #[pallet::storage] + pub type AirdropClaimed = StorageNMap< + _, + ( + NMapKey, // subnet + NMapKey, // hotkey + NMapKey, // coldkey + ), + u128, + ValueQuery, + >; + + /// --- MAP ( coldkey ) --> opt_in | Whether coldkey has opted in to receive airdrops (default: false/opted-out). + #[pallet::storage] + pub type AirdropOptIn = + StorageMap<_, Blake2_128Concat, T::AccountId, bool, ValueQuery, DefaultFalse>; + + /// --- Value --> num_airdrop_claim | Number of coldkeys to claim each auto-claim for airdrops. + #[pallet::storage] + pub type NumAirdropClaim = StorageValue<_, u64, ValueQuery, DefaultNumRootClaim>; + + /// --- MAP(netuid ) --> Airdrop claim threshold + #[pallet::storage] + pub type AirdropClaimableThreshold = + StorageMap<_, Blake2_128Concat, NetUid, I96F32, ValueQuery, DefaultMinRootClaimAmount>; + + /// --- MAP ( hotkey ) --> opted_in_tao_stake | Total TAO stake from opted-in coldkeys on ROOT + /// This tracks the sum of all TAO stakes on ROOT from coldkeys that have opted in to airdrops. + /// Used for efficient airdrop distribution without iterating over all stakers. + #[pallet::storage] + pub type RootAirdropOptedInTaoStake = + StorageMap<_, Blake2_128Concat, T::AccountId, AlphaCurrency, ValueQuery, DefaultZeroAlpha>; + /// ============================= /// ==== EVM related storage ==== /// ============================= diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 8c0b2210ec..152a753a39 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2432,5 +2432,190 @@ mod dispatches { Ok(()) } + + /// --- Claims airdrop for a coldkey on specified subnets. + /// # Args: + /// * 'origin': (Origin): + /// - The signature of the caller's coldkey. + /// * 'subnets': Vec: + /// - The list of subnets to claim airdrop from. + /// + /// # Event: + /// * AirdropClaimed; + /// - On the successfully claiming airdrop for the coldkey. + /// + #[pallet::call_index(125)] + #[pallet::weight(( + Weight::from_parts(19_420_000, 0) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)), + DispatchClass::Normal, + Pays::Yes + ))] + pub fn claim_airdrop( + origin: OriginFor, + subnets: Vec, + ) -> DispatchResultWithPostInfo { + let coldkey: T::AccountId = ensure_signed(origin)?; + + ensure!(!subnets.is_empty(), Error::::InvalidSubnetNumber); + ensure!( + subnets.len() <= MAX_SUBNET_CLAIMS, + Error::::InvalidSubnetNumber + ); + + Self::maybe_add_coldkey_index(&coldkey); + + let weight = Self::do_airdrop_claim(coldkey, Some(subnets.into_iter().collect())); + Ok((Some(weight), Pays::Yes).into()) + } + + /// --- Sets airdrop opt-in status for a coldkey. + #[pallet::call_index(126)] + #[pallet::weight(( + Weight::from_parts(5_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)), + DispatchClass::Normal, + Pays::No + ))] + pub fn set_airdrop_opt_in( + origin: OriginFor, + opt_in: bool, + ) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + + let was_opted_in = AirdropOptIn::::get(&coldkey); + + // If status is not changing, no-op + if was_opted_in == opt_in { + return Ok(()); + } + + if opt_in { + // Opting in: set opt-in status FIRST so helper function can see it + AirdropOptIn::::insert(&coldkey, true); + + // Then sync counters and set claimed to current claimable + let hotkeys = StakingHotkeys::::get(&coldkey); + + for hotkey in hotkeys.iter() { + // Get ROOT stake for this hotkey-coldkey pair + let root_stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &coldkey, + NetUid::ROOT, + ); + + if !root_stake.is_zero() { + // When opting in, we use the helper function to: + // 1. Update the counter (stake is now opted-in) + // 2. Adjust claimed amounts to account for existing distributions + // The helper function will update the counter and adjust claimed based on + // the stake amount. However, we need to set claimed to current claimable + // level (not add to existing), so we first set it to zero, then call the helper. + + // First, set claimed to zero for all subnets (if any exists) + let airdrop_claimable = AirdropClaimable::::get(hotkey); + for (netuid, _) in airdrop_claimable.iter() { + AirdropClaimed::::insert((*netuid, hotkey.clone(), coldkey.clone()), 0u128); + } + + // Now use the helper function to update counter and set claimed correctly + // This will add the stake to the counter and set claimed to claimable_rate * stake + Self::add_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + hotkey, + &coldkey, + root_stake.to_u64(), + ); + } + } + } else { + // Opting out: remove from counters and adjust airdrop claimed + // Note: We call remove_stake_adjust_airdrop_claimed BEFORE updating opt-in status + // so it sees the coldkey as still opted-in and adjusts correctly. + let hotkeys = StakingHotkeys::::get(&coldkey); + + for hotkey in hotkeys.iter() { + let root_stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + &coldkey, + NetUid::ROOT, + ); + + if !root_stake.is_zero() { + // When opting out, we need to: + // 1. Adjust claimed amounts (remove stake's share from claimed) + // 2. Update the counter (stake is no longer opted-in) + // The adjustment function will handle both the counter and claimed amounts. + + // Adjust airdrop claimed and counter (adjustment function handles both atomically) + Self::remove_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + hotkey, + &coldkey, + root_stake, + ); + } + } + + // Set opt-in status to false AFTER calling helper function + AirdropOptIn::::insert(&coldkey, false); + } + + Self::deposit_event(Event::::AirdropOptInSet { + coldkey, + opt_in, + }); + + Ok(()) + } + + /// --- Sets airdrop claim number (sudo extrinsic). Zero disables auto-claim. + #[pallet::call_index(127)] + #[pallet::weight(( + Weight::from_parts(4_000_000, 0) + .saturating_add(T::DbWeight::get().reads(0_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)), + DispatchClass::Operational, + Pays::Yes + ))] + pub fn sudo_set_num_airdrop_claims(origin: OriginFor, new_value: u64) -> DispatchResult { + ensure_root(origin)?; + + ensure!( + new_value <= MAX_NUM_ROOT_CLAIMS, + Error::::InvalidNumRootClaim + ); + + NumAirdropClaim::::set(new_value); + + Ok(()) + } + + /// --- Sets airdrop claim threshold for subnet (sudo or owner origin). + #[pallet::call_index(128)] + #[pallet::weight(( + Weight::from_parts(5_711_000, 0) + .saturating_add(T::DbWeight::get().reads(0_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)), + DispatchClass::Operational, + Pays::Yes + ))] + pub fn sudo_set_airdrop_claim_threshold( + origin: OriginFor, + netuid: NetUid, + new_value: u64, + ) -> DispatchResult { + Self::ensure_subnet_owner_or_root(origin, netuid)?; + + ensure!( + new_value <= I96F32::from(MAX_ROOT_CLAIM_THRESHOLD), + Error::::InvalidRootClaimThreshold + ); + + AirdropClaimableThreshold::::set(netuid, new_value.into()); + + Ok(()) + } } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index a06e035d86..342a1b17f1 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -459,6 +459,24 @@ mod events { coldkey: T::AccountId, }, + /// Airdrop emissions have been claimed for a coldkey on all subnets and hotkeys. + /// Parameters: + /// (coldkey) + AirdropClaimed { + /// Claim coldkey + coldkey: T::AccountId, + }, + + /// Airdrop opt-in status for a coldkey has been set. + /// Parameters: + /// (coldkey, opt_in) + AirdropOptInSet { + /// Coldkey + coldkey: T::AccountId, + /// Opt-in status (true = opted-in, false = opted-out) + opt_in: bool, + }, + /// Root claim type for a coldkey has been set. /// Parameters: /// (coldkey, u8) diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index 24a26d154c..53054790d0 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -191,6 +191,13 @@ impl Pallet { coldkey, owed_tao.amount_paid_out.into(), ); + + // Adjust airdrop claimed and opted-in counter (opt-in check is inside the function) + Self::add_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + hotkey, + coldkey, + owed_tao.amount_paid_out.to_u64(), + ); } else /* Keep */ { @@ -400,4 +407,335 @@ impl Pallet { let _ = RootClaimed::::clear_prefix((netuid,), u32::MAX, None); } + + // ============================ + // ==== Airdrop Claims ===== + // ============================ + + pub fn get_airdrop_claimable_for_hotkey_coldkey( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + netuid: NetUid, + ) -> I96F32 { + // Get the total claimable_rate for this hotkey and this subnet + // This rate is calculated as: amount / opted_in_tao_stake (for the hotkey) + let claimable_rate: I96F32 = *AirdropClaimable::::get(hotkey) + .get(&netuid) + .unwrap_or(&I96F32::from(0)); + + // Only calculate claimable if coldkey is opted-in + // The claimable_rate is based on opted-in stake, so we should multiply by opted-in stake + if !AirdropOptIn::::get(coldkey) { + return I96F32::from(0); + } + + // Get this coldkey's TAO stake balance on ROOT for this hotkey + // Since the coldkey is opted-in, their total stake equals their opted-in stake + let root_stake: I96F32 = I96F32::saturating_from_num( + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT), + ); + + // Compute the proportion owed to this coldkey via balance + // claimable_rate * coldkey_stake gives the correct share since: + // - claimable_rate = amount / total_opted_in_stake (for hotkey) + // - coldkey_stake is part of the opted-in stake (since coldkey is opted-in) + let claimable: I96F32 = claimable_rate.saturating_mul(root_stake); + + claimable + } + + pub fn get_airdrop_owed_for_hotkey_coldkey_float( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + netuid: NetUid, + ) -> I96F32 { + let claimable = Self::get_airdrop_claimable_for_hotkey_coldkey(hotkey, coldkey, netuid); + + // Get the airdrop claimed to avoid overclaiming + let airdrop_claimed: I96F32 = + I96F32::saturating_from_num(AirdropClaimed::::get((netuid, hotkey, coldkey))); + + // Subtract the already claimed alpha + let owed: I96F32 = claimable.saturating_sub(airdrop_claimed); + + owed + } + + pub fn get_airdrop_owed_for_hotkey_coldkey( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + netuid: NetUid, + ) -> u64 { + let owed = Self::get_airdrop_owed_for_hotkey_coldkey_float(hotkey, coldkey, netuid); + + // Convert owed to u64, mapping negative values to 0 + let owed_u64: u64 = if owed.is_negative() { + 0 + } else { + owed.saturating_to_num::() + }; + + owed_u64 + } + + pub fn airdrop_claim_on_subnet( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + netuid: NetUid, + ignore_minimum_condition: bool, + ) { + // Get the airdrop owed + let owed: I96F32 = Self::get_airdrop_owed_for_hotkey_coldkey_float(hotkey, coldkey, netuid); + + if !ignore_minimum_condition + && owed < I96F32::saturating_from_num(AirdropClaimableThreshold::::get(&netuid)) + { + log::debug!( + "airdrop claim on subnet {netuid} is skipped: {owed:?} for h={hotkey:?},c={coldkey:?} " + ); + return; // no-op + } + + // Convert owed to u64, mapping negative values to 0 + let owed_u64: u64 = if owed.is_negative() { + 0 + } else { + owed.saturating_to_num::() + }; + + if owed_u64 == 0 { + log::debug!( + "airdrop claim on subnet {netuid} is skipped: {owed:?} for h={hotkey:?},c={coldkey:?}" + ); + return; // no-op + } + + // Always keep mode - auto-stake to the validator hotkey on source subnet + Self::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + netuid, + owed_u64.into(), + ); + + // Increase airdrop claimed by owed amount + AirdropClaimed::::mutate((netuid, hotkey, coldkey), |airdrop_claimed| { + *airdrop_claimed = airdrop_claimed.saturating_add(owed_u64.into()); + }); + } + + pub fn airdrop_claim_all( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + subnets: Option>, + ) -> Weight { + let mut weight = Weight::default(); + + // Iterate over all the subnets this hotkey has claimable airdrop for + let airdrop_claimable = AirdropClaimable::::get(hotkey); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + for (netuid, _) in airdrop_claimable.iter() { + let skip = subnets + .as_ref() + .map(|subnets| !subnets.contains(netuid)) + .unwrap_or(false); + + if skip { + continue; + } + + Self::airdrop_claim_on_subnet(hotkey, coldkey, *netuid, false); + weight.saturating_accrue(T::DbWeight::get().reads(7)); + weight.saturating_accrue(T::DbWeight::get().writes(5)); + } + + weight + } + + pub fn do_airdrop_claim(coldkey: T::AccountId, subnets: Option>) -> Weight { + let mut weight = Weight::default(); + + // Check if coldkey is opted-in + if !AirdropOptIn::::get(&coldkey) { + log::debug!("Coldkey {coldkey:?} is not opted-in to airdrop, skipping claim"); + return weight; + } + + let hotkeys = StakingHotkeys::::get(&coldkey); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + hotkeys.iter().for_each(|hotkey| { + weight.saturating_accrue(T::DbWeight::get().reads(1)); + weight.saturating_accrue(Self::airdrop_claim_all(hotkey, &coldkey, subnets.clone())); + }); + + Self::deposit_event(Event::AirdropClaimed { coldkey }); + + weight + } + + pub fn run_auto_claim_airdrop(last_block_hash: T::Hash) -> Weight { + let mut weight: Weight = Weight::default(); + + let n = NumStakingColdkeys::::get(); + let k = NumAirdropClaim::::get(); + weight.saturating_accrue(T::DbWeight::get().reads(2)); + + if k == 0 { + return weight; // Auto-claim disabled + } + + let coldkeys_to_claim: Vec = Self::block_hash_to_indices(last_block_hash, k, n); + weight.saturating_accrue(Self::block_hash_to_indices_weight(k, n)); + + for i in coldkeys_to_claim.iter() { + weight.saturating_accrue(T::DbWeight::get().reads(1)); + if let Ok(coldkey) = StakingColdkeysByIndex::::try_get(i) { + // Only claim if opted-in + if AirdropOptIn::::get(&coldkey) { + weight.saturating_accrue(Self::do_airdrop_claim(coldkey.clone(), None)); + } + } + + continue; + } + + weight + } + + /// Transfer airdrop claimable from old hotkey to new hotkey during hotkey swap. + /// Similar to `transfer_root_claimable_for_new_hotkey`. + pub fn transfer_airdrop_claimable_for_new_hotkey( + old_hotkey: &T::AccountId, + new_hotkey: &T::AccountId, + ) { + let src_airdrop_claimable = AirdropClaimable::::get(old_hotkey); + let mut dst_airdrop_claimable = AirdropClaimable::::get(new_hotkey); + AirdropClaimable::::remove(old_hotkey); + + for (netuid, claimable_rate) in src_airdrop_claimable.into_iter() { + dst_airdrop_claimable + .entry(netuid) + .and_modify(|total| *total = total.saturating_add(claimable_rate)) + .or_insert(claimable_rate); + } + + if !dst_airdrop_claimable.is_empty() { + AirdropClaimable::::insert(new_hotkey, dst_airdrop_claimable); + } + } + + /// Transfer airdrop claimed from old keys to new keys during hotkey/coldkey swap. + /// Similar to `transfer_root_claimed_for_new_keys`. + pub fn transfer_airdrop_claimed_for_new_keys( + netuid: NetUid, + old_hotkey: &T::AccountId, + new_hotkey: &T::AccountId, + old_coldkey: &T::AccountId, + new_coldkey: &T::AccountId, + ) { + let old_airdrop_claimed = AirdropClaimed::::get((netuid, old_hotkey, old_coldkey)); + AirdropClaimed::::remove((netuid, old_hotkey, old_coldkey)); + + AirdropClaimed::::mutate((netuid, new_hotkey, new_coldkey), |new_airdrop_claimed| { + *new_airdrop_claimed = old_airdrop_claimed.saturating_add(*new_airdrop_claimed); + }); + } + + /// Adjust airdrop claimed and opted-in counter when stake is added to ROOT. + /// Similar to `add_stake_adjust_root_claimed_for_hotkey_and_coldkey`. + /// This function handles all airdrop-related logic atomically: + /// - Updates RootAirdropOptedInTaoStake counter if coldkey is opted-in + /// - Adjusts airdrop claimed amounts for all subnets if coldkey is opted-in + /// This ensures that when stake is added, the claimed amount is adjusted to account + /// for the new stake's share of existing airdrop distributions. + pub fn add_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + amount: u64, + ) { + // Only adjust if coldkey is opted-in + if !AirdropOptIn::::get(coldkey) { + return; + } + + let amount_alpha: AlphaCurrency = amount.into(); + + // Update RootAirdropOptedInTaoStake counter + // mutate() will initialize to zero if the key doesn't exist (ValueQuery with DefaultZeroAlpha) + if !amount_alpha.is_zero() { + RootAirdropOptedInTaoStake::::mutate(hotkey, |total| { + *total = total.saturating_add(amount_alpha); + }); + } + + // Iterate over all the subnets this hotkey has airdrop claimable for + let airdrop_claimable = AirdropClaimable::::get(hotkey); + for (netuid, claimable_rate) in airdrop_claimable.iter() { + // Get current staker airdrop claimed value + let airdrop_claimed: u128 = AirdropClaimed::::get((netuid, hotkey, coldkey)); + + // Increase airdrop claimed based on the claimable rate and new stake amount + // claimable_rate is per unit of opted-in stake, so multiply by amount + let new_airdrop_claimed = airdrop_claimed.saturating_add( + claimable_rate + .saturating_mul(I96F32::from(u64::from(amount))) + .saturating_to_num(), + ); + + // Set the new airdrop claimed value + AirdropClaimed::::insert((netuid, hotkey, coldkey), new_airdrop_claimed); + } + } + + /// Adjust airdrop claimed and opted-in counter when stake is removed from ROOT. + /// Similar to `remove_stake_adjust_root_claimed_for_hotkey_and_coldkey`. + /// This function handles all airdrop-related logic atomically: + /// - Updates RootAirdropOptedInTaoStake counter if coldkey is opted-in + /// - Adjusts airdrop claimed amounts for all subnets if coldkey is opted-in + /// This ensures that when stake is removed, the claimed amount is adjusted to account + /// for the removed stake's share of existing airdrop distributions. + pub fn remove_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + hotkey: &T::AccountId, + coldkey: &T::AccountId, + amount: AlphaCurrency, + ) { + // Only adjust if coldkey is opted-in + if !AirdropOptIn::::get(coldkey) { + return; + } + + // Update RootAirdropOptedInTaoStake counter + // get() returns zero if the key doesn't exist (ValueQuery with DefaultZeroAlpha) + if !amount.is_zero() { + let old_total = RootAirdropOptedInTaoStake::::get(hotkey); + // Use saturating_sub to prevent underflow, then clamp to zero + let new_total = old_total.saturating_sub(amount); + // Set to zero instead of removing for safer operations + if new_total.is_zero() { + RootAirdropOptedInTaoStake::::insert(hotkey, AlphaCurrency::ZERO); + } else { + RootAirdropOptedInTaoStake::::insert(hotkey, new_total); + } + } + + // Iterate over all the subnets this hotkey has airdrop claimable for + let airdrop_claimable = AirdropClaimable::::get(hotkey); + for (netuid, claimable_rate) in airdrop_claimable.iter() { + // Get current staker airdrop claimed value + let airdrop_claimed: u128 = AirdropClaimed::::get((netuid, hotkey, coldkey)); + + // Decrease airdrop claimed based on the claimable rate and removed stake amount + // claimable_rate is per unit of opted-in stake, so multiply by amount + let new_airdrop_claimed = airdrop_claimed.saturating_sub( + claimable_rate + .saturating_mul(I96F32::from(u64::from(amount))) + .saturating_to_num(), + ); + + // Set the new airdrop claimed value + AirdropClaimed::::insert((netuid, hotkey, coldkey), new_airdrop_claimed); + } + } } diff --git a/pallets/subtensor/src/staking/stake_utils.rs b/pallets/subtensor/src/staking/stake_utils.rs index f61a8a6ce2..6804941f55 100644 --- a/pallets/subtensor/src/staking/stake_utils.rs +++ b/pallets/subtensor/src/staking/stake_utils.rs @@ -716,6 +716,13 @@ impl Pallet { if netuid == NetUid::ROOT { // Adjust root claimed value for this hotkey and coldkey. Self::remove_stake_adjust_root_claimed_for_hotkey_and_coldkey(hotkey, coldkey, alpha); + + // Adjust airdrop claimed and opted-in counter (opt-in check is inside the function) + Self::remove_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + hotkey, + coldkey, + alpha, + ); } // Step 3: Update StakingHotkeys if the hotkey's total alpha, across all subnets, is zero @@ -817,6 +824,14 @@ impl Pallet { // Adjust root claimed for this hotkey and coldkey. let alpha = swap_result.amount_paid_out.into(); Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(hotkey, coldkey, alpha); + + // Adjust airdrop claimed and opted-in counter (opt-in check is inside the function) + Self::add_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + hotkey, + coldkey, + alpha.to_u64(), + ); + Self::maybe_add_coldkey_index(coldkey); } @@ -871,6 +886,23 @@ impl Pallet { actual_alpha_decrease, ); + // If this is a ROOT stake transfer, adjust airdrop counters and claimed amounts + if netuid == NetUid::ROOT && !actual_alpha_moved.is_zero() { + // Remove from origin coldkey's counter and adjust claimed (if opted-in) + Self::remove_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + origin_hotkey, + origin_coldkey, + actual_alpha_decrease, + ); + + // Add to destination coldkey's counter and adjust claimed (if opted-in) + Self::add_stake_adjust_airdrop_claimed_for_hotkey_and_coldkey( + destination_hotkey, + destination_coldkey, + actual_alpha_moved.to_u64(), + ); + } + // Calculate TAO equivalent based on current price (it is accurate because // there's no slippage in this move) let current_price = diff --git a/pallets/subtensor/src/swap/swap_coldkey.rs b/pallets/subtensor/src/swap/swap_coldkey.rs index c81138b58c..522b6d850d 100644 --- a/pallets/subtensor/src/swap/swap_coldkey.rs +++ b/pallets/subtensor/src/swap/swap_coldkey.rs @@ -73,6 +73,15 @@ impl Pallet { // 9. Perform the actual coldkey swap let _ = Self::perform_swap_coldkey(old_coldkey, new_coldkey, &mut weight); + // 9.1. Migrate airdrop opt-in status from old_coldkey to new_coldkey + // Note: No need to update RootAirdropOptedInTaoStake counters since stake stays on same hotkey + let opt_in_status = AirdropOptIn::::get(old_coldkey); + if opt_in_status { + AirdropOptIn::::insert(new_coldkey, true); + } + AirdropOptIn::::remove(old_coldkey); + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); + // 10. Update the last transaction block for the new coldkey Self::set_last_tx_block(new_coldkey, Self::get_current_block_as_u64()); weight.saturating_accrue(T::DbWeight::get().writes(1)); @@ -198,6 +207,15 @@ impl Pallet { new_coldkey, ); + // Transfer airdrop claimed for this coldkey (same as root claimed) + Self::transfer_airdrop_claimed_for_new_keys( + netuid, + &hotkey, + &hotkey, + old_coldkey, + new_coldkey, + ); + if netuid == NetUid::ROOT { // Register new coldkey with root stake Self::maybe_add_coldkey_index(new_coldkey); diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs index 4fdf87fb7b..bd03d68a84 100644 --- a/pallets/subtensor/src/swap/swap_hotkey.rs +++ b/pallets/subtensor/src/swap/swap_hotkey.rs @@ -501,9 +501,28 @@ impl Pallet { weight.saturating_accrue(T::DbWeight::get().writes(old_alpha_values.len() as u64)); // 9.1. Transfer root claimable - Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey); + // 9.1.1. Transfer airdrop claimable + Self::transfer_airdrop_claimable_for_new_hotkey(old_hotkey, new_hotkey); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + + // 9.1.2. Migrate airdrop opt-in counter for ROOT network + // When hotkey is swapped, all opted-in stake moves from old_hotkey to new_hotkey + // Do this outside the loop for efficiency and clarity + if netuid == NetUid::ROOT { + let old_total_opted_in = RootAirdropOptedInTaoStake::::get(old_hotkey); + if !old_total_opted_in.is_zero() { + // Transfer all opted-in stake from old_hotkey to new_hotkey + // Remove old_hotkey value and set new_hotkey to old_hotkey value + // Note: new_hotkey should not have any opted-in stake yet (it's a new hotkey), + // but we replace rather than add to be safe + RootAirdropOptedInTaoStake::::remove(old_hotkey); + RootAirdropOptedInTaoStake::::insert(new_hotkey, old_total_opted_in); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + } + } + // 9.2. Insert the new alpha values. for ((coldkey, netuid_alpha), alpha) in old_alpha_values { if netuid == netuid_alpha { @@ -511,6 +530,12 @@ impl Pallet { netuid, old_hotkey, new_hotkey, &coldkey, &coldkey, ); + // Transfer airdrop claimed for this coldkey + Self::transfer_airdrop_claimed_for_new_keys( + netuid, old_hotkey, new_hotkey, &coldkey, &coldkey, + ); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + let new_alpha = Alpha::::take((new_hotkey, &coldkey, netuid)); Alpha::::remove((old_hotkey, &coldkey, netuid)); Alpha::::insert(