Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions pallets/subtensor/src/staking/claim_root.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -625,7 +626,7 @@ impl<T: Config> Pallet<T> {
}

// 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)),
};
Expand All @@ -649,6 +650,15 @@ impl<T: Config> Pallet<T> {
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(
Expand Down Expand Up @@ -1032,14 +1042,18 @@ impl<T: Config> Pallet<T> {
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:?}");
return TransactionOutcome::Rollback(Err(err));
}
};

if let Err(err) = Self::credit_root_account_without_event(tao) {
Comment on lines 1050 to +1053

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Document and test the dissolution event change

This shared conversion path now also suppresses the Balances::Transfer event during subnet dissolution, although the PR description and new regression coverage discuss root claims only. Because runtime events are externally observable, either preserve the prior transfer behavior here or explicitly document this additional behavior change and add dissolution-path coverage for the intended event set.

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,
Expand All @@ -1060,10 +1074,11 @@ impl<T: Config> Pallet<T> {
.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<TaoBalance, DispatchError> {
Expand All @@ -1075,17 +1090,32 @@ impl<T: Config> Pallet<T> {
)
.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::<T>::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::<T>::SubnetNotExists)?;
<T as Config>::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::<T>::RootNetworkDoesNotExist)?;
<T as Config>::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.
Expand Down
37 changes: 37 additions & 0 deletions pallets/subtensor/src/tests/claim_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Test>::get(netuid_b);
let flow_c_after = SubnetProtocolFlow::<Test>::get(netuid_c);

Expand Down
Loading