diff --git a/pallets/subtensor/src/coinbase/tao.rs b/pallets/subtensor/src/coinbase/tao.rs index d9c7d43590..4df6e2f858 100644 --- a/pallets/subtensor/src/coinbase/tao.rs +++ b/pallets/subtensor/src/coinbase/tao.rs @@ -4,16 +4,19 @@ /// - Reading colkey TAO balances /// - Access to subnet TAO reserves /// -use frame_support::traits::{ - Imbalance, LockableCurrency, WithdrawReasons, - fungible::Mutate, - tokens::{ - Fortitude, Precision, Preservation, - fungible::{Balanced, Credit, Inspect}, +use frame_support::{ + storage::{TransactionOutcome, with_transaction}, + traits::{ + Imbalance, LockableCurrency, WithdrawReasons, + fungible::Mutate, + tokens::{ + Fortitude, Precision, Preservation, + fungible::{Balanced, Credit, Inspect, Unbalanced}, + }, }, }; -use sp_runtime::traits::AccountIdConversion; -use sp_runtime::{DispatchError, DispatchResult}; +use sp_runtime::traits::{AccountIdConversion, CheckedAdd}; +use sp_runtime::{ArithmeticError, DispatchError, DispatchResult}; use subtensor_runtime_common::{NetUid, TaoBalance}; use super::*; @@ -237,6 +240,51 @@ impl Pallet { ) } + /// Atomically move TAO from one or more accounts into one destination without emitting balance + /// events. + /// + /// The requested debits and aggregate credit cancel without changing total issuance. Normal + /// dust handling can still reduce issuance if a source account is reaped. The nested storage + /// transaction rolls every debit (including dust handling) back if any debit or the final + /// credit fails. + pub(crate) fn settle_tao_balances( + debits: &[(T::AccountId, BalanceOf)], + destination: &T::AccountId, + ) -> Result, DispatchError> { + let amount_to_credit = debits + .iter() + .try_fold(BalanceOf::::zero(), |total, (_, amount)| { + total.checked_add(amount).ok_or(ArithmeticError::Overflow) + })?; + + if amount_to_credit.is_zero() { + return Ok(amount_to_credit); + } + + with_transaction(|| { + for (source, amount) in debits { + if let Err(err) = ::Currency::decrease_balance( + source, + *amount, + Precision::Exact, + Preservation::Expendable, + Fortitude::Polite, + ) { + return TransactionOutcome::Rollback(Err(err)); + } + } + + match ::Currency::increase_balance( + destination, + amount_to_credit, + Precision::Exact, + ) { + Ok(_) => TransactionOutcome::Commit(Ok(amount_to_credit)), + Err(err) => TransactionOutcome::Rollback(Err(err)), + } + }) + } + /// Create TAO and return the imbalance. /// /// The mint workflow is following: diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index ff277c8e19..28e1bfcab6 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -602,6 +602,7 @@ impl Pallet { // must also be credited to the root reserves. let mut root_slot_tao: u64 = 0; let mut swapped_tao: u64 = 0; + let mut tao_debits: Vec<(T::AccountId, TaoBalance)> = Vec::new(); for (netuid, slot_alpha) in holdings.iter() { // This staker's pro-rata slice of the holding: slot_alpha * owed / P. @@ -625,10 +626,12 @@ impl Pallet { } // Sell the slice to TAO. - let tao = match Self::sell_basket_alpha_for_root_tao(*netuid, take.into()) { - Ok(tao) => tao, - Err(err) => return TransactionOutcome::Rollback(Err(err)), - }; + let (source_account, tao) = + match Self::sell_basket_alpha_for_settlement(*netuid, take.into()) { + Ok(settlement) => settlement, + Err(err) => return TransactionOutcome::Rollback(Err(err)), + }; + tao_debits.push((source_account, tao)); // Record root sell (reduces protocol cost). SubnetRootSellTao::::mutate(*netuid, |total| { @@ -649,6 +652,21 @@ impl Pallet { return TransactionOutcome::Rollback(Ok(0)); } + // Settle every source debit and the aggregate root credit atomically, without balance + // events or an untracked issuance imbalance. + if swapped_tao > 0 { + let Some(root_account) = Self::get_subnet_account_id(NetUid::ROOT) else { + return TransactionOutcome::Rollback(Err( + Error::::RootNetworkDoesNotExist.into() + )); + }; + if let Err(err) = Self::settle_tao_balances(&tao_debits, &root_account) + .inspect_err(|err| log::error!("Error settling basket TAO to root: {err:?}")) + { + 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,13 +1050,27 @@ impl Pallet { holding_alpha, ); - let tao = match Self::sell_basket_alpha_for_root_tao(netuid, holding_alpha) { - Ok(tao) => tao, - Err(err) => { - log::error!("Error converting basket holding to root: {err:?}"); - return TransactionOutcome::Rollback(Err(err)); - } + let (source_account, tao) = + match Self::sell_basket_alpha_for_settlement(netuid, holding_alpha) { + Ok(settlement) => settlement, + Err(err) => { + log::error!("Error converting basket holding to root: {err:?}"); + return TransactionOutcome::Rollback(Err(err)); + } + }; + + let Some(root_account) = Self::get_subnet_account_id(NetUid::ROOT) else { + return TransactionOutcome::Rollback(Err( + Error::::RootNetworkDoesNotExist.into() + )); }; + if let Err(err) = Self::settle_tao_balances(&[(source_account, tao)], &root_account) + .inspect_err(|err| { + log::error!("Error settling converted basket TAO to root: {err:?}") + }) + { + 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( @@ -1060,13 +1092,14 @@ 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 and returns the source-account debit for the + /// caller's eventual aggregate settlement. The alpha must already have been removed from the + /// escrow position. Protocol outflow accounting stays in the caller's surrounding storage + /// transaction with the eventual TAO settlement. + fn sell_basket_alpha_for_settlement( netuid: NetUid, alpha: AlphaBalance, - ) -> Result { + ) -> Result<(T::AccountId, TaoBalance), DispatchError> { let out = Self::swap_alpha_for_tao( netuid, alpha, @@ -1075,15 +1108,12 @@ 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)?; Self::record_protocol_outflow(netuid, out.amount_paid_out); - Ok(out.amount_paid_out) + Ok((subnet_account, out.amount_paid_out)) } /// Drop a dissolving subnet's entries from the LEGACY per-subnet claimable rates. The diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 739a9216f8..c17f25173a 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -6,7 +6,7 @@ use crate::{ BasketClaimed, BasketRate, BasketShares, BurnIncreaseMult, DefaultMinRootClaimAmount, Error, Keys, MAX_ROOT_CLAIM_THRESHOLD, NetworksAdded, NumStakingColdkeys, RootClaimableThreshold, StakingColdkeys, StakingColdkeysByIndex, SubnetAlphaIn, SubnetMovingPrice, SubnetProtocolFlow, - SubnetTAO, SubnetworkN, Tempo, TotalStake, Uids, Weights, + SubnetTAO, SubnetworkN, Tempo, TotalIssuance, TotalStake, Uids, Weights, }; use approx::assert_abs_diff_eq; use frame_support::dispatch::{DispatchClass, GetDispatchInfo, RawOrigin}; @@ -535,11 +535,52 @@ 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); + let balances_issuance_before = Balances::total_issuance(); + let subtensor_issuance_before = TotalIssuance::::get(); + 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_eq!(Balances::total_issuance(), balances_issuance_before); + assert_eq!(TotalIssuance::::get(), subtensor_issuance_before); + 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); diff --git a/pallets/subtensor/src/tests/tao.rs b/pallets/subtensor/src/tests/tao.rs index 7d4be6c963..04e119326e 100644 --- a/pallets/subtensor/src/tests/tao.rs +++ b/pallets/subtensor/src/tests/tao.rs @@ -43,6 +43,58 @@ fn total_balance(account: &U256) -> TaoBalance { Balances::total_balance(account) } +#[test] +fn test_settle_tao_balances_rolls_back_debits_when_credit_fails() { + new_test_ext(1).execute_with(|| { + let source = U256::from(1); + let destination = U256::from(10_001); + let amount = ExistentialDeposit::get() - TaoBalance::from(1); + + let source_before = total_balance(&source); + let destination_before = total_balance(&destination); + let balances_ti_before = balances_total_issuance(); + let subtensor_ti_before = subtensor_total_issuance(); + + // The destination does not exist, so an exact credit below ED must fail after the debit. + assert!(SubtensorModule::settle_tao_balances(&[(source, amount)], &destination).is_err()); + + assert_eq!(total_balance(&source), source_before); + assert_eq!(total_balance(&destination), destination_before); + assert_eq!(balances_total_issuance(), balances_ti_before); + assert_eq!(subtensor_total_issuance(), subtensor_ti_before); + }); +} + +#[test] +fn test_settle_tao_balances_rolls_back_prior_debits_when_later_debit_fails() { + new_test_ext(1).execute_with(|| { + let funded_source = U256::from(1); + let empty_source = U256::from(10_002); + let destination = U256::from(2); + let amount = ExistentialDeposit::get(); + + let funded_source_before = total_balance(&funded_source); + let empty_source_before = total_balance(&empty_source); + let destination_before = total_balance(&destination); + let balances_ti_before = balances_total_issuance(); + let subtensor_ti_before = subtensor_total_issuance(); + + assert!( + SubtensorModule::settle_tao_balances( + &[(funded_source, amount), (empty_source, amount)], + &destination, + ) + .is_err() + ); + + assert_eq!(total_balance(&funded_source), funded_source_before); + assert_eq!(total_balance(&empty_source), empty_source_before); + assert_eq!(total_balance(&destination), destination_before); + assert_eq!(balances_total_issuance(), balances_ti_before); + assert_eq!(subtensor_total_issuance(), subtensor_ti_before); + }); +} + // ---------------------------------------------------- // transfer_tao // ----------------------------------------------------