diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index ff277c8e19..65208f1e54 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -1,6 +1,7 @@ use super::*; use crate::weights::WeightInfo; use frame_support::storage::{TransactionOutcome, with_transaction}; +use frame_support::traits::tokens::{Fortitude, Precision, Preservation, fungible::Unbalanced}; use frame_support::weights::{Weight, WeightMeter}; use sp_core::Get; use sp_runtime::DispatchError; @@ -625,7 +626,7 @@ impl Pallet { } // Sell the slice to TAO. - let tao = match Self::sell_basket_alpha_for_root_tao(*netuid, take.into()) { + let tao = match Self::sell_basket_alpha_and_debit_subnet(*netuid, take.into()) { Ok(tao) => tao, Err(err) => return TransactionOutcome::Rollback(Err(err)), }; @@ -649,6 +650,15 @@ impl Pallet { return TransactionOutcome::Rollback(Ok(0)); } + // Every subnet sale debited its source account without emitting a balance event. + // Land the aggregate proceeds in the root account once, instead of writing the root + // account (and emitting a Transfer event) once per basket holding. + if swapped_tao > 0 + && let Err(err) = Self::credit_root_account_without_event(swapped_tao.into()) + { + return TransactionOutcome::Rollback(Err(err)); + } + // Stake the redeemed TAO on root for the staker. Only the swapped portion is new TAO // on root (the root-slot portion was already counted in the root reserves). Self::increase_stake_for_hotkey_and_coldkey_on_subnet( @@ -1032,7 +1042,7 @@ impl Pallet { holding_alpha, ); - let tao = match Self::sell_basket_alpha_for_root_tao(netuid, holding_alpha) { + let tao = match Self::sell_basket_alpha_and_debit_subnet(netuid, holding_alpha) { Ok(tao) => tao, Err(err) => { log::error!("Error converting basket holding to root: {err:?}"); @@ -1040,6 +1050,10 @@ impl Pallet { } }; + if let Err(err) = Self::credit_root_account_without_event(tao) { + return TransactionOutcome::Rollback(Err(err)); + } + // Hold the realized TAO as the fund's root-slot (cash) position. Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, @@ -1060,10 +1074,11 @@ impl Pallet { .is_ok() } - /// Sells basket `alpha` on `netuid` for TAO and lands it in the root subnet account, booking - /// the protocol outflow. The alpha must already have been removed from the escrow position. - /// Shared by claim redemption and dissolution conversion; callers stay transactional. - fn sell_basket_alpha_for_root_tao( + /// Sells basket `alpha` on `netuid` for TAO, silently debits the source subnet account, and + /// books the protocol outflow. The alpha must already have been removed from the escrow + /// position. Callers stay transactional and must credit the aggregate proceeds to the root + /// account before committing. + fn sell_basket_alpha_and_debit_subnet( netuid: NetUid, alpha: AlphaBalance, ) -> Result { @@ -1075,17 +1090,32 @@ impl Pallet { ) .inspect_err(|err| log::error!("Error swapping basket alpha for TAO: {err:?}"))?; - let root_subnet_account_id = - Self::get_subnet_account_id(NetUid::ROOT).ok_or(Error::::RootNetworkDoesNotExist)?; - - Self::transfer_tao_from_subnet(netuid, &root_subnet_account_id, out.amount_paid_out.into()) - .inspect_err(|err| log::error!("Error transferring basket TAO from subnet: {err:?}"))?; + let subnet_account = + Self::get_subnet_account_id(netuid).ok_or(Error::::SubnetNotExists)?; + ::Currency::decrease_balance( + &subnet_account, + out.amount_paid_out.into(), + Precision::Exact, + Preservation::Expendable, + Fortitude::Polite, + ) + .inspect_err(|err| log::error!("Error debiting basket TAO from subnet: {err:?}"))?; Self::record_protocol_outflow(netuid, out.amount_paid_out); Ok(out.amount_paid_out) } + /// Credits physically realized basket TAO to the root subnet account without a Balances + /// event. This is paired with the per-source raw debits above inside a storage transaction, + /// so issuance is unchanged and any failure rolls the whole move back. + fn credit_root_account_without_event(amount: TaoBalance) -> Result<(), DispatchError> { + let root_account = + Self::get_subnet_account_id(NetUid::ROOT).ok_or(Error::::RootNetworkDoesNotExist)?; + ::Currency::increase_balance(&root_account, amount.into(), Precision::Exact)?; + Ok(()) + } + /// Drop a dissolving subnet's entries from the LEGACY per-subnet claimable rates. The /// live basket state is fund-level (no per-subnet entitlement), so only the legacy /// storage — kept for `migrate_seed_beta_basket` — needs per-subnet cleanup. diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 739a9216f8..55ac998887 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -535,11 +535,48 @@ fn test_root_basket_records_symmetric_protocol_flow() { // Now redeem the basket. The fund-level claim sells the staker's pro-rata slice of BOTH // holdings back to TAO, booking an outflow on each dest that nets the round-trip back // toward zero. + let root_account = SubtensorModule::get_subnet_account_id(NetUid::ROOT).unwrap(); + let account_b = SubtensorModule::get_subnet_account_id(netuid_b).unwrap(); + let account_c = SubtensorModule::get_subnet_account_id(netuid_c).unwrap(); + let root_balance_before = SubtensorModule::get_coldkey_balance(&root_account); + let balance_b_before = SubtensorModule::get_coldkey_balance(&account_b); + let balance_c_before = SubtensorModule::get_coldkey_balance(&account_c); + System::reset_events(); assert_ok!(SubtensorModule::claim_root_with_hotkey( RuntimeOrigin::signed(coldkey), hotkey )); + let root_balance_after = SubtensorModule::get_coldkey_balance(&root_account); + let source_debits = balance_b_before + .saturating_sub(SubtensorModule::get_coldkey_balance(&account_b)) + .saturating_add( + balance_c_before.saturating_sub(SubtensorModule::get_coldkey_balance(&account_c)), + ); + assert_eq!( + root_balance_after.saturating_sub(root_balance_before), + source_debits, + "all source-account debits must land in the root account" + ); + assert!(source_debits > TaoBalance::ZERO); + assert!( + System::events() + .iter() + .all(|record| !matches!(record.event, RuntimeEvent::Balances(_))), + "an internal basket claim must not emit per-holding Balances events" + ); + assert_eq!( + System::events() + .iter() + .filter(|record| matches!( + record.event, + RuntimeEvent::SubtensorModule(crate::Event::BasketClaimed { .. }) + )) + .count(), + 1, + "the claim must retain one aggregate event" + ); + let flow_b_after = SubnetProtocolFlow::::get(netuid_b); let flow_c_after = SubnetProtocolFlow::::get(netuid_c);