From f35d5bf6d8889e931c40fb726cd883a700a2e6af Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Tue, 4 Aug 2026 08:39:38 -0700 Subject: [PATCH 1/2] fix: aggregate root claim balance settlement --- pallets/subtensor/src/staking/claim_root.rs | 52 ++++++++++++++++----- pallets/subtensor/src/tests/claim_root.rs | 37 +++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) 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); From 3d2f2c5057769d1b66e797d8b47c44245c9bcf0e Mon Sep 17 00:00:00 2001 From: UnArbosSix Date: Thu, 6 Aug 2026 09:10:59 -0700 Subject: [PATCH 2/2] atomically settle aggregate TAO balance moves --- pallets/subtensor/src/coinbase/tao.rs | 64 +++++++++++++-- pallets/subtensor/src/staking/claim_root.rs | 90 ++++++++++----------- pallets/subtensor/src/tests/claim_root.rs | 6 +- pallets/subtensor/src/tests/tao.rs | 52 ++++++++++++ 4 files changed, 158 insertions(+), 54 deletions(-) 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 65208f1e54..28e1bfcab6 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -1,7 +1,6 @@ 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; @@ -603,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. @@ -626,10 +626,12 @@ impl Pallet { } // Sell the slice to TAO. - let tao = match Self::sell_basket_alpha_and_debit_subnet(*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| { @@ -650,13 +652,19 @@ 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)); + // 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 @@ -1042,15 +1050,25 @@ impl Pallet { 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:?}"); - 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)); + } + }; - if let Err(err) = Self::credit_root_account_without_event(tao) { + 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)); } @@ -1074,14 +1092,14 @@ impl Pallet { .is_ok() } - /// 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( + /// 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, @@ -1092,28 +1110,10 @@ impl Pallet { 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(()) + 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 55ac998887..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}; @@ -541,6 +541,8 @@ fn test_root_basket_records_symmetric_protocol_flow() { 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), @@ -559,6 +561,8 @@ fn test_root_basket_records_symmetric_protocol_flow() { "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() 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 // ----------------------------------------------------